@aswless_854771076/ai_short_studio_cli 0.1.64 → 0.1.65

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.
Files changed (36) hide show
  1. package/README.md +35 -2
  2. package/dist/client/api-client.js +19 -2
  3. package/dist/commands/canvas/asset/select.d.ts +2 -1
  4. package/dist/commands/canvas/asset/select.js +22 -3
  5. package/dist/commands/canvas/edge/list.js +4 -1
  6. package/dist/commands/canvas/node/list.d.ts +4 -0
  7. package/dist/commands/canvas/node/list.js +43 -2
  8. package/dist/commands/canvas/node/run.d.ts +3 -0
  9. package/dist/commands/canvas/node/run.js +103 -5
  10. package/dist/commands/config/ai-provider/capabilities.d.ts +26 -0
  11. package/dist/commands/config/ai-provider/capabilities.js +90 -0
  12. package/dist/commands/config/ai-provider/get.d.ts +21 -0
  13. package/dist/commands/config/ai-provider/get.js +21 -0
  14. package/dist/commands/config/ai-provider/list.d.ts +20 -0
  15. package/dist/commands/config/ai-provider/list.js +19 -0
  16. package/dist/commands/config/ai-provider/models.d.ts +21 -0
  17. package/dist/commands/config/ai-provider/models.js +21 -0
  18. package/dist/commands/config/ai-provider/set.d.ts +40 -0
  19. package/dist/commands/config/ai-provider/set.js +65 -0
  20. package/dist/commands/config/ai-provider/test.d.ts +24 -0
  21. package/dist/commands/config/ai-provider/test.js +24 -0
  22. package/dist/commands/config/set.d.ts +1 -0
  23. package/dist/commands/config/set.js +55 -2
  24. package/dist/commands/task/wait.d.ts +1 -0
  25. package/dist/commands/task/wait.js +6 -1
  26. package/dist/domain/ai-provider-capabilities.d.ts +109 -0
  27. package/dist/domain/ai-provider-capabilities.js +180 -0
  28. package/dist/domain/ai-provider-command-helpers.d.ts +27 -0
  29. package/dist/domain/ai-provider-command-helpers.js +55 -0
  30. package/dist/domain/vvicat-api.d.ts +42 -2
  31. package/dist/domain/vvicat-api.js +56 -3
  32. package/dist/preflight.js +9 -0
  33. package/package.json +1 -1
  34. package/skills/using-vvicat-ai-short-studio-cli/SKILL.md +15 -1
  35. package/skills/using-vvicat-ai-short-studio-cli/references/commands/project.md +20 -1
  36. package/skills/using-vvicat-ai-short-studio-cli/references/project-and-canvas.md +1 -1
package/README.md CHANGED
@@ -45,11 +45,29 @@ CLI 只把 refresh token 保存到系统钥匙串。短期 access token 会写
45
45
  - `ai-short-studio canvas folder`:画布资产目录管理(5 预设 + 用户子目录 + 软删 + 跨项目复制)。
46
46
  - `ai-short-studio canvas episode`:列出或创建无限画布分集。其他画布命令用 `--episode <ID|集数>` 选择分集。
47
47
  - `ai-short-studio task`:异步任务读取、批量等待和取消。
48
- - `ai-short-studio config`:读取管理员发布的全局模型,管理个人默认模型、能力默认值和并发配置;CLI 会拒绝写入 Provider 或模型目录。
48
+ - `ai-short-studio config`:读取管理员发布的全局模型,管理个人默认模型和能力默认值;CLI 会拒绝写入 Provider、模型目录或工作流并发。
49
+ - `ai-short-studio config ai-provider`(管理员):列出、查看、部分更新、连通性测试、拉取模型目录与上报能力诊断。仅供管理员账号使用。
49
50
 
50
51
  CLI 不提供分集删除或任务人工复核提交;这两类需要确认生产资产或保留范围的操作继续在网页端完成。
51
52
 
52
- `config get` 返回管理员发布的全局模型与个人默认值,不返回供应商密钥。已保存的默认模型失效时,服务端会改用同类型的首个已启用模型,并用实时选项的第一项补齐该模型缺失的能力默认值。Comfly 和 RunningHub 的凭据、服务地址与工作流均由服务端维护;CLI 预检只检查默认模型及能力选项。`config set` 会拒绝写入任何供应商或模型目录。
53
+ `config get` 返回管理员发布的全局模型与个人默认值,不返回供应商密钥。已保存的默认模型失效时,服务端会改用同类型的首个已启用模型,并用实时选项的第一项补齐该模型缺失的能力默认值。Comfly 和 RunningHub 的凭据、服务地址与工作流均由服务端维护;CLI 预检只检查默认模型及能力选项。`config set` 会拒绝写入任何供应商、模型目录或工作流并发;工作流并发由管理员在用户管理中配置。
54
+
55
+ `config ai-provider` 把管理员侧的 AI Provider 管理从网页端下沉到 CLI,方便部署/CI 与 Agent 复核:
56
+
57
+ ```bash
58
+ ai-short-studio config ai-provider list --json
59
+ ai-short-studio config ai-provider get --id <providerId> --json
60
+ ai-short-studio config ai-provider set --id <providerId> [--base-url <url>] [--api-key-stdin] [--enabled true|false] [--preset <name>] [--json]
61
+ ai-short-studio config ai-provider test --id <providerId> --json
62
+ ai-short-studio config ai-provider models --id <providerId> --json
63
+ ai-short-studio config ai-provider capabilities --json
64
+ ```
65
+
66
+ - `--api-key-stdin` 从隐藏 stdin 读取密钥,禁止写入 shell 历史或日志;非交互环境使用 `VVICAT_AI_PROVIDER_<ID>_API_KEY` 显式注入。
67
+ - `--preset <name>` 调用服务端内置 Preset(如 `openai-compatible`、`runninghub`、`comfly`)一键种子。
68
+ - `test` 输出延迟与 HTTP 状态,不消耗配额外的 token。`models` 只在 `enabled` 且最近一次 `test` 成功时返回目录。
69
+ - `capabilities` 报告当前生效的私有/LAN baseUrl 白名单(来自环境变量 `VVICAT_ALLOW_PRIVATE_PROVIDER_BASE_URLS`,CSV `host:port`)、已注册 Provider 数量、未配置 Provider 类型与缺失项,方便 Agent 明确自己能做什么、缺什么。
70
+ - 设置 Provider `baseUrl` 时若目标是私有/LAN 主机(如 `http://127.0.0.1:8188`、`http://192.168.3.30:8188` 本地 ComfyUI),必须在运行 Next.js 的环境里把 `VVICAT_ALLOW_PRIVATE_PROVIDER_BASE_URLS=127.0.0.1:8188,192.168.3.30:8188` 显式打开;公网 URL 仍强制 HTTPS 与公网 IP 校验。
53
71
 
54
72
  ```bash
55
73
  ai-short-studio canvas edge add --project <projectId> --from <sourceNodeId>:<sourceHandle> --to <targetNodeId>:<targetHandle> --json
@@ -108,12 +126,16 @@ ai-short-studio task wait --task <task1> --task <task2> --concurrency 4 --json
108
126
 
109
127
  `canvas node update` 的 `--set key=<JSON>`、`--unset key`、`--title`、`--x` 和 `--y` 只修改指定字段;旧 `--config` 仍兼容。`update-many` 在一次画布版本校验中原子更新多个节点。结构化生成资源仍必须使用 `edit-resource` 或 `edit-shot`,不能通过字段更新改写 artifact。批量执行与等待默认并发为 4、上限为 16;Agent 应优先单进程批处理,避免为每个节点启动一个 CLI 进程。
110
128
 
129
+ 节点执行若返回 `MODEL_PRICING_NOT_CONFIGURED`,表示所选模型能力参数没有匹配的已发布价格档位;应修正模型参数或由管理员发布价格,不能直接重复提交。
130
+
111
131
  使用 `canvas node edit-shot <nodeId> --project <projectId> --file shot.json` 编辑镜头。命令会追加并选中不可变的人工 DOCUMENT 版本,不允许修改 `shotKey`、`shotIndex` 或来源身份,旧版本继续可选。
112
132
 
113
133
  使用 `canvas node edit-resource <nodeId> --project <projectId> --file resource.json` 编辑剧本、人物、人物视觉、场景、子场景或道具结构化资源。命令只替换完整父文档中的目标实体,追加并选中人工版本;名称、资源键、ID 和所属关系不可修改,兄弟资源节点保持原选版。资源版本写入不属于 `canvas apply` 图补丁,409 后需重新读取画布与当前选版。
114
134
 
115
135
  CLI 通过 `/api/v1/bootstrap` 完成版本、连通性与 Supabase 公共配置预检,并直接使用现有 `/api/projects`、`/api/canvas/node-types`、项目画布与资产路由。
116
136
 
137
+ > 2026-09-05:资产管理 SSOT Phase 1.1 — 删除 5 个已迁移的 `/api/asset-hub/*` 路由(`generate-image` / `modify-image` / `select-image` / `undo-image` / `update-asset-label`),并移除 5 个多方法路由的 GET handler。CLI 不直接调用这些端点,因此不影响 CLI 行为。详见 `docs/asset-management-api-matrix.md`(Phase 1.1 段)。
138
+
117
139
  `canvas node types [kind] --json` 同时兼容节点目录 schema v1/v2,并原样返回动态节点的 `source`、`definitionId` 和 `definitionVersionId`。后台发布后重新执行该命令即可读取新目录;归档节点不能新建,但已有实例仍按画布中的固定版本 schema 更新和执行。CLI 创建节点只提交 `kind` 与配置,版本由服务端固定,不能由客户端伪造。
118
140
 
119
141
  新建视频生成节点按实时模型能力执行:首尾帧模型使用 `start-frame` + `end-frame`,普通图生视频使用 `start-frame`,图片参考使用 `references`。Ark 两个 Seedance 2.0 和 StarRouter 两个内置 Dreamina Seedance 2.0 可使用 `reference-videos` 和 `reference-audios`,上限为 9 张参考图、3 个参考视频和 3 个参考音频;部署方启用的内部 RunningHub MiniMax H3 模型支持纯文本零媒体输入,参考图、参考视频和独立参考音频范围分别为 0–9、0–2 和 0–2。RunningHub 参考视频只传画面,需要声音时须单独连接参考音频。它的实时 `durationOptions` 为 5–15 秒的每个整数,服务端把 1–4 秒兼容输入归一为 5 秒。其他 StarRouter 和自定义模型不会按名称猜能力。参考音频不能作为唯一媒体输入;纯文本指不连接任何媒体,不是只连参考音频。多模态参考不能与首尾帧混用。历史节点未声明新版输入语义时继续按原多图参考逻辑执行,不会被读取或保存自动迁移。
@@ -208,3 +230,14 @@ npm run release:ci:test -- --version 0.1.1-beta.0
208
230
  ```
209
231
 
210
232
  `release:ci` 会依次运行测试和类型检查、发布 npm、等待 registry 可见,再从 npm 安装该精确版本并验证 CLI 与配套 Skill。无需配置自有 Runner;只有访问内网或需要固定出口 IP 时才使用自有构建集群。
233
+
234
+
235
+
236
+ ## 包身份与升级协议
237
+
238
+ CLI 与服务端 `/api/v1/bootstrap` 必须就包身份(`name` + `latestVersion`)达成一致,否则 `preflight` 会抛"服务端返回的 CLI 包名与当前安装包不一致"并阻塞所有 CLI 命令。
239
+
240
+ - 仓库内 `package.json` 的 `name` 字段硬绑定为 `@aswless_854771076/ai_short_studio_cli`,pre-commit guard `npm run check:cli-package-identity` 强制。
241
+ - 升级发布流程见 `.github/workflows/cli-publish.yml`(push main 自动 `npm run release -- --verify`)。
242
+ - 手动 release SOP 见本仓库 `.agents/skills/vvicat-cli-adapter/references/cli-release-sop.md`。
243
+ - 服务端 `/api/v1/bootstrap` 现暴露 `data.cliDrift` 字段,drift 时返回 `reason` + `manifest` + `npm` 三组对照信息;Agent 进入新环境跑 `config ai-provider capabilities --json` 即可看到。
@@ -18,8 +18,21 @@ export class ApiClient {
18
18
  if (response.status === 401 && options.auth !== false) {
19
19
  response = await this.send(path, options, await this.session.refreshAccessToken());
20
20
  }
21
- if (!response.ok)
21
+ if (!response.ok) {
22
+ // root cause: undici fetch close + @napi-rs/keyring handle race;
23
+ // setImmediate alone not enough, need real delay.
24
+ //
25
+ // 抛出前必须给 undici 真实时间完成内部 cleanup:在 Node 21+ / Windows 上,
26
+ // setImmediate 只 flush 微任务队列,但 undici 内部的 socket close 是异步且
27
+ // 至少需要几十毫秒完成。若 await response.json() / apiError(response) 解析
28
+ // body 时 undici 已经发出 close 信号,但底层 libuv handle(被
29
+ // @napi-rs/keyring 持有)尚未 close,就会触发
30
+ // `UV_HANDLE_CLOSING assertion failed`(src\win\async.c:76),进程异常
31
+ // 退出 3221226505。先 sleep 50ms 让 undici 内部 cleanup 完成,再读 body
32
+ // / throw 才是安全的。详见 preflight.ts 的同源注释。
33
+ await new Promise((resolve) => setTimeout(resolve, 50));
22
34
  throw await apiError(response);
35
+ }
23
36
  if (response.status === 204)
24
37
  return undefined;
25
38
  return await response.json();
@@ -30,8 +43,12 @@ export class ApiClient {
30
43
  if (response.status === 401 && options.auth !== false) {
31
44
  response = await this.send(path, options, await this.session.refreshAccessToken());
32
45
  }
33
- if (!response.ok)
46
+ if (!response.ok) {
47
+ // 同 request:throw 前需要真实 delay 让 undici 收尾,否则同样触发
48
+ // UV_HANDLE_CLOSING。response() 路径同样需要 sleep 50ms。
49
+ await new Promise((resolve) => setTimeout(resolve, 50));
34
50
  throw await apiError(response);
51
+ }
35
52
  return response;
36
53
  }
37
54
  async send(path, options, accessToken) {
@@ -1,11 +1,12 @@
1
1
  import { BaseCommand } from '../../../base-command.js';
2
2
  export default class CanvasAssetSelect extends BaseCommand {
3
3
  static args: {
4
- assetId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
4
+ assetId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
5
  };
6
6
  static flags: {
7
7
  project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
8
  version: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ asset: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,8 +1,27 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../../base-command.js';
3
3
  import { commandApi, projectFlag } from '../../../command-helpers.js';
4
+ // 同时支持 positional ASSETID 与 --asset <id>:
5
+ // - 历史 CLI 调用方习惯写 `... select-version <assetId> --version <v>`。
6
+ // - bundled skill 文档 (skills/using-vvicat-ai-short-studio-cli/references/commands/assets-and-tasks.md:9)
7
+ // 写的是 `--asset <assetId>`,agent 容易按文档写错。
8
+ // 两种调用方式都接受,重复给出时报错。
4
9
  export default class CanvasAssetSelect extends BaseCommand {
5
- static args = { assetId: Args.string({ required: true }) };
6
- static flags = { ...BaseCommand.baseFlags, project: projectFlag, version: Flags.string({ required: true }) };
7
- async run() { const { args, flags } = await this.parse(CanvasAssetSelect); const { api } = await commandApi(flags); this.print(await api.selectAssetVersion(flags.project, args.assetId, flags.version)); }
10
+ static args = { assetId: Args.string({ required: false, description: '资产 ID(位置参数,与 --asset 二选一)' }) };
11
+ static flags = {
12
+ ...BaseCommand.baseFlags,
13
+ project: projectFlag,
14
+ version: Flags.string({ required: true }),
15
+ asset: Flags.string({ description: '资产 ID(与位置参数 ASSETID 二选一)' }),
16
+ };
17
+ async run() {
18
+ const { args, flags } = await this.parse(CanvasAssetSelect);
19
+ const assetId = args.assetId ?? flags.asset;
20
+ if (!assetId)
21
+ throw new Error('请提供 assetId 作为位置参数或 --asset');
22
+ if (args.assetId && flags.asset)
23
+ throw new Error('请提供 assetId 作为位置参数或 --asset(不能同时给两个)');
24
+ const { api } = await commandApi(flags);
25
+ this.print(await api.selectAssetVersion(flags.project, assetId, flags.version));
26
+ }
8
27
  }
@@ -1,10 +1,13 @@
1
1
  import { BaseCommand } from '../../../base-command.js';
2
2
  import { commandApi, projectFlag } from '../../../command-helpers.js';
3
+ // 输出保持 `{edges: [...]}` 与 `canvas node list --json` 对称,方便 agent 统一处理。
4
+ // 历史版本直接 print 数组,agent 拿到的就是裸数组,结构与 nodes 不对称。
3
5
  export default class CanvasEdgeList extends BaseCommand {
4
6
  static flags = { ...BaseCommand.baseFlags, project: projectFlag };
5
7
  async run() {
6
8
  const { flags } = await this.parse(CanvasEdgeList);
7
9
  const { api } = await commandApi(flags);
8
- this.print(await api.listEdges(flags.project));
10
+ const edges = await api.listEdges(flags.project);
11
+ this.print({ edges });
9
12
  }
10
13
  }
@@ -2,6 +2,10 @@ import { BaseCommand } from '../../../base-command.js';
2
2
  export default class CanvasNodeList extends BaseCommand {
3
3
  static flags: {
4
4
  project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
5
+ kind: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ status: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ search: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'producer-node': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
5
9
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
10
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
11
  locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,10 +1,51 @@
1
+ import { Flags } from '@oclif/core';
1
2
  import { BaseCommand } from '../../../base-command.js';
2
3
  import { commandApi, projectFlag } from '../../../command-helpers.js';
4
+ // list 命令的客户端过滤与 inspect 的 matchesInspectNode(vvicat-api.ts:494) 行为类似,
5
+ // 但不复用服务端过滤,因为 list 返回的 CanvasNode 结构和 inspect 不完全一致。
6
+ // 输出保持 `{nodes: [...]}` 与原版本兼容。
7
+ function matchesListNode(node, filters) {
8
+ if (filters.kinds?.length && !filters.kinds.includes(node.kind))
9
+ return false;
10
+ if (filters.statuses?.length && !filters.statuses.includes(String(node.status ?? 'idle')))
11
+ return false;
12
+ if (filters.producerNodeId) {
13
+ const artifact = node.data?.artifact && typeof node.data.artifact === 'object' && !Array.isArray(node.data.artifact)
14
+ ? node.data.artifact
15
+ : {};
16
+ if (artifact.producerNodeId !== filters.producerNodeId)
17
+ return false;
18
+ }
19
+ if (filters.search) {
20
+ const query = filters.search.toLocaleLowerCase();
21
+ const artifact = node.data?.artifact && typeof node.data.artifact === 'object' && !Array.isArray(node.data.artifact)
22
+ ? node.data.artifact
23
+ : {};
24
+ const candidates = [node.id, node.title, artifact.name, artifact.resourceKey];
25
+ if (!candidates.some((value) => typeof value === 'string' && value.toLocaleLowerCase().includes(query)))
26
+ return false;
27
+ }
28
+ return true;
29
+ }
3
30
  export default class CanvasNodeList extends BaseCommand {
4
- static flags = { ...BaseCommand.baseFlags, project: projectFlag };
31
+ static flags = {
32
+ ...BaseCommand.baseFlags,
33
+ project: projectFlag,
34
+ kind: Flags.string({ description: '按节点 kind 过滤,可重复指定', multiple: true }),
35
+ status: Flags.string({ description: '按节点状态过滤,可重复指定', multiple: true }),
36
+ search: Flags.string({ description: '按节点 ID / 标题 / artifact 名称 / resourceKey 模糊搜索' }),
37
+ 'producer-node': Flags.string({ description: '按 artifact.producerNodeId 过滤' }),
38
+ };
5
39
  async run() {
6
40
  const { flags } = await this.parse(CanvasNodeList);
7
41
  const { api } = await commandApi(flags);
8
- this.print(await api.listNodes(flags.project));
42
+ const nodes = await api.listNodes(flags.project);
43
+ const filtered = nodes.filter((node) => matchesListNode(node, {
44
+ kinds: flags.kind,
45
+ statuses: flags.status,
46
+ search: flags.search,
47
+ producerNodeId: flags['producer-node'],
48
+ }));
49
+ this.print({ nodes: filtered });
9
50
  }
10
51
  }
@@ -9,6 +9,9 @@ export default class CanvasNodeRun extends BaseCommand {
9
9
  concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
10
  wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'max-wait-ms': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'max-attempts': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'auto-select-version': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
15
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
16
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
17
  locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -10,6 +10,18 @@ export default class CanvasNodeRun extends BaseCommand {
10
10
  concurrency: Flags.integer({ description: '批量提交并发数', default: 4, min: 1, max: 16 }),
11
11
  wait: Flags.boolean({ default: false }),
12
12
  interval: Flags.integer({ default: 1000 }),
13
+ 'max-wait-ms': Flags.integer({
14
+ description: '单次 waitTask 最大等待毫秒数;0 表示不超时(保留旧行为)。例如 --wait --max-wait-ms 180000 表示 3 分钟超时。',
15
+ default: 0,
16
+ }),
17
+ 'max-attempts': Flags.integer({
18
+ description: 'retryable 失败时,最大重新执行节点次数。0=不重试;>0 时配合 --max-wait-ms 使用。',
19
+ default: 0,
20
+ }),
21
+ 'auto-select-version': Flags.boolean({
22
+ description: '节点跑完且 task.result 含 assetId/versionId 时,自动调 canvas asset select-version 让下游 cvd 拿到 profile。默认 false,避免误选。',
23
+ default: false,
24
+ }),
13
25
  };
14
26
  async run() {
15
27
  const { args, flags } = await this.parse(CanvasNodeRun);
@@ -17,6 +29,11 @@ export default class CanvasNodeRun extends BaseCommand {
17
29
  if (!nodeIds.length)
18
30
  throw new Error('请提供 nodeId 或至少一个 --node');
19
31
  const { api } = await commandApi(flags);
32
+ // 简化:批量 + retry 的组合会让 race 复杂化。带 --node 多值 + --max-attempts > 0
33
+ // 时只对第一个节点生效并打印警告,保持职责简单。
34
+ if (flags.wait && flags['max-attempts'] > 0 && nodeIds.length > 1) {
35
+ this.log('warn: --max-attempts 仅对单节点生效;批量模式下自动忽略');
36
+ }
20
37
  const submissions = await mapConcurrent(nodeIds, flags.concurrency, async (nodeId) => ({
21
38
  nodeId,
22
39
  result: await api.runNode(flags.project, nodeId),
@@ -25,13 +42,94 @@ export default class CanvasNodeRun extends BaseCommand {
25
42
  this.print(nodeIds.length === 1 ? submissions[0].result : { submissions });
26
43
  return;
27
44
  }
45
+ const maxWaitMs = flags['max-wait-ms'] > 0 ? flags['max-wait-ms'] : Number.POSITIVE_INFINITY;
28
46
  const tasks = submissions.filter((item) => item.result.task?.id);
29
- const completed = await mapConcurrent(tasks, flags.concurrency, async (item) => ({
30
- nodeId: item.nodeId,
31
- task: await api.waitTask(item.result.task.id, flags.interval),
32
- }));
33
- this.print(nodeIds.length === 1 ? completed[0]?.task ?? submissions[0].result : { submissions, tasks: completed });
47
+ const completed = await mapConcurrent(tasks, flags.concurrency, async (item) => {
48
+ let currentSubmission = item.result;
49
+ let attempt = 0;
50
+ const maxAttempts = flags['max-attempts'] > 0 ? flags['max-attempts'] : 1;
51
+ // 职责分离:
52
+ // - waitTask 只负责按 interval 轮询并在 maxWaitMs 到达时抛 'wait timeout'。
53
+ // - 本循环负责 retry 决策(task.error?.retryable && attempt < maxAttempts 时
54
+ // cancel + 重新 runNode 并继续 wait)。
55
+ // 关键点:'wait timeout' 不应该被外层 catch 当作任务失败,也不应该触发整批
56
+ // mapConcurrent 的 reject(否则 agent 看到 exit 0 + 空输出)。这里把 timeout
57
+ // 视为"还在跑",让 while 继续下一轮 wait;只在 task.status === 'failed' 时
58
+ // 才根据 retryable 决定是否退。
59
+ // eslint-disable-next-line no-constant-condition
60
+ while (true) {
61
+ attempt += 1;
62
+ const taskId = currentSubmission.task?.id;
63
+ if (!taskId)
64
+ throw new Error(`节点 ${item.nodeId} 未返回 task id`);
65
+ let task;
66
+ try {
67
+ task = (await api.waitTask(taskId, flags.interval, { maxWaitMs }));
68
+ }
69
+ catch (error) {
70
+ if (error instanceof Error && error.message.startsWith('wait timeout')) {
71
+ // wait 还没拿到终态:视为任务仍在跑,消耗一次 attempt 后重新 wait。
72
+ // 即使 maxAttempts 已耗尽,只要不是真的 failed + retryable,就继续 wait,
73
+ // 直到任务真的结束(completed / failed / canceled)。这样长任务在
74
+ // 较小 maxWaitMs 下不会突然退出整批 mapConcurrent。
75
+ if (attempt > maxAttempts * 8) {
76
+ // 防御性兜底:避免无限循环(例如 waitTask 永远拿不到终态)。
77
+ // 仅在 attempt 数远超 maxAttempts 时才退,并把 timeout 信息透出。
78
+ const err = new Error(`节点 ${item.nodeId} 等待超时(attempt=${attempt})`);
79
+ Object.assign(err, { taskId, attempts: attempt });
80
+ throw err;
81
+ }
82
+ continue;
83
+ }
84
+ throw error;
85
+ }
86
+ if (task.status === 'completed') {
87
+ return { nodeId: item.nodeId, task, attempts: attempt };
88
+ }
89
+ if (task.status === 'failed' && task.error?.retryable === true && attempt < maxAttempts) {
90
+ // 取消旧任务后重新执行节点,再重新跑一次 wait。waitTask 不做 cancel/retry 决策。
91
+ try {
92
+ await api.cancelTask(taskId);
93
+ }
94
+ catch { /* 旧任务可能已终态,忽略 */ }
95
+ const newSubmission = await api.runNode(flags.project, item.nodeId);
96
+ currentSubmission = newSubmission;
97
+ continue;
98
+ }
99
+ return { nodeId: item.nodeId, task, attempts: attempt };
100
+ }
101
+ });
102
+ this.print(nodeIds.length === 1 ? completed[0] : { submissions, tasks: completed });
34
103
  if (completed.some((item) => item.task.status !== 'completed'))
35
104
  this.error('部分任务未成功完成', { exit: 8 });
105
+ // --auto-select-version:节点成功跑完后,若 task.result 含 assetId + versionId,
106
+ // 自动调 canvas asset select-version,让下游 cvd 节点能读到 character profile。
107
+ // 服务端可能已经物化 resource,但 selectedOutputVersionId 不一定自动 set,所以
108
+ // 这里走显式 PATCH;只对 completed 的任务生效,避免在 failed 任务上误选。
109
+ if (flags['auto-select-version']) {
110
+ const selectedVersions = [];
111
+ for (const item of completed) {
112
+ if (item.task.status !== 'completed')
113
+ continue;
114
+ const assetId = item.task.result?.assetId;
115
+ const versionId = item.task.result?.versionId;
116
+ if (!assetId || !versionId)
117
+ continue;
118
+ try {
119
+ await api.selectAssetVersion(flags.project, assetId, versionId);
120
+ selectedVersions.push({ nodeId: item.nodeId, assetId, versionId });
121
+ }
122
+ catch (error) {
123
+ // 选版失败不阻塞主流程;仅 warn。
124
+ this.log(`warn: 节点 ${item.nodeId} 自动 select-version 失败:${error instanceof Error ? error.message : String(error)}`);
125
+ }
126
+ }
127
+ if (selectedVersions.length) {
128
+ // 在已经 print 过的 completed 输出之外,额外把 selectedVersion 列表输出,
129
+ // 让 agent 能立即知道哪个 nodeId 对应哪个 assetId/versionId。
130
+ // 不影响默认行为,仅 --auto-select-version=true 时出现。
131
+ this.log(`auto-selected versions: ${JSON.stringify(selectedVersions)}`);
132
+ }
133
+ }
36
134
  }
37
135
  }
@@ -0,0 +1,26 @@
1
+ import { BaseCommand } from '../../../base-command.js';
2
+ /**
3
+ * vvicat config ai-provider capabilities
4
+ *
5
+ * 聚合视图:agent 视角的「当前能跑什么 / 缺什么」。
6
+ *
7
+ * 数据来源:
8
+ * - GET /api/admin/ai-providers → 仓库 provider 目录
9
+ * - GET /api/user/api-config → catalog + defaultModels + capabilityDefaults
10
+ * - POST /api/admin/ai-providers/{id}/test (每个 enabled provider) → reachability
11
+ *
12
+ * 输出 CapabilitiesReport JSON(schema 见 src/domain/ai-provider-capabilities.ts)。
13
+ */
14
+ export default class ConfigAiProviderCapabilities extends BaseCommand {
15
+ static description: string;
16
+ static flags: {
17
+ 'no-reachability': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ 'reachability-concurrency': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
19
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
22
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
23
+ episode: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
+ };
25
+ run(): Promise<void>;
26
+ }
@@ -0,0 +1,90 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base-command.js';
3
+ import { commandApi } from '../../../command-helpers.js';
4
+ import { buildCapabilitiesReport } from '../../../domain/ai-provider-capabilities.js';
5
+ import { extractCapabilityDefaults, extractCatalogModels, extractDefaultModels } from '../../../domain/ai-provider-command-helpers.js';
6
+ /**
7
+ * vvicat config ai-provider capabilities
8
+ *
9
+ * 聚合视图:agent 视角的「当前能跑什么 / 缺什么」。
10
+ *
11
+ * 数据来源:
12
+ * - GET /api/admin/ai-providers → 仓库 provider 目录
13
+ * - GET /api/user/api-config → catalog + defaultModels + capabilityDefaults
14
+ * - POST /api/admin/ai-providers/{id}/test (每个 enabled provider) → reachability
15
+ *
16
+ * 输出 CapabilitiesReport JSON(schema 见 src/domain/ai-provider-capabilities.ts)。
17
+ */
18
+ export default class ConfigAiProviderCapabilities extends BaseCommand {
19
+ static description = '聚合视图:当前能跑哪些模型,缺什么配置';
20
+ static flags = {
21
+ ...BaseCommand.baseFlags,
22
+ 'no-reachability': Flags.boolean({
23
+ description: '跳过连通性探测(更快,但 reachability 全部为 unknown)',
24
+ default: false,
25
+ }),
26
+ 'reachability-concurrency': Flags.integer({
27
+ description: '并发探测 provider 数(默认 4)',
28
+ default: 4,
29
+ }),
30
+ };
31
+ async run() {
32
+ const { flags } = await this.parse(ConfigAiProviderCapabilities);
33
+ const { api } = await commandApi(flags);
34
+ const [providersPayload, config] = await Promise.all([
35
+ api.listAiProviders(),
36
+ api.config(),
37
+ ]);
38
+ const providerRows = (providersPayload.items ?? []);
39
+ const reachability = {};
40
+ if (!flags['no-reachability']) {
41
+ const enabled = providerRows.filter((p) => p.enabled);
42
+ const concurrency = Math.max(1, Math.min(flags['reachability-concurrency'], enabled.length || 1));
43
+ const results = await probeConcurrently(enabled, concurrency, api.testAiProvider.bind(api));
44
+ for (const [id, result] of results)
45
+ reachability[id] = result;
46
+ }
47
+ const report = buildCapabilitiesReport({
48
+ baseUrl: flags['base-url'] ?? '',
49
+ providers: providerRows.map((p) => ({
50
+ id: p.id,
51
+ name: p.name,
52
+ type: p.type,
53
+ enabled: p.enabled,
54
+ baseUrl: p.baseUrl,
55
+ apiKeyMask: p.apiKeyMask,
56
+ hasApiKey: p.hasApiKey,
57
+ protocolMode: p.protocolMode,
58
+ lastTestResult: p.lastTestResult,
59
+ lastResponseMs: p.lastResponseMs,
60
+ upstreamModels: p.upstreamModels,
61
+ upstreamModelsSyncedAt: p.upstreamModelsSyncedAt,
62
+ })),
63
+ catalogModels: extractCatalogModels(config),
64
+ defaultModels: extractDefaultModels(config),
65
+ capabilityDefaults: extractCapabilityDefaults(config),
66
+ reachability,
67
+ });
68
+ this.print(report);
69
+ }
70
+ }
71
+ async function probeConcurrently(providers, concurrency, probe) {
72
+ const out = [];
73
+ let cursor = 0;
74
+ const workers = Array.from({ length: Math.min(concurrency, Math.max(providers.length, 1)) }, async () => {
75
+ while (cursor < providers.length) {
76
+ const index = cursor++;
77
+ const provider = providers[index];
78
+ try {
79
+ const result = await probe(provider.id);
80
+ const detail = result.steps?.map((step) => `${step.status ?? 'unknown'}/${step.httpStatus ?? '?'}`).join(', ') ?? '';
81
+ out.push([provider.id, { ok: result.success === true, detail, durationMs: result.durationMs }]);
82
+ }
83
+ catch (error) {
84
+ out.push([provider.id, { ok: false, detail: error instanceof Error ? error.message : String(error) }]);
85
+ }
86
+ }
87
+ });
88
+ await Promise.all(workers);
89
+ return out;
90
+ }
@@ -0,0 +1,21 @@
1
+ import { BaseCommand } from '../../../base-command.js';
2
+ /**
3
+ * vvicat config ai-provider get <providerId>
4
+ *
5
+ * 查看单个 provider 的 baseUrl / enabled / protocol / apiKey 状态。
6
+ * 服务端 serializeExtendedProvider 已脱敏(apiKeyMask='••••••••'),CLI 不再多处理。
7
+ */
8
+ export default class ConfigAiProviderGet extends BaseCommand {
9
+ static description: string;
10
+ static args: {
11
+ providerId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
12
+ };
13
+ static flags: {
14
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
17
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ episode: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
+ };
20
+ run(): Promise<void>;
21
+ }
@@ -0,0 +1,21 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base-command.js';
3
+ import { commandApi } from '../../../command-helpers.js';
4
+ /**
5
+ * vvicat config ai-provider get <providerId>
6
+ *
7
+ * 查看单个 provider 的 baseUrl / enabled / protocol / apiKey 状态。
8
+ * 服务端 serializeExtendedProvider 已脱敏(apiKeyMask='••••••••'),CLI 不再多处理。
9
+ */
10
+ export default class ConfigAiProviderGet extends BaseCommand {
11
+ static description = '查看单个 AI provider 详情';
12
+ static args = {
13
+ providerId: Args.string({ required: true, description: 'provider ID(如 comfyui / runninghub)' }),
14
+ };
15
+ static flags = BaseCommand.baseFlags;
16
+ async run() {
17
+ const { args, flags } = await this.parse(ConfigAiProviderGet);
18
+ const { api } = await commandApi(flags);
19
+ this.print(await api.getAiProvider(args.providerId));
20
+ }
21
+ }
@@ -0,0 +1,20 @@
1
+ import { BaseCommand } from '../../../base-command.js';
2
+ /**
3
+ * vvicat config ai-provider list
4
+ *
5
+ * 列出仓库中所有 AI provider(comfly / comfyui / runninghub / openai / ...),
6
+ * 含 enabled/protocol/baseUrl/apiKey 状态(apiKey 脱敏)。
7
+ *
8
+ * 服务端要求 admin 鉴权。
9
+ */
10
+ export default class ConfigAiProviderList extends BaseCommand {
11
+ static description: string;
12
+ static flags: {
13
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
16
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ episode: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ };
19
+ run(): Promise<void>;
20
+ }
@@ -0,0 +1,19 @@
1
+ import { BaseCommand } from '../../../base-command.js';
2
+ import { commandApi } from '../../../command-helpers.js';
3
+ /**
4
+ * vvicat config ai-provider list
5
+ *
6
+ * 列出仓库中所有 AI provider(comfly / comfyui / runninghub / openai / ...),
7
+ * 含 enabled/protocol/baseUrl/apiKey 状态(apiKey 脱敏)。
8
+ *
9
+ * 服务端要求 admin 鉴权。
10
+ */
11
+ export default class ConfigAiProviderList extends BaseCommand {
12
+ static description = '列出仓库中所有 AI provider(apiKey 脱敏)';
13
+ static flags = BaseCommand.baseFlags;
14
+ async run() {
15
+ const { flags } = await this.parse(ConfigAiProviderList);
16
+ const { api } = await commandApi(flags);
17
+ this.print(await api.listAiProviders());
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ import { BaseCommand } from '../../../base-command.js';
2
+ /**
3
+ * vvicat config ai-provider models <providerId>
4
+ *
5
+ * 拉取 provider 远端真实模型清单(GET baseUrl/models)。
6
+ * 服务端会把结果缓存到 ai_providers.upstreamModels,可重复调用观察同步状态。
7
+ */
8
+ export default class ConfigAiProviderModels extends BaseCommand {
9
+ static description: string;
10
+ static args: {
11
+ providerId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
12
+ };
13
+ static flags: {
14
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
17
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ episode: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
+ };
20
+ run(): Promise<void>;
21
+ }
@@ -0,0 +1,21 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base-command.js';
3
+ import { commandApi } from '../../../command-helpers.js';
4
+ /**
5
+ * vvicat config ai-provider models <providerId>
6
+ *
7
+ * 拉取 provider 远端真实模型清单(GET baseUrl/models)。
8
+ * 服务端会把结果缓存到 ai_providers.upstreamModels,可重复调用观察同步状态。
9
+ */
10
+ export default class ConfigAiProviderModels extends BaseCommand {
11
+ static description = '拉取 provider 远端真实模型清单(GET /models)';
12
+ static args = {
13
+ providerId: Args.string({ required: true, description: 'provider ID' }),
14
+ };
15
+ static flags = BaseCommand.baseFlags;
16
+ async run() {
17
+ const { args, flags } = await this.parse(ConfigAiProviderModels);
18
+ const { api } = await commandApi(flags);
19
+ this.print(await api.fetchAiProviderModels(args.providerId));
20
+ }
21
+ }