dsh-connect-workbuddy 5.9.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
3
- import { homedir } from "node:os";
3
+ import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join, resolve, win32 } from "node:path";
5
5
  import { execFile, execFileSync } from "node:child_process";
6
6
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -1158,18 +1158,43 @@ function queueNoticeText(report) {
1158
1158
  * - `gemini-*` :什么都不加
1159
1159
  * - 其它 :`response_format: 'b64_json'` + 可选 `n` / `quality` / `style` / `background`
1160
1160
  *
1161
- * 国内区产品配置里带 `text-to-image` 标签的只有 `hunyuan-image-alpha`,
1162
- * 带 `image-to-image` 的只有 `hunyuan-image-alpha-edit`,因此实际走的就是
1163
- * hunyuan 分支、响应里是 COS 签名 URL(24h 有效),单张约 5.71 credits。
1161
+ * 两个区的目录各有一条媒体模型,互不重叠(2026-10-25 实测):
1162
+ * - 国内区(61 条模型):`text-to-image` 只有 `hunyuan-image-alpha`,
1163
+ * `image-to-image` 只有 `hunyuan-image-alpha-edit`。走 hunyuan 分支,
1164
+ * 响应是 COS 签名 URL(24h 有效),单张约 5.71 credits。
1165
+ * - 国际区(29 条模型):`hy-image-3.5` 同时带 `text-to-image` 与
1166
+ * `image-to-image`。走「其它」分支(带 `response_format: 'b64_json'`),
1167
+ * 实测单张约 0.03 credits,返回的同样是 ap-singapore 的 COS 签名 URL。
1168
+ *
1169
+ * 两个数不是一个价目表的两个档位:目录条目只有 id/name/tags,**没有价格**,
1170
+ * 价格只在上游结算时以 `data.usage.credit` 返回,所以工具只能回报实扣、
1171
+ * 不能预先报价。
1164
1172
  *
1165
1173
  * @module dsh-connect-workbuddy/image-gen
1166
1174
  */
1167
1175
  /** 产品配置里「文生图」模型的标签。 */
1168
1176
  const IMAGE_TAG_TEXT_TO_IMAGE = "text-to-image";
1177
+ /** 产品配置里「图生图 / 改图」模型的标签。 */
1178
+ const IMAGE_TAG_IMAGE_TO_IMAGE = "image-to-image";
1169
1179
  /** 官方 CLI 的默认尺寸。 */
1170
1180
  const DEFAULT_IMAGE_SIZE = "1024x1024";
1171
1181
  /** 生图路由。 */
1172
1182
  const IMAGE_GENERATIONS_PATH = "/v2/images/generations";
1183
+ /** 改图路由:官方 CLI 的 `ImageService.editImage` 打的就是这条(2026-10-25 国际区实测 200)。 */
1184
+ const IMAGE_EDITS_PATH = "/v2/images/edits";
1185
+ /**
1186
+ * 本插件认作「媒体模型」的全部标签。
1187
+ *
1188
+ * 目录只读一次就返回全部媒体条目,由调用方按 tag 分组:生图工具只认两个 image
1189
+ * 标签,卡片清单则把视频条目一并列出来。这样 `fetchMediaModels` 不必为每种能力
1190
+ * 各读一遍 `/v3/config`。
1191
+ */
1192
+ const MEDIA_TAGS = [
1193
+ IMAGE_TAG_TEXT_TO_IMAGE,
1194
+ IMAGE_TAG_IMAGE_TO_IMAGE,
1195
+ "text-to-video",
1196
+ "image-to-video"
1197
+ ];
1173
1198
  /** 生图链路的错误。带一个稳定的 `code`,避免调用方去匹配文案。 */
1174
1199
  var WorkBuddyImageError = class extends Error {
1175
1200
  code;
@@ -1179,62 +1204,100 @@ var WorkBuddyImageError = class extends Error {
1179
1204
  this.code = code;
1180
1205
  }
1181
1206
  };
1182
- function asString(value) {
1207
+ function asString$1(value) {
1183
1208
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
1184
1209
  }
1185
1210
  function stringList(value) {
1186
1211
  if (!Array.isArray(value)) return [];
1187
1212
  const out = [];
1188
1213
  for (const item of value) {
1189
- const text = asString(item);
1214
+ const text = asString$1(item);
1190
1215
  if (text !== void 0) out.push(text);
1191
1216
  }
1192
1217
  return out;
1193
1218
  }
1219
+ function asFiniteNumber(value) {
1220
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1221
+ }
1222
+ /**
1223
+ * 把目录条目上的 `videoGeneration` 读成已知字段的配置对象(图像链路只是搬运,
1224
+ * 不解释它)。与 `video-gen.ts` 的 `readVideoGeneration` 必须同形——同一份上游
1225
+ * 数据,两个入口不能读出两个答案。缺字段即 undefined,不补默认值。
1226
+ */
1227
+ function readVideoGeneration(raw) {
1228
+ if (typeof raw !== "object" || raw === null) return void 0;
1229
+ const record = raw;
1230
+ const resolutions = stringList(record["resolutions"]);
1231
+ const defaultResolution = asString$1(record["defaultResolution"]);
1232
+ const defaultSeconds = asFiniteNumber(record["defaultSeconds"]);
1233
+ const minSeconds = asFiniteNumber(record["minSeconds"]);
1234
+ const maxSeconds = asFiniteNumber(record["maxSeconds"]);
1235
+ const config = {
1236
+ ...resolutions.length === 0 ? {} : { resolutions },
1237
+ ...defaultResolution === void 0 ? {} : { defaultResolution },
1238
+ ...defaultSeconds === void 0 ? {} : { defaultSeconds },
1239
+ ...minSeconds === void 0 ? {} : { minSeconds },
1240
+ ...maxSeconds === void 0 ? {} : { maxSeconds }
1241
+ };
1242
+ return Object.keys(config).length === 0 ? void 0 : config;
1243
+ }
1194
1244
  /**
1195
1245
  * 从产品配置的 `data.models` 里挑出带指定 tag 的媒体模型。
1196
1246
  *
1247
+ * `tag` 可以是一组标签,命中任一即算——目录只读一次就返回全部媒体条目({@link MEDIA_TAGS}),
1248
+ * 调用方再按能力分组过滤,比每种能力各读一遍 `/v3/config` 少一次往返。
1249
+ *
1197
1250
  * 去重按 `id`:上游目录里同一个 id 只应出现一次,但目录是外部数据,
1198
1251
  * 重复条目不该让卡片或工具参数里出现两个同名选项。
1199
1252
  */
1200
1253
  function selectMediaModels(rawModels, tag = IMAGE_TAG_TEXT_TO_IMAGE) {
1254
+ const wanted = typeof tag === "string" ? [tag] : tag;
1201
1255
  if (!Array.isArray(rawModels)) return [];
1202
1256
  const out = [];
1203
1257
  const seen = /* @__PURE__ */ new Set();
1204
1258
  for (const entry of rawModels) {
1205
1259
  if (typeof entry !== "object" || entry === null) continue;
1206
1260
  const record = entry;
1207
- const id = asString(record["id"]);
1261
+ const id = asString$1(record["id"]);
1208
1262
  if (id === void 0 || seen.has(id)) continue;
1209
1263
  const tags = stringList(record["tags"]);
1210
- if (!tags.includes(tag)) continue;
1264
+ if (!wanted.some((one) => tags.includes(one))) continue;
1211
1265
  seen.add(id);
1266
+ const videoGeneration = readVideoGeneration(record["videoGeneration"]);
1212
1267
  out.push({
1213
1268
  id,
1214
- name: asString(record["name"]) ?? id,
1215
- tags
1269
+ name: asString$1(record["name"]) ?? id,
1270
+ tags,
1271
+ ...videoGeneration === void 0 ? {} : { videoGeneration }
1216
1272
  });
1217
1273
  }
1218
1274
  return out;
1219
1275
  }
1276
+ /** tag 的人话名字,只用在错误信息里。 */
1277
+ function tagLabel$1(tag) {
1278
+ if (tag === "image-to-image") return "图生图";
1279
+ if (tag === "text-to-image") return "文生图";
1280
+ return tag;
1281
+ }
1220
1282
  /**
1221
- * 选出生图模型:优先用调用方点名的那个,否则用目录里的第一个。
1283
+ * 按能力标签选一个图像模型:先在该标签组内找调用方点名的那个,否则取组内第一个。
1222
1284
  *
1223
- * 点名的模型必须在目录里——不在目录里就直接报错,而不是把它发上去让上游
1224
- * 回一个「route config not found」。这样报错信息能指出真正的问题(模型名写错
1225
- * 或该区没有这个模型),而不是一句上游 404。
1285
+ * 点名的模型必须在**这一组**里——写错名字或拿改图模型来文生图都直接报错,
1286
+ * 而不是把它发上去让上游回一个「route config not found」。这样报错信息能指出
1287
+ * 真正的问题(模型名写错,或该区这一组没有模型),那句话里还会列出本组可用项。
1226
1288
  */
1227
- function pickImageModel(models, requested) {
1289
+ function pickMediaModel(models, requested, tag = IMAGE_TAG_TEXT_TO_IMAGE) {
1290
+ const group = models.filter((model) => model.tags.includes(tag));
1228
1291
  if (requested !== void 0 && requested !== "") {
1229
- const found = models.find((model) => model.id === requested);
1292
+ const found = group.find((model) => model.id === requested);
1230
1293
  if (found === void 0) {
1231
- const known = models.map((model) => model.id).join(", ");
1232
- throw new WorkBuddyImageError(`workbuddy image: 目录里没有生图模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_IMAGE_MODEL");
1294
+ const known = group.map((model) => model.id).join(", ");
1295
+ throw new WorkBuddyImageError(`workbuddy image: 目录里没有${tagLabel$1(tag)}模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_IMAGE_MODEL");
1233
1296
  }
1234
1297
  return found;
1235
1298
  }
1236
- const first = models[0];
1237
- if (first === void 0) throw new WorkBuddyImageError("workbuddy image: 该区账号的产品配置里没有任何带 text-to-image 标签的模型。国内区应有 hunyuan-image-alpha;国际区当前没有生图模型。", "NO_IMAGE_MODEL");
1299
+ const first = group[0];
1300
+ if (first === void 0) throw new WorkBuddyImageError(`workbuddy image: 该区账号的产品配置里没有任何带 ${tag} 标签的模型(${tagLabel$1(tag)})。国内区文生图应是 hunyuan-image-alpha、改图是 hunyuan-image-alpha-edit;国际区 hy-image-3.5 同时带这两个标签;一个都没有说明该区目录暂时没有这类模型。`, "NO_IMAGE_MODEL");
1238
1301
  return first;
1239
1302
  }
1240
1303
  /** 按官方 CLI 的 `buildBaseRequestBody` 分支规则拼请求体。 */
@@ -1255,6 +1318,83 @@ function buildImageBody(request) {
1255
1318
  return body;
1256
1319
  }
1257
1320
  /**
1321
+ * 改图请求体:生图那一套字段 + `image`(data URL 数组)+ 可选 `input_fidelity`。
1322
+ *
1323
+ * 顺序照抄官方 CLI 的 `editImage`:先 `buildBaseRequestBody`,再补 `image`,
1324
+ * 再可选补 `input_fidelity`。`image` 上游只认字符串数组,单个字符串会被拒。
1325
+ */
1326
+ function buildEditBody(request) {
1327
+ const body = buildImageBody(request);
1328
+ body["image"] = [...request.image];
1329
+ if (request.inputFidelity !== void 0 && request.inputFidelity !== "") body["input_fidelity"] = request.inputFidelity;
1330
+ return body;
1331
+ }
1332
+ /** 输入图的大小上限。上游没说,但一张改图输入不该比产物大一个量级,8 MB 足够。 */
1333
+ const MAX_INPUT_IMAGE_BYTES = 8388608;
1334
+ /** 按扩展名猜输入图的 MIME;不认识就返回 undefined(交给嗅探)。 */
1335
+ const IMAGE_MIME_BY_EXTENSION = {
1336
+ ".png": "image/png",
1337
+ ".jpg": "image/jpeg",
1338
+ ".jpeg": "image/jpeg",
1339
+ ".gif": "image/gif",
1340
+ ".webp": "image/webp",
1341
+ ".bmp": "image/bmp",
1342
+ ".svg": "image/svg+xml",
1343
+ ".ico": "image/x-icon",
1344
+ ".tif": "image/tiff",
1345
+ ".tiff": "image/tiff"
1346
+ };
1347
+ function imageMimeForPath(path) {
1348
+ const lower = path.toLowerCase();
1349
+ const dot = lower.lastIndexOf(".");
1350
+ if (dot < 0) return void 0;
1351
+ return IMAGE_MIME_BY_EXTENSION[lower.slice(dot)];
1352
+ }
1353
+ /**
1354
+ * 按魔数嗅探图片 MIME,照抄官方 CLI 的 `detectMimeTypeFromBase64`。
1355
+ *
1356
+ * 认不出来时兜底 `image/png`(CLI 也是这么兜的):宁可让上游按 png 试一次,
1357
+ * 也不要因为一个我们没见过的头就拒掉用户给的图。
1358
+ */
1359
+ function sniffImageMime(bytes) {
1360
+ const at = (index) => bytes[index] ?? -1;
1361
+ if (at(0) === 137 && at(1) === 80 && at(2) === 78 && at(3) === 71) return "image/png";
1362
+ if (at(0) === 255 && at(1) === 216 && at(2) === 255) return "image/jpeg";
1363
+ if (at(0) === 71 && at(1) === 73 && at(2) === 70 && at(3) === 56) return "image/gif";
1364
+ if (at(0) === 82 && at(1) === 73 && at(2) === 70 && at(3) === 70 && at(8) === 87 && at(9) === 69 && at(10) === 66 && at(11) === 80) return "image/webp";
1365
+ if (at(0) === 66 && at(1) === 77) return "image/bmp";
1366
+ return "image/png";
1367
+ }
1368
+ /** 拼出上游唯一接受的输入形态:`data:<mime>;base64,<base64>`。 */
1369
+ function imageDataUrl(mime, base64) {
1370
+ return `data:${mime};base64,${base64}`;
1371
+ }
1372
+ /** 解析一个 data URL 形式的输入图;不是 `data:image/*;base64,` 就返回 undefined。 */
1373
+ function parseImageDataUrl(value) {
1374
+ const match = /^data:([^;,]+);base64,(.+)$/s.exec(value.trim());
1375
+ if (match === null) return void 0;
1376
+ const mime = match[1] ?? "";
1377
+ const base64 = match[2] ?? "";
1378
+ if (!mime.startsWith("image/") || base64 === "") return void 0;
1379
+ return {
1380
+ mime,
1381
+ base64
1382
+ };
1383
+ }
1384
+ const BARE_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
1385
+ /**
1386
+ * 看起来像「裸 base64」而不是路径吗?
1387
+ *
1388
+ * 判据照抄 CLI 的 `processImageInput`:不含路径分隔符、足够长、字符集是 base64。
1389
+ * 长度门槛是必要的——`a.png` 这种文件名同样不含分隔符,短到不足以是图片数据。
1390
+ */
1391
+ function isBareBase64Image(value) {
1392
+ const text = value.trim();
1393
+ if (text.length < 100) return false;
1394
+ if (text.includes("/") || text.includes("\\")) return false;
1395
+ return BARE_BASE64.test(text);
1396
+ }
1397
+ /**
1258
1398
  * 解析生图响应。
1259
1399
  *
1260
1400
  * 信封是 `{code, msg, request_id, data}`;`code !== 0` 是业务失败(与 HTTP 状态
@@ -1266,8 +1406,8 @@ function parseImageResponse(payload) {
1266
1406
  const envelope = payload;
1267
1407
  const code = typeof envelope["code"] === "number" ? envelope["code"] : void 0;
1268
1408
  if (code !== void 0 && code !== 0) {
1269
- const message = asString(envelope["msg"]) ?? "unknown error";
1270
- const requestId = asString(envelope["request_id"]);
1409
+ const message = asString$1(envelope["msg"]) ?? "unknown error";
1410
+ const requestId = asString$1(envelope["request_id"]);
1271
1411
  throw new WorkBuddyImageError(`workbuddy image: 上游拒绝(code ${code}): ${message}` + (requestId === void 0 ? "" : `(request_id ${requestId})`), "UPSTREAM");
1272
1412
  }
1273
1413
  const data = envelope["data"];
@@ -1279,8 +1419,8 @@ function parseImageResponse(payload) {
1279
1419
  let b64;
1280
1420
  if (typeof first === "object" && first !== null) {
1281
1421
  const item = first;
1282
- url = asString(item["url"]);
1283
- b64 = asString(item["b64_json"]);
1422
+ url = asString$1(item["url"]);
1423
+ b64 = asString$1(item["b64_json"]);
1284
1424
  }
1285
1425
  if (url === void 0 && b64 === void 0) throw new WorkBuddyImageError("workbuddy image: 上游返回成功但 data.data 里既没有 url 也没有 b64_json", "EMPTY_RESULT");
1286
1426
  const usage = typeof record["usage"] === "object" && record["usage"] !== null ? record["usage"] : {};
@@ -1301,17 +1441,295 @@ function describeImageFailure(status, bodyText) {
1301
1441
  const parsed = JSON.parse(text);
1302
1442
  if (typeof parsed === "object" && parsed !== null) {
1303
1443
  const record = parsed;
1304
- const message = asString(record["msg"]);
1444
+ const message = asString$1(record["msg"]);
1305
1445
  const code = typeof record["code"] === "number" ? record["code"] : void 0;
1306
1446
  if (message !== void 0) detail = code === void 0 ? message : `code ${code}: ${message}`;
1307
1447
  }
1308
1448
  } catch {}
1309
1449
  if (status === 401 || status === 403) return new WorkBuddyImageError(`workbuddy image: 凭据被上游拒绝(HTTP ${status}),请在插件设置里重新登录 WorkBuddy。${detail === "" ? "" : ` ${detail}`}`, "AUTH");
1310
1450
  if (status === 429) return new WorkBuddyImageError(`workbuddy image: 触发上游限流(HTTP 429),稍后再试。${detail === "" ? "" : ` ${detail}`}`, "RATE_LIMIT");
1311
- if (status === 404) return new WorkBuddyImageError(`workbuddy image: 上游没有这条生图路由(HTTP 404)。全球区没有生图模型,请用国内区账号。${detail === "" ? "" : ` ${detail}`}`, "NO_ROUTE");
1451
+ if (status === 404) return new WorkBuddyImageError(`workbuddy image: 上游没有这条生图路由(HTTP 404)——该区网关没有开放生图接口。${detail === "" ? "" : ` ${detail}`}`, "NO_ROUTE");
1312
1452
  return new WorkBuddyImageError(`workbuddy image: 上游失败(HTTP ${status})${detail === "" ? "" : `: ${detail}`}`, "UPSTREAM");
1313
1453
  }
1314
1454
  //#endregion
1455
+ //#region src/video-gen.ts
1456
+ /**
1457
+ * WorkBuddy 生视频的纯逻辑层:媒体模型筛选、请求体构造、任务轮询解析、失败描述。
1458
+ *
1459
+ * 这一层刻意不做任何网络 I/O、不引用宿主的任何包(同 `src/image-gen.ts` 的约定,
1460
+ * 见其模块注释里的可达图说明)。`src/upstream.ts` 会静态引用本模块,所以这里
1461
+ * 必须保持零外部依赖。
1462
+ *
1463
+ * 上游契约照抄官方 CLI `codebuddy-lite-wb.mjs` 的 `VideoService`(实测 2026-10-25,
1464
+ * 二进制位于 `WorkBuddy/resources/app.asar.unpacked/cli/dist/codebuddy-lite-wb.mjs`):
1465
+ *
1466
+ * submitTask: POST {endpoint}/v2/videos/generations
1467
+ * body = { prompt, model, seconds, negative_prompt, watermark:true,
1468
+ * image_url/last_image_url(图生视频时),
1469
+ * extra_parameters:{ resolution, enable_audio,
1470
+ * aspect_ratio(仅文生视频) } }
1471
+ * resp = { code, msg, request_id, data:{ id } } ← 只是 task id
1472
+ * pollTask: POST {endpoint}/v2/videos/tasks { task_id }
1473
+ * 轮询直到 data.status === 'completed' | 'failed'
1474
+ * resp = { code, msg, data:{ status, usage?:{credit}, ... } }
1475
+ * download: GET 每条 data[].url → 写本地 .mp4
1476
+ *
1477
+ * 与 `ImageService` 的根本区别:视频是**异步任务**,不是一次同步请求。生图
1478
+ * `ImageService` 调一次 `generations` 直接拿 URL;视频 `VideoService` 要先
1479
+ * `generations` 拿 task id,再 `tasks` 轮询,最后下载。所以本模块把"提交 / 轮询 /
1480
+ * 下载"三段拆开,工具层负责把它们串成一条异步流程(`src/video-tool.ts`)。
1481
+ *
1482
+ * 模型配置来自产品配置里每条媒体模型的 `videoGeneration` 字段:
1483
+ * { resolutions:[...], defaultResolution, defaultSeconds,
1484
+ * minSeconds, maxSeconds }。时长/分辨率要在该模型允许的集合内,否则上游
1485
+ * 会拒。这里把"该模型支持哪些值"的读取与校验收进来,工具层只把用户给的值
1486
+ * 递进来、拿校验结果。
1487
+ *
1488
+ * 两个区的视频目录(2026-10-25 实测,`src/upstream.ts` 的 `fetchMediaModels`
1489
+ * 注释):
1490
+ * - 国内区(61 条模型):`kling-video-v3` 与 `minimax-video-h3` 各带
1491
+ * `text-to-video` + `image-to-video`。
1492
+ * - 国际区(29 条):`kling-video-v3` 带两种 video tag。
1493
+ * 两边目录完全不重叠,所以"以哪个区为准"由工具的 `videoToolRegionOf` 决定
1494
+ * (默认国内区优先,与 image 工具一致)。
1495
+ *
1496
+ * @module dsh-connect-workbuddy/video-gen
1497
+ */
1498
+ /** 产品配置里「文生视频」模型的标签。 */
1499
+ const VIDEO_TAG_TEXT_TO_VIDEO = "text-to-video";
1500
+ /** 产品配置里「图生视频」模型的标签。 */
1501
+ const VIDEO_TAG_IMAGE_TO_VIDEO = "image-to-video";
1502
+ /** 生视频路由(提交任务,拿 task id)。 */
1503
+ const VIDEO_GENERATIONS_PATH = "/v2/videos/generations";
1504
+ /** 任务轮询路由。 */
1505
+ const VIDEO_TASKS_PATH = "/v2/videos/tasks";
1506
+ /** 支持的非图像决定宽高比集合。 */
1507
+ const VIDEO_ASPECT_RATIOS = [
1508
+ "16:9",
1509
+ "9:16",
1510
+ "1:1"
1511
+ ];
1512
+ /** 生视频链路的错误。带一个稳定的 `code`,避免调用方去匹配文案。 */
1513
+ var WorkBuddyVideoError = class extends Error {
1514
+ code;
1515
+ constructor(message, code) {
1516
+ super(message);
1517
+ this.name = "WorkBuddyVideoError";
1518
+ this.code = code;
1519
+ }
1520
+ };
1521
+ function asString(value) {
1522
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1523
+ }
1524
+ /**
1525
+ * 从上游的拒绝文案里读出它自己报的时长区间。
1526
+ *
1527
+ * 目录没有 `videoGeneration` 时,本地无从校验,只能让上游先拒一次;它会以
1528
+ * `unsupported video duration: 1 seconds, supported range: 4-15 seconds` 这种
1529
+ * 文案把真相说出来。把这段读出来记到本次进程里,第二次同样的参数就能在本地被拦下,
1530
+ * 不再多打一次注定失败的请求。读不出来就返回 undefined(绝不猜)。
1531
+ */
1532
+ function parseSupportedSecondsRange(text) {
1533
+ const match = /supported range:\s*(\d+(?:\.\d+)?)\s*(?:-|–|—|~|to)\s*(\d+(?:\.\d+)?)/i.exec(text);
1534
+ if (match === null) return void 0;
1535
+ const minSeconds = Number(match[1]);
1536
+ const maxSeconds = Number(match[2]);
1537
+ if (!Number.isFinite(minSeconds) || !Number.isFinite(maxSeconds)) return void 0;
1538
+ if (minSeconds <= 0 || maxSeconds < minSeconds) return void 0;
1539
+ return {
1540
+ minSeconds,
1541
+ maxSeconds
1542
+ };
1543
+ }
1544
+ /** tag 的人话名字,只用在错误信息里。 */
1545
+ function tagLabel(tag) {
1546
+ if (tag === "image-to-video") return "图生视频";
1547
+ if (tag === "text-to-video") return "文生视频";
1548
+ return tag;
1549
+ }
1550
+ /**
1551
+ * 按能力标签选一个视频模型:先在该标签组内找调用方点名的那个,否则取组内第一个。
1552
+ *
1553
+ * 与 `image-gen.ts` 的 `pickMediaModel` 完全一致的设计——点名的模型必须在**这一组**
1554
+ * 里,写错名字或拿文生视频模型去图生视频都直接报错,而不是把它发上去换一个上游 404。
1555
+ */
1556
+ function pickVideoModel(models, requested, tag = VIDEO_TAG_TEXT_TO_VIDEO) {
1557
+ const group = models.filter((model) => model.tags.includes(tag));
1558
+ if (requested !== void 0 && requested !== "") {
1559
+ const found = group.find((model) => model.id === requested);
1560
+ if (found === void 0) {
1561
+ const known = group.map((model) => model.id).join(", ");
1562
+ throw new WorkBuddyVideoError(`workbuddy video: 目录里没有${tagLabel(tag)}模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_VIDEO_MODEL");
1563
+ }
1564
+ return found;
1565
+ }
1566
+ const first = group[0];
1567
+ if (first === void 0) throw new WorkBuddyVideoError(`workbuddy video: 该区账号的产品配置里没有任何带 ${tag} 标签的模型(${tagLabel(tag)})。国内区文生视频/图生视频是 kling-video-v3、minimax-video-h3;国际区 kling-video-v3 同时带这两个标签;一个都没有说明该区目录暂时没有这类模型。`, "NO_VIDEO_MODEL");
1568
+ return first;
1569
+ }
1570
+ /**
1571
+ * 把一次生视频请求组装成上游 `POST /v2/videos/generations` 的请求体。
1572
+ *
1573
+ * 完全照抄官方 CLI 的 `submitTask`:`prompt` / `model` / `seconds` /
1574
+ * `negative_prompt` / `watermark:true`,图生视频时带 `image_url` 或
1575
+ * `last_image_url`,`extra_parameters` 里放 `resolution` / `enable_audio` /
1576
+ * 以及仅文生视频的 `aspect_ratio`。
1577
+ *
1578
+ * `config` 来自该模型的 `videoGeneration` 字段,用于回落默认值与约束校验
1579
+ * (见 {@link resolveVideoParams})。`isImageToVideo` 由调用方(工具层)按
1580
+ * "有没有给 image" 唯一决定,与 image 工具"不给 image 就是文生图"的约定一致。
1581
+ */
1582
+ function buildVideoBody(request, isImageToVideo) {
1583
+ const body = {
1584
+ prompt: request.prompt,
1585
+ model: request.model,
1586
+ seconds: request.seconds,
1587
+ negative_prompt: request.negativePrompt ?? "",
1588
+ watermark: true
1589
+ };
1590
+ if (request.image !== void 0) body["image_url"] = request.image;
1591
+ if (request.lastImage !== void 0) body["last_image_url"] = request.lastImage;
1592
+ const extra = {
1593
+ resolution: request.resolution,
1594
+ enable_audio: request.enableAudio ?? true
1595
+ };
1596
+ if (!isImageToVideo) extra["aspect_ratio"] = request.aspectRatio ?? "16:9";
1597
+ body["extra_parameters"] = extra;
1598
+ return body;
1599
+ }
1600
+ /**
1601
+ * 校验并回落一次生视频请求的可选参数(时长 / 分辨率 / 宽高比)。
1602
+ *
1603
+ * 集中处理"该模型支持哪些值"的判断,让工具层只需把用户给的值递进来。
1604
+ * 返回:最终要用的 `seconds` / `resolution` / `aspectRatio`(图生视频时
1605
+ * `aspectRatio` 为 undefined,因为由输入图决定),以及一条非致命告警列表
1606
+ * (例如用户给的分辨率模型不支持、被回落成默认值)。
1607
+ *
1608
+ * 抛 `WorkBuddyVideoError`('INVALID_ARGS') 的情况:prompt 超长、宽高比非法、
1609
+ * 模型不支持自定义时长却给了时长、时长越界。
1610
+ */
1611
+ function resolveVideoParams(request, config) {
1612
+ const warnings = [];
1613
+ if ((request.prompt ?? "").length > 2500) throw new WorkBuddyVideoError("workbuddy video: prompt 不能超过 2500 字符。", "INVALID_ARGS");
1614
+ if ((request.negativePrompt?.length ?? 0) > 2500) throw new WorkBuddyVideoError("workbuddy video: negative_prompt 不能超过 2500 字符。", "INVALID_ARGS");
1615
+ const isImageToVideo = request.image !== void 0 || request.lastImage !== void 0;
1616
+ const minSeconds = config?.minSeconds;
1617
+ const maxSeconds = config?.maxSeconds;
1618
+ let seconds = request.seconds ?? config?.defaultSeconds ?? 5;
1619
+ if (request.seconds !== void 0) {
1620
+ if (minSeconds !== void 0 && maxSeconds !== void 0 && (seconds < minSeconds || seconds > maxSeconds)) throw new WorkBuddyVideoError(`workbuddy video: 时长必须在 ${minSeconds} 到 ${maxSeconds} 秒之间(给定 ${seconds})。`, "INVALID_ARGS");
1621
+ }
1622
+ const resolutions = config?.resolutions ?? [];
1623
+ const defaultResolution = config?.defaultResolution;
1624
+ let resolution = defaultResolution;
1625
+ if (request.resolution !== void 0) {
1626
+ const requested = request.resolution;
1627
+ if (resolutions.length === 0) resolution = requested;
1628
+ else {
1629
+ const matched = resolutions.includes(requested) ? requested : resolutions.find((one) => one.toLowerCase() === requested.toLowerCase());
1630
+ if (matched === void 0) {
1631
+ warnings.push(`resolution ${requested} 不被模型 ${request.model} 支持;改用 ${defaultResolution ?? "上游默认"}`);
1632
+ resolution = defaultResolution;
1633
+ } else resolution = matched;
1634
+ }
1635
+ }
1636
+ let aspectRatio;
1637
+ if (!isImageToVideo) {
1638
+ const ar = request.aspectRatio ?? "16:9";
1639
+ if (!VIDEO_ASPECT_RATIOS.includes(ar)) throw new WorkBuddyVideoError(`workbuddy video: aspect_ratio 只能是 ${VIDEO_ASPECT_RATIOS.join("、")} 之一(给定 ${ar})。`, "INVALID_ARGS");
1640
+ aspectRatio = ar;
1641
+ }
1642
+ return {
1643
+ seconds,
1644
+ resolution,
1645
+ aspectRatio,
1646
+ warnings
1647
+ };
1648
+ }
1649
+ /**
1650
+ * 解析 `POST /v2/videos/generations` 的响应,取出 task id。
1651
+ *
1652
+ * 信封是 `{code, msg, request_id, data}`,`code !== 0` 是业务失败(与 HTTP
1653
+ * 状态无关)。成功时 `data.id` 是 task id。没有 id 就说不通(上游一定返回 id)。
1654
+ */
1655
+ function parseVideoSubmission(payload) {
1656
+ if (typeof payload !== "object" || payload === null) throw new WorkBuddyVideoError("workbuddy video: 上游返回的不是 JSON 对象", "UPSTREAM");
1657
+ const envelope = payload;
1658
+ const code = typeof envelope["code"] === "number" ? envelope["code"] : void 0;
1659
+ if (code !== void 0 && code !== 0) {
1660
+ const message = asString(envelope["msg"]) ?? "unknown error";
1661
+ const requestId = asString(envelope["request_id"]);
1662
+ throw new WorkBuddyVideoError(`workbuddy video: 提交失败(code ${code}): ${message}` + (requestId === void 0 ? "" : `(request_id ${requestId})`), "UPSTREAM");
1663
+ }
1664
+ const taskId = asString((typeof envelope["data"] === "object" && envelope["data"] !== null ? envelope["data"] : {})["id"]);
1665
+ if (taskId === void 0) throw new WorkBuddyVideoError("workbuddy video: 上游没返回 task id,无法进入轮询。", "EMPTY_RESULT");
1666
+ return {
1667
+ taskId,
1668
+ warnings: []
1669
+ };
1670
+ }
1671
+ /**
1672
+ * 解析 `POST /v2/videos/tasks` 的轮询响应。
1673
+ *
1674
+ * 返回当前 `status`,以及(完成态时)成品视频 URL 列表与实扣积分。
1675
+ * `status` 不在 `pending/processing/completed/failed` 内的未知字符串,一律
1676
+ * 当成"还没好"继续轮询,避免把新状态误判成失败而浪费一次已花钱的任务。
1677
+ */
1678
+ function parseVideoTask(payload) {
1679
+ if (typeof payload !== "object" || payload === null) throw new WorkBuddyVideoError("workbuddy video: 轮询响应不是 JSON 对象", "UPSTREAM");
1680
+ const envelope = payload;
1681
+ const code = typeof envelope["code"] === "number" ? envelope["code"] : void 0;
1682
+ if (code !== void 0 && code !== 0) {
1683
+ const message = asString(envelope["msg"]) ?? "unknown error";
1684
+ const requestId = asString(envelope["request_id"]);
1685
+ throw new WorkBuddyVideoError(`workbuddy video: 轮询失败(code ${code}): ${message}` + (requestId === void 0 ? "" : `(request_id ${requestId})`), "UPSTREAM");
1686
+ }
1687
+ const data = typeof envelope["data"] === "object" && envelope["data"] !== null ? envelope["data"] : {};
1688
+ const status = asString(data["status"]) ?? "unknown";
1689
+ if (status === "completed") {
1690
+ const items = Array.isArray(data["data"]) ? data["data"] : [];
1691
+ const videos = [];
1692
+ for (const item of items) {
1693
+ if (typeof item !== "object" || item === null) continue;
1694
+ const url = asString(item["url"]);
1695
+ if (url !== void 0) videos.push(url);
1696
+ }
1697
+ const usage = typeof data["usage"] === "object" && data["usage"] !== null ? data["usage"] : {};
1698
+ return {
1699
+ status,
1700
+ videos,
1701
+ credit: typeof usage["credit"] === "number" && Number.isFinite(usage["credit"]) ? usage["credit"] : void 0
1702
+ };
1703
+ }
1704
+ return {
1705
+ status,
1706
+ videos: []
1707
+ };
1708
+ }
1709
+ /** 把一次非 2xx 的提交/轮询响应翻成带 `code` 的错误,好让上层决定怎么提示。 */
1710
+ function describeVideoFailure(status, bodyText, phase = "submit") {
1711
+ const text = bodyText.trim();
1712
+ let detail = text.slice(0, 240);
1713
+ if (text.startsWith("{")) try {
1714
+ const parsed = JSON.parse(text);
1715
+ if (typeof parsed === "object" && parsed !== null) {
1716
+ const record = parsed;
1717
+ const message = asString(record["msg"]);
1718
+ const code = typeof record["code"] === "number" ? record["code"] : void 0;
1719
+ if (message !== void 0) detail = code === void 0 ? message : `code ${code}: ${message}`;
1720
+ }
1721
+ } catch {}
1722
+ const where = phase === "submit" ? "提交生视频任务" : "轮询生视频任务";
1723
+ if (status === 401 || status === 403) return new WorkBuddyVideoError(`workbuddy video: 凭据被上游拒绝(HTTP ${status}),请在插件设置里重新登录 WorkBuddy。${detail === "" ? "" : ` ${detail}`}`, "AUTH");
1724
+ if (status === 429) return new WorkBuddyVideoError(`workbuddy video: 触发上游限流(HTTP 429),稍后再试。${detail === "" ? "" : ` ${detail}`}`, "RATE_LIMIT");
1725
+ if (status === 404) return new WorkBuddyVideoError(`workbuddy video: 上游没有这条生视频路由(HTTP 404)——该区网关没有开放视频接口。${detail === "" ? "" : ` ${detail}`}`, "NO_ROUTE");
1726
+ if (phase === "submit" && status >= 400 && status < 500) {
1727
+ const range = parseSupportedSecondsRange(detail);
1728
+ return new WorkBuddyVideoError(`workbuddy video: 上游拒绝了这次生视频请求(HTTP ${status}),没有创建任务、没有扣积分${range === void 0 ? "" : `;该模型支持的时长是 ${range.minSeconds}-${range.maxSeconds} 秒`}。按下面的提示改参数后可以立刻重试。${detail === "" ? "" : ` ${detail}`}`, "REJECTED");
1729
+ }
1730
+ return new WorkBuddyVideoError(`workbuddy video: ${where}失败(HTTP ${status})${detail === "" ? "" : `: ${detail}`}`, "UPSTREAM");
1731
+ }
1732
+ //#endregion
1315
1733
  //#region src/off-thinking.ts
1316
1734
  /**
1317
1735
  * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
@@ -1958,6 +2376,14 @@ const PROBE_TIMEOUT_MS = 1e4;
1958
2376
  * 又不至于让一个卡死的连接占住工具槽位太久。
1959
2377
  */
1960
2378
  const IMAGE_TIMEOUT_MS = 18e4;
2379
+ /** 视频提交/轮询的总超时:视频要生成几十秒到几分钟,远大于生图。 */
2380
+ const VIDEO_TIMEOUT_MS = 6e5;
2381
+ /** 轮询之间的间隔(官方 CLI pollTask 用 5s)。 */
2382
+ const VIDEO_POLL_INTERVAL_MS = 5e3;
2383
+ /** 单个轮询轮次的上限(与 VIDEO_TIMEOUT_MS 对应,防死循环)。 */
2384
+ const VIDEO_POLL_TIMEOUT_MS = 6e5;
2385
+ /** 单条视频下载超时(官方 CLI 用 120s)。 */
2386
+ const VIDEO_DOWNLOAD_TIMEOUT_MS = 12e4;
1961
2387
  /**
1962
2388
  * Insufficient-credit markers.
1963
2389
  *
@@ -4368,12 +4794,22 @@ var WorkBuddyUpstreamClient = class {
4368
4794
  return levels;
4369
4795
  }
4370
4796
  /**
4371
- * 读该账号所属区的产品配置,挑出带 `text-to-image` 标签的媒体模型。
4797
+ * 读该账号所属区的产品配置,返回**全部**媒体模型(图 + 视频,按 tag 区分)。
4372
4798
  *
4373
4799
  * 走的是和 {@link fetchModels} 同一条 `/v3/config`(国内区与国际区都答),
4374
4800
  * 区别只在这里保留 `data.models` 的媒体条目、而不是投影成 CLI 聊天目录。
4375
- * 国内区实测 62 条模型里只有 `hunyuan-image-alpha` 带 `text-to-image`;
4376
- * 国际区一条都没有 —— 所以本方法对国际区返回空数组,而不是编一个模型出来。
4801
+ * 两区实测(2026-10-25,同日各跑一次只读探针):
4802
+ * - 国内区 61 条模型 / 17 条带 tag:`hunyuan-image-alpha`(`text-to-image`)、
4803
+ * `hunyuan-image-alpha-edit`(`image-to-image`)、`kling-video-v3` 与
4804
+ * `minimax-video-h3`(`text-to-video` + `image-to-video`)。
4805
+ * - 国际区 29 条 / 7 条带 tag:`hy-image-3.5`(`text-to-image` +
4806
+ * `image-to-image`)、`kling-video-v3`(两种视频 tag)。
4807
+ * 两边目录完全不重叠,所以「国际区一条都没有」是 2026-10-21 的旧观测,
4808
+ * 已经过期 —— 目录真的没标时才返回空数组(而不是编一个模型出来)。
4809
+ *
4810
+ * 返回的是**未分组**的全集:谁要 `text-to-image` 就自己按 tag 过滤
4811
+ * ({@link selectMediaModels} / `pickMediaModel`)。生图工具、改图路径、卡片清单
4812
+ * 因此共用这一次读取,而不是各读一遍目录。
4377
4813
  *
4378
4814
  * 这是一次 EXTRA 读取,不是「复用目录那一次」:`fetchModels` 的结果被投影成
4379
4815
  * 聊天目录就丢掉了媒体标签,所以这里必须再读一遍同一份文档。它是只读 GET、
@@ -4395,7 +4831,7 @@ var WorkBuddyUpstreamClient = class {
4395
4831
  });
4396
4832
  const envelope = await readEnvelope(response);
4397
4833
  if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
4398
- return selectMediaModels((typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {})["models"], IMAGE_TAG_TEXT_TO_IMAGE);
4834
+ return selectMediaModels((typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {})["models"], MEDIA_TAGS);
4399
4835
  };
4400
4836
  try {
4401
4837
  return await readMedia(GLOBAL_CONFIG_PATH);
@@ -4409,7 +4845,9 @@ var WorkBuddyUpstreamClient = class {
4409
4845
  * 发一次文生图请求。
4410
4846
  *
4411
4847
  * 端点、请求体、响应解析全部照抄官方 CLI 的 ImageService(见 `image-gen.ts`
4412
- * 的模块注释)。实测单张 5.71 credits,产物是 24 小时有效的 COS 签名 URL。
4848
+ * 的模块注释)。产物是 24 小时有效的 COS 签名 URL;实扣积分随区与模型不同
4849
+ * (国内区 `hunyuan-image-alpha` 5.71、国际区 `hy-image-3.5` 0.03),
4850
+ * 只在响应的 `data.usage.credit` 里给出,调用方按实际值回报。
4413
4851
  *
4414
4852
  * 失败分两层:HTTP 非 2xx 交给 {@link describeImageFailure} 翻成带 code 的
4415
4853
  * 错误(401/403 提示重新登录、404 提示该区没有生图路由、429 提示限流);
@@ -4432,6 +4870,151 @@ var WorkBuddyUpstreamClient = class {
4432
4870
  }
4433
4871
  return parseImageResponse(payload);
4434
4872
  }
4873
+ /**
4874
+ * 发一次改图请求(`image-to-image`)。
4875
+ *
4876
+ * 端点换成官方 CLI `ImageService.editImage` 用的 `/v2/images/edits`,请求体是
4877
+ * 生图那一套 + `image`(**必须是 data URL 字符串数组**)+ 可选 `input_fidelity`。
4878
+ * 2026-10-25 国际区实测:`hy-image-3.5` + 一张 712×962 的截图 → 200、code 0、
4879
+ * 一张 COS 签名 URL、`credit 0.03`。
4880
+ *
4881
+ * `image` 的形态由调用方(工具层)负责规整成 data URL——这一层只发请求、
4882
+ * 不碰文件系统,和 {@link generateImage} 的分工保持一致。
4883
+ */
4884
+ async editImage(credential, request, signal) {
4885
+ if (request.image.length === 0) throw new WorkBuddyImageError("workbuddy image: 改图请求缺少输入图(image 数组为空)。", "INVALID_ARGS");
4886
+ const response = await fetch(`${chatBase(credential)}${IMAGE_EDITS_PATH}`, {
4887
+ method: "POST",
4888
+ headers: imageHeaders(credential),
4889
+ body: JSON.stringify(buildEditBody(request)),
4890
+ signal: signal ?? AbortSignal.timeout(IMAGE_TIMEOUT_MS)
4891
+ });
4892
+ const text = await response.text();
4893
+ if (!response.ok) throw describeImageFailure(response.status, text);
4894
+ let payload;
4895
+ try {
4896
+ payload = JSON.parse(text);
4897
+ } catch {
4898
+ throw new WorkBuddyImageError(`workbuddy image: 上游返回的不是 JSON(HTTP ${response.status}): ${text.slice(0, 200)}`, "UPSTREAM");
4899
+ }
4900
+ return parseImageResponse(payload);
4901
+ }
4902
+ /**
4903
+ * 发一次生视频请求(提交任务,拿 task id)。
4904
+ *
4905
+ * 端点与请求体照抄官方 CLI 的 `VideoService.submitTask`:`POST /v2/videos/generations`,
4906
+ * 请求体由 `buildVideoBody` 拼(prompt / model / seconds / negative_prompt /
4907
+ * watermark:true / 图生视频时的 image_url / extra_parameters)。响应取
4908
+ * `data.id` 作为 task id 返回,真正的视频要再轮询 `/v2/videos/tasks` 才拿得到。
4909
+ *
4910
+ * 这是一次**花钱**的请求(与生图一样),所以失败分类交给 {@link describeVideoFailure}:
4911
+ * 401/403 提示重新登录、404 提示该区没有视频路由、429 提示限流、其它透传 msg。
4912
+ * HTTP 200 但信封 `code !== 0` 由 {@link parseVideoSubmission} 处理。
4913
+ */
4914
+ async submitVideo(credential, request, signal) {
4915
+ const isImageToVideo = request.image !== void 0 || request.lastImage !== void 0;
4916
+ const body = buildVideoBody({
4917
+ model: request.model,
4918
+ prompt: request.prompt,
4919
+ seconds: request.seconds,
4920
+ negativePrompt: request.negativePrompt,
4921
+ image: request.image,
4922
+ lastImage: request.lastImage,
4923
+ resolution: request.resolution,
4924
+ aspectRatio: request.aspectRatio,
4925
+ enableAudio: request.enableAudio
4926
+ }, isImageToVideo);
4927
+ const response = await fetch(`${chatBase(credential)}${VIDEO_GENERATIONS_PATH}`, {
4928
+ method: "POST",
4929
+ headers: imageHeaders(credential, this.identity),
4930
+ body: JSON.stringify(body),
4931
+ signal: signal ?? AbortSignal.timeout(VIDEO_TIMEOUT_MS)
4932
+ });
4933
+ const text = await response.text();
4934
+ if (!response.ok) throw describeVideoFailure(response.status, text, "submit");
4935
+ let payload;
4936
+ try {
4937
+ payload = JSON.parse(text);
4938
+ } catch {
4939
+ throw new WorkBuddyVideoError(`workbuddy video: 上游返回的不是 JSON(HTTP ${response.status}): ${text.slice(0, 200)}`, "UPSTREAM");
4940
+ }
4941
+ return parseVideoSubmission(payload);
4942
+ }
4943
+ /**
4944
+ * 轮询一个生视频任务直到完成或失败。
4945
+ *
4946
+ * `POST /v2/videos/tasks`(请求体 `{ task_id }`),官方 CLI 的 `VideoService.pollTask`
4947
+ * 就是每 5 秒查一次、直到 `status === 'completed' | 'failed'`。这里照搬同样的
4948
+ * 轮询节奏与整体超时(默认 ~600s,远大于生图的 180s,因为视频要生成几十秒到几分钟)。
4949
+ *
4950
+ * 注意:轮询**已经花过钱**(提交那次就扣了),所以轮询期间的网络抖动、超时要
4951
+ * 当成真实失败——调用方据此进冷却。只有"提交那一次"因为参数写错 / 没登录而没
4952
+ * 发出去,才不进冷却(同生图工具的设计)。
4953
+ */
4954
+ async pollVideoTask(credential, taskId, signal) {
4955
+ const startedAt = Date.now();
4956
+ const ownSignal = signal ?? AbortSignal.timeout(VIDEO_TIMEOUT_MS);
4957
+ const combined = signal === void 0 ? ownSignal : AbortSignal.any([signal, AbortSignal.timeout(VIDEO_TIMEOUT_MS)]);
4958
+ for (;;) {
4959
+ if (combined.aborted) throw new WorkBuddyVideoError("workbuddy video: 轮询被取消或超时。", "POLL_TIMEOUT");
4960
+ if (Date.now() - startedAt > VIDEO_POLL_TIMEOUT_MS) throw new WorkBuddyVideoError(`workbuddy video: 轮询超过 ${VIDEO_POLL_TIMEOUT_MS / 1e3} 秒仍未完成(task ${taskId})。`, "POLL_TIMEOUT");
4961
+ let payload;
4962
+ try {
4963
+ const response = await fetch(`${chatBase(credential)}${VIDEO_TASKS_PATH}`, {
4964
+ method: "POST",
4965
+ headers: imageHeaders(credential, this.identity),
4966
+ body: JSON.stringify({ task_id: taskId }),
4967
+ signal: combined
4968
+ });
4969
+ const text = await response.text();
4970
+ if (!response.ok) throw describeVideoFailure(response.status, text, "poll");
4971
+ try {
4972
+ payload = JSON.parse(text);
4973
+ } catch {
4974
+ throw new WorkBuddyVideoError(`workbuddy video: 轮询响应不是 JSON(HTTP ${response.status}): ${text.slice(0, 200)}`, "UPSTREAM");
4975
+ }
4976
+ } catch (error) {
4977
+ if (error instanceof WorkBuddyVideoError) throw error;
4978
+ throw new WorkBuddyVideoError(`workbuddy video: 轮询请求失败:${error instanceof Error ? error.message : String(error)}`, "UPSTREAM");
4979
+ }
4980
+ const result = parseVideoTask(payload);
4981
+ if (result.status === "completed") {
4982
+ if (result.videos.length === 0) throw new WorkBuddyVideoError("workbuddy video: 任务完成但没有返回任何视频 URL。", "EMPTY_RESULT");
4983
+ return {
4984
+ videos: result.videos,
4985
+ ...result.credit !== void 0 ? { credit: result.credit } : {}
4986
+ };
4987
+ }
4988
+ if (result.status === "failed") throw new WorkBuddyVideoError(`workbuddy video: 生视频任务失败(task ${taskId})。`, "TASK_FAILED");
4989
+ await new Promise((resolve, reject) => {
4990
+ const timer = setTimeout(resolve, VIDEO_POLL_INTERVAL_MS);
4991
+ const onAbort = () => {
4992
+ clearTimeout(timer);
4993
+ reject(new WorkBuddyVideoError("workbuddy video: 轮询被取消。", "POLL_TIMEOUT"));
4994
+ };
4995
+ combined.addEventListener("abort", onAbort, { once: true });
4996
+ });
4997
+ }
4998
+ }
4999
+ /**
5000
+ * 下载一个视频 URL 到本地临时目录,返回绝对路径。
5001
+ *
5002
+ * 官方 CLI 的 `VideoService.downloadVideos` 把每条 `data[].url` 写成本地 `.mp4`
5003
+ * (产物是 COS 签名 URL,和生图同源,但不是图片 MIME)。这里每次一条,工具层
5004
+ * 负责把多条结果各自落盘并回 `file://` 给模型与用户。
5005
+ */
5006
+ async downloadVideo(credential, url, signal) {
5007
+ const response = await fetch(url, { signal: signal ?? AbortSignal.timeout(VIDEO_DOWNLOAD_TIMEOUT_MS) });
5008
+ if (!response.ok) throw new WorkBuddyVideoError(`workbuddy video: 下载视频失败(HTTP ${response.status}): ${url}`, "UPSTREAM");
5009
+ const buffer = Buffer.from(await response.arrayBuffer());
5010
+ const dir = join(tmpdir(), "workbuddy-video-gen");
5011
+ await mkdir(dir, { recursive: true });
5012
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
5013
+ const safeUrl = url.replace(/[^A-Za-z0-9._-]+/g, "_").slice(-32);
5014
+ const path = join(dir, `video-${stamp}-${safeUrl}.mp4`);
5015
+ await writeFile(path, buffer);
5016
+ return path;
5017
+ }
4435
5018
  /** Query today's check-in status without changing account state. */
4436
5019
  async fetchCheckinStatus(credential) {
4437
5020
  const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
@@ -6983,7 +7566,7 @@ function describeRosterSeenPayload(raw) {
6983
7566
  //#endregion
6984
7567
  //#region src/version.ts
6985
7568
  /** The npm package version this build was produced from. */
6986
- const WORKBUDDY_CONNECT_VERSION = "5.9.0";
7569
+ const WORKBUDDY_CONNECT_VERSION = "5.11.0";
6987
7570
  //#endregion
6988
7571
  //#region src/host-heartbeat.ts
6989
7572
  /**
@@ -7141,4 +7724,4 @@ async function clearHostHeartbeat() {
7141
7724
  await rm(workbuddyHostHeartbeatPath(), { force: true });
7142
7725
  }
7143
7726
  //#endregion
7144
- export { parseCreditMultiplier as $, isEncryptedCredentialError as A, isEncryptedFieldWrapper as At, WorkBuddyCredentialRejectedError as B, writeFileAtomic as Bt, WorkBuddyCredentialStore as C, WORKBUDDY_APP_EXECUTABLE_ENV as Ct, defaultDesktopAuthDirs as D, fetchAtRestKeyPayload as Dt, defaultDesktopAuthCandidates as E, deriveAtRestKeyId as Et, workbuddyAccountId as F, readAtRestKey as Ft, forceNeutralHead as G, channelGateMemberOf as H, workbuddyOwnAuthPath as I, workbuddyAppExecutableCandidates as It, isCredentialRejectedError as J, isContextTooLongBody as K, CONTEXT_OVERFLOW_MARKER as L, hostPeersInfo as Lt, legacyWorkbuddyOwnAuthPath as M, macosBundleExecutable as Mt, parseWorkBuddyAuth as N, macosNestedAppBundles as Nt, defaultDesktopAuthPath as O, findWorkbuddyAppExecutable as Ot, preferredAccountName as P, openEncryptedField as Pt, openingEvidenceOfBody as Q, CREDENTIAL_REJECTED_CODE as R, resolveDshHome as Rt, WORKBUDDY_AUTH_FILE_ENV as S, nativeModalityOf as St, authFileName as T, deriveAtRestKey as Tt, classifyUpstreamError as U, WorkBuddyUpstreamClient as V, declaredTools as W, knownRegionOf as X, isFirstMessageGateBody as Y, modelIdOfBody as Z, deriveCatalog as _, pickImageModel as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, NATIVE_MODALITY_BY_MODEL_ID as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, WorkBuddyImageError as gt, WorkBuddyCatalog as h, DEFAULT_IMAGE_SIZE as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, isWorkbuddyBundle as jt, hasEncryptedCredentialFields as k, findWorkbuddyAppExecutableWithSource as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseQueueAnswer as vt, WorkBuddyEncryptedCredentialError as w, clearAtRestKeyCache as wt, WORKBUDDY_AUTH_FILENAME as x, imageDefaultFor as xt, withoutRouters as y, queueNoticeText as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, withFileLock as zt };
7727
+ export { parseCreditMultiplier as $, withFileLock as $t, isEncryptedCredentialError as A, pickMediaModel as At, WorkBuddyCredentialRejectedError as B, deriveAtRestKeyId as Bt, WorkBuddyCredentialStore as C, IMAGE_TAG_TEXT_TO_IMAGE as Ct, defaultDesktopAuthDirs as D, imageMimeForPath as Dt, defaultDesktopAuthCandidates as E, imageDataUrl as Et, workbuddyAccountId as F, imageDefaultFor as Ft, forceNeutralHead as G, isWorkbuddyBundle as Gt, channelGateMemberOf as H, findWorkbuddyAppExecutable as Ht, workbuddyOwnAuthPath as I, nativeModalityOf as It, isCredentialRejectedError as J, openEncryptedField as Jt, isContextTooLongBody as K, macosBundleExecutable as Kt, CONTEXT_OVERFLOW_MARKER as L, WORKBUDDY_APP_EXECUTABLE_ENV as Lt, legacyWorkbuddyOwnAuthPath as M, parseQueueAnswer as Mt, parseWorkBuddyAuth as N, queueNoticeText as Nt, defaultDesktopAuthPath as O, isBareBase64Image as Ot, preferredAccountName as P, NATIVE_MODALITY_BY_MODEL_ID as Pt, openingEvidenceOfBody as Q, resolveDshHome as Qt, CREDENTIAL_REJECTED_CODE as R, clearAtRestKeyCache as Rt, WORKBUDDY_AUTH_FILE_ENV as S, IMAGE_TAG_IMAGE_TO_IMAGE as St, authFileName as T, WorkBuddyImageError as Tt, classifyUpstreamError as U, findWorkbuddyAppExecutableWithSource as Ut, WorkBuddyUpstreamClient as V, fetchAtRestKeyPayload as Vt, declaredTools as W, isEncryptedFieldWrapper as Wt, knownRegionOf as X, workbuddyAppExecutableCandidates as Xt, isFirstMessageGateBody as Y, readAtRestKey as Yt, modelIdOfBody as Z, hostPeersInfo as Zt, deriveCatalog as _, WorkBuddyVideoError as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, resolveVideoParams as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, writeFileAtomic as en, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, VIDEO_TAG_TEXT_TO_VIDEO as gt, WorkBuddyCatalog as h, VIDEO_TAG_IMAGE_TO_VIDEO as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, sniffImageMime as jt, hasEncryptedCredentialFields as k, parseImageDataUrl as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, macosNestedAppBundles as qt, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseSupportedSecondsRange as vt, WorkBuddyEncryptedCredentialError as w, MAX_INPUT_IMAGE_BYTES as wt, WORKBUDDY_AUTH_FILENAME as x, DEFAULT_IMAGE_SIZE as xt, withoutRouters as y, pickVideoModel as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, deriveAtRestKey as zt };