dsh-connect-workbuddy 5.10.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";
@@ -1204,18 +1204,43 @@ var WorkBuddyImageError = class extends Error {
1204
1204
  this.code = code;
1205
1205
  }
1206
1206
  };
1207
- function asString(value) {
1207
+ function asString$1(value) {
1208
1208
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
1209
1209
  }
1210
1210
  function stringList(value) {
1211
1211
  if (!Array.isArray(value)) return [];
1212
1212
  const out = [];
1213
1213
  for (const item of value) {
1214
- const text = asString(item);
1214
+ const text = asString$1(item);
1215
1215
  if (text !== void 0) out.push(text);
1216
1216
  }
1217
1217
  return out;
1218
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
+ }
1219
1244
  /**
1220
1245
  * 从产品配置的 `data.models` 里挑出带指定 tag 的媒体模型。
1221
1246
  *
@@ -1233,21 +1258,23 @@ function selectMediaModels(rawModels, tag = IMAGE_TAG_TEXT_TO_IMAGE) {
1233
1258
  for (const entry of rawModels) {
1234
1259
  if (typeof entry !== "object" || entry === null) continue;
1235
1260
  const record = entry;
1236
- const id = asString(record["id"]);
1261
+ const id = asString$1(record["id"]);
1237
1262
  if (id === void 0 || seen.has(id)) continue;
1238
1263
  const tags = stringList(record["tags"]);
1239
1264
  if (!wanted.some((one) => tags.includes(one))) continue;
1240
1265
  seen.add(id);
1266
+ const videoGeneration = readVideoGeneration(record["videoGeneration"]);
1241
1267
  out.push({
1242
1268
  id,
1243
- name: asString(record["name"]) ?? id,
1244
- tags
1269
+ name: asString$1(record["name"]) ?? id,
1270
+ tags,
1271
+ ...videoGeneration === void 0 ? {} : { videoGeneration }
1245
1272
  });
1246
1273
  }
1247
1274
  return out;
1248
1275
  }
1249
1276
  /** tag 的人话名字,只用在错误信息里。 */
1250
- function tagLabel(tag) {
1277
+ function tagLabel$1(tag) {
1251
1278
  if (tag === "image-to-image") return "图生图";
1252
1279
  if (tag === "text-to-image") return "文生图";
1253
1280
  return tag;
@@ -1265,12 +1292,12 @@ function pickMediaModel(models, requested, tag = IMAGE_TAG_TEXT_TO_IMAGE) {
1265
1292
  const found = group.find((model) => model.id === requested);
1266
1293
  if (found === void 0) {
1267
1294
  const known = group.map((model) => model.id).join(", ");
1268
- throw new WorkBuddyImageError(`workbuddy image: 目录里没有${tagLabel(tag)}模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_IMAGE_MODEL");
1295
+ throw new WorkBuddyImageError(`workbuddy image: 目录里没有${tagLabel$1(tag)}模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_IMAGE_MODEL");
1269
1296
  }
1270
1297
  return found;
1271
1298
  }
1272
1299
  const first = group[0];
1273
- if (first === void 0) throw new WorkBuddyImageError(`workbuddy image: 该区账号的产品配置里没有任何带 ${tag} 标签的模型(${tagLabel(tag)})。国内区文生图应是 hunyuan-image-alpha、改图是 hunyuan-image-alpha-edit;国际区 hy-image-3.5 同时带这两个标签;一个都没有说明该区目录暂时没有这类模型。`, "NO_IMAGE_MODEL");
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");
1274
1301
  return first;
1275
1302
  }
1276
1303
  /** 按官方 CLI 的 `buildBaseRequestBody` 分支规则拼请求体。 */
@@ -1379,8 +1406,8 @@ function parseImageResponse(payload) {
1379
1406
  const envelope = payload;
1380
1407
  const code = typeof envelope["code"] === "number" ? envelope["code"] : void 0;
1381
1408
  if (code !== void 0 && code !== 0) {
1382
- const message = asString(envelope["msg"]) ?? "unknown error";
1383
- const requestId = asString(envelope["request_id"]);
1409
+ const message = asString$1(envelope["msg"]) ?? "unknown error";
1410
+ const requestId = asString$1(envelope["request_id"]);
1384
1411
  throw new WorkBuddyImageError(`workbuddy image: 上游拒绝(code ${code}): ${message}` + (requestId === void 0 ? "" : `(request_id ${requestId})`), "UPSTREAM");
1385
1412
  }
1386
1413
  const data = envelope["data"];
@@ -1392,8 +1419,8 @@ function parseImageResponse(payload) {
1392
1419
  let b64;
1393
1420
  if (typeof first === "object" && first !== null) {
1394
1421
  const item = first;
1395
- url = asString(item["url"]);
1396
- b64 = asString(item["b64_json"]);
1422
+ url = asString$1(item["url"]);
1423
+ b64 = asString$1(item["b64_json"]);
1397
1424
  }
1398
1425
  if (url === void 0 && b64 === void 0) throw new WorkBuddyImageError("workbuddy image: 上游返回成功但 data.data 里既没有 url 也没有 b64_json", "EMPTY_RESULT");
1399
1426
  const usage = typeof record["usage"] === "object" && record["usage"] !== null ? record["usage"] : {};
@@ -1414,7 +1441,7 @@ function describeImageFailure(status, bodyText) {
1414
1441
  const parsed = JSON.parse(text);
1415
1442
  if (typeof parsed === "object" && parsed !== null) {
1416
1443
  const record = parsed;
1417
- const message = asString(record["msg"]);
1444
+ const message = asString$1(record["msg"]);
1418
1445
  const code = typeof record["code"] === "number" ? record["code"] : void 0;
1419
1446
  if (message !== void 0) detail = code === void 0 ? message : `code ${code}: ${message}`;
1420
1447
  }
@@ -1425,6 +1452,284 @@ function describeImageFailure(status, bodyText) {
1425
1452
  return new WorkBuddyImageError(`workbuddy image: 上游失败(HTTP ${status})${detail === "" ? "" : `: ${detail}`}`, "UPSTREAM");
1426
1453
  }
1427
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
1428
1733
  //#region src/off-thinking.ts
1429
1734
  /**
1430
1735
  * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
@@ -2071,6 +2376,14 @@ const PROBE_TIMEOUT_MS = 1e4;
2071
2376
  * 又不至于让一个卡死的连接占住工具槽位太久。
2072
2377
  */
2073
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;
2074
2387
  /**
2075
2388
  * Insufficient-credit markers.
2076
2389
  *
@@ -4586,6 +4899,122 @@ var WorkBuddyUpstreamClient = class {
4586
4899
  }
4587
4900
  return parseImageResponse(payload);
4588
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
+ }
4589
5018
  /** Query today's check-in status without changing account state. */
4590
5019
  async fetchCheckinStatus(credential) {
4591
5020
  const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
@@ -7137,7 +7566,7 @@ function describeRosterSeenPayload(raw) {
7137
7566
  //#endregion
7138
7567
  //#region src/version.ts
7139
7568
  /** The npm package version this build was produced from. */
7140
- const WORKBUDDY_CONNECT_VERSION = "5.10.0";
7569
+ const WORKBUDDY_CONNECT_VERSION = "5.11.0";
7141
7570
  //#endregion
7142
7571
  //#region src/host-heartbeat.ts
7143
7572
  /**
@@ -7295,4 +7724,4 @@ async function clearHostHeartbeat() {
7295
7724
  await rm(workbuddyHostHeartbeatPath(), { force: true });
7296
7725
  }
7297
7726
  //#endregion
7298
- export { parseCreditMultiplier as $, isEncryptedCredentialError as A, nativeModalityOf as At, WorkBuddyCredentialRejectedError as B, macosBundleExecutable as Bt, WorkBuddyCredentialStore as C, parseImageDataUrl as Ct, defaultDesktopAuthDirs as D, queueNoticeText as Dt, defaultDesktopAuthCandidates as E, parseQueueAnswer as Et, workbuddyAccountId as F, fetchAtRestKeyPayload as Ft, forceNeutralHead as G, hostPeersInfo as Gt, channelGateMemberOf as H, openEncryptedField as Ht, workbuddyOwnAuthPath as I, findWorkbuddyAppExecutable as It, isCredentialRejectedError as J, writeFileAtomic as Jt, isContextTooLongBody as K, resolveDshHome as Kt, CONTEXT_OVERFLOW_MARKER as L, findWorkbuddyAppExecutableWithSource as Lt, legacyWorkbuddyOwnAuthPath as M, clearAtRestKeyCache as Mt, parseWorkBuddyAuth as N, deriveAtRestKey as Nt, defaultDesktopAuthPath as O, NATIVE_MODALITY_BY_MODEL_ID as Ot, preferredAccountName as P, deriveAtRestKeyId as Pt, openingEvidenceOfBody as Q, CREDENTIAL_REJECTED_CODE as R, isEncryptedFieldWrapper as Rt, WORKBUDDY_AUTH_FILE_ENV as S, isBareBase64Image as St, authFileName as T, sniffImageMime as Tt, classifyUpstreamError as U, readAtRestKey as Ut, WorkBuddyUpstreamClient as V, macosNestedAppBundles as Vt, declaredTools as W, workbuddyAppExecutableCandidates as Wt, knownRegionOf as X, isFirstMessageGateBody as Y, modelIdOfBody as Z, deriveCatalog as _, IMAGE_TAG_TEXT_TO_IMAGE as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, imageDataUrl 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, IMAGE_TAG_IMAGE_TO_IMAGE as gt, WorkBuddyCatalog as h, DEFAULT_IMAGE_SIZE as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, WORKBUDDY_APP_EXECUTABLE_ENV as jt, hasEncryptedCredentialFields as k, imageDefaultFor 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, withFileLock 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, MAX_INPUT_IMAGE_BYTES as vt, WorkBuddyEncryptedCredentialError as w, pickMediaModel as wt, WORKBUDDY_AUTH_FILENAME as x, imageMimeForPath as xt, withoutRouters as y, WorkBuddyImageError as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, isWorkbuddyBundle 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 };