dsh-llm-codebuddy-power 1.0.0 → 1.3.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.
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, ToolCallId, contentHasImage, isContextWindowExceededError, isQuotaExceededError, resolveImageAttachmentAccess } from "@deepseek-ai/dsh-llm";
1
3
  import { gzipSync } from "node:zlib";
2
- import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, ToolCallId, contentHasImage, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
3
4
  import { randomBytes, randomUUID } from "node:crypto";
4
5
  import { promises } from "node:fs";
5
6
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
@@ -7,7 +8,6 @@ import { dirname } from "node:path";
7
8
  import { EventSourceParserStream } from "eventsource-parser/stream";
8
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
10
  import { spawn } from "node:child_process";
10
- import z from "@deepseek-ai/schemastery";
11
11
  //#region lib/types/constants.js
12
12
  /**
13
13
  * CodeBuddy 服务的固定事实。
@@ -77,8 +77,23 @@ const REQUEST_IMAGE_DIMENSION_LADDER = [
77
77
  const LOGIN_TIMEOUT_MS = 600 * 1e3;
78
78
  /** 等待浏览器登录完成时的轮询间隔,单位 ms。 */
79
79
  const LOGIN_POLL_INTERVAL_MS = 1e3;
80
+ /**
81
+ * 握手结算后在宿主表里保留结果的时长,单位 ms。
82
+ *
83
+ * 逾期即回收,但**客户端是过一会儿才来问的**(它按自己的间隔轮询),所以结果不能
84
+ * 一结算就丢:一丢,那次本已成功的登录就再也答不出 `done: true`,客户端只能干等到
85
+ * 十分钟截止。保留窗口只需覆盖一个客户端轮询周期,取一个宽裕值。
86
+ */
87
+ const LOGIN_RESULT_RETENTION_MS = 60 * 1e3;
80
88
  /** 含义为“浏览器登录尚未完成”的服务码。 */
81
89
  const AUTH_PENDING_CODE = 11217;
90
+ /**
91
+ * 一次性赠送活动所用的子产品编码。
92
+ *
93
+ * "裂变包"与"拉新权益包"两类赠送活动各有自己的 `PackageCode`,却共用这一个子产品
94
+ * 编码,而可续期主套餐用的是另一个,因此它是区分赠送与主体的稳定依据。
95
+ */
96
+ const CODEBUDDY_GIFT_SUB_PRODUCT_CODE = "sp_tcaca_codebuddyide_bonus_pack";
82
97
  //#endregion
83
98
  //#region lib/types/codebuddy.js
84
99
  /**
@@ -525,16 +540,19 @@ function normalizeResetTime(raw) {
525
540
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
526
541
  }
527
542
  /**
528
- * 判断某个套餐显示名是否表示一次性赠送额度。
543
+ * 判断某个计量窗口是否是一次性赠送额度。
529
544
  *
530
- * CodeBuddy 的个人计量面把推荐/裂变额度命名为“裂变包”,未来任何促销都可能
531
- * 带有“赠送”;其他一切(试用/可续期主套餐、企业行)都视为主池。显示名是
532
- * 计量面暴露的唯一区分依据,因此两种写法都会被匹配。
533
- * @param name - 窗口的显示名。
534
- * @returns 赠送套餐名返回 true。
545
+ * 依据是子产品编码:{@link CODEBUDDY_GIFT_SUB_PRODUCT_CODE} 由全部赠送活动共用,
546
+ * 而可续期主套餐用另一个编码。套餐显示名不能独立作依据 —— 两类赠送活动的
547
+ * `PackageCode` 不同、显示名也不同,只有一类名字里带“裂变”,另一类叫“拉新权益包”,
548
+ * 按名匹配会把后者错算进主池。显示名与子产品名因此只作兜底,供未来换了子产品编码
549
+ * 却仍在名字里沿用“裂变/赠送”字样的赠送活动使用。
550
+ * @param resource - `Accounts` 数组里的一项。
551
+ * @returns 赠送窗口返回 true。
535
552
  */
536
- function isGiftName(name) {
537
- return /裂变|赠送/.test(name);
553
+ function isGiftResource(resource) {
554
+ if (string(resource, "SubProductCode") === "sp_tcaca_codebuddyide_bonus_pack") return true;
555
+ return /裂变|赠送/.test(string(resource, "PackageName") ?? "") || /裂变|赠送/.test(string(resource, "SubProductName") ?? "");
538
556
  }
539
557
  /**
540
558
  * 把有可用容量的窗口聚合成一个池(used/limit 求和)。
@@ -563,13 +581,13 @@ function liveGiftWindows(windows) {
563
581
  /**
564
582
  * 构建个人快照的主池/赠送池划分,以及提示框的赠送明细(最早过期的有效赠送
565
583
  * 在前,最多 5 个)。
566
- * @param windows - 已解析的窗口。
584
+ * @param windows - 已解析的窗口,各自带 {@link UsageWindow.gift} 归属。
567
585
  * @returns 该划分,仅当对应池有可用 limit 时才包含 `main`/`gift`。
568
586
  */
569
587
  function splitPools(windows) {
570
588
  const mainWindows = [];
571
589
  const giftWindows = [];
572
- for (const window of windows) if (isGiftName(window.name)) giftWindows.push(window);
590
+ for (const window of windows) if (window.gift) giftWindows.push(window);
573
591
  else mainWindows.push(window);
574
592
  const main = poolOf(mainWindows);
575
593
  const live = liveGiftWindows(giftWindows);
@@ -608,14 +626,17 @@ function personalUsage(accounts) {
608
626
  const name = string(resource, "PackageName") ?? string(resource, "PackageCode") ?? string(resource, "ResourceId") ?? `resource_${index}`;
609
627
  const rawReset = string(resource, "CycleEndTime");
610
628
  const resetsAt = rawReset === void 0 ? void 0 : normalizeResetTime(rawReset);
629
+ const gift = isGiftResource(resource);
611
630
  if (limit <= 0) return {
612
631
  name,
632
+ gift,
613
633
  ...resetsAt === void 0 ? {} : { resetsAt }
614
634
  };
615
635
  return {
616
636
  name,
617
637
  used,
618
638
  limit,
639
+ gift,
619
640
  ...resetsAt === void 0 ? {} : { resetsAt }
620
641
  };
621
642
  });
@@ -645,6 +666,7 @@ function enterpriseUsage(data) {
645
666
  name: "enterprise",
646
667
  used,
647
668
  limit,
669
+ gift: false,
648
670
  ...reset === void 0 ? {} : { resetsAt: reset }
649
671
  };
650
672
  const main = poolOf([window]);
@@ -908,10 +930,14 @@ function isAccountRef(value) {
908
930
  /**
909
931
  * 文件读出的记录是否合法。
910
932
  *
911
- * `accounts` 与 `credit` 同为必填:一条记录说不出这一轮用的是哪个账号,悬停提示就会
912
- * 缺一行,那与"没有这条记录"没有区别,因此不完整即整条丢弃。引用还必须能在同一份
913
- * 文件的账号表里解析到 —— 一个悬空引用渲染不出账号,与缺字段是同一种残缺。
914
- * 插件尚未发布,磁盘上不存在按旧格式写下的文件,故不保留渐进读取。
933
+ * `credit` 与 `accounts` 同为必填:一条记录说不出这一轮扣了多少、用的是哪个账号,
934
+ * 就答不了它存在的唯一问题,与"没有这条记录"没有区别,因此不完整即整条丢弃。引用
935
+ * 还必须能在同一份文件的账号表里解析到 —— 一个悬空引用渲染不出账号,与缺字段是同
936
+ * 一种残缺。
937
+ *
938
+ * `requestId` 则允许缺失:它是后加的字段,已发布的旧版本写下的记录没有它。那些轮次
939
+ * 照常显示积分,只是拿不出请求 id,因此不值得为它丢掉整轮积分。存在时必须是字符串,
940
+ * 否则是损坏内容而不是旧格式。
915
941
  * @param value - 文件里的一条轮次记录。
916
942
  * @param table - 同一份文件的账号表。
917
943
  * @returns 该记录是否可用。
@@ -919,7 +945,7 @@ function isAccountRef(value) {
919
945
  function isCreditRecord(value, table) {
920
946
  if (typeof value !== "object" || value === null) return false;
921
947
  const record = value;
922
- return typeof record.credit === "number" && Number.isFinite(record.credit) && Array.isArray(record.accounts) && record.accounts.length > 0 && record.accounts.every((key) => typeof key === "string" && isAccountRef(table[key]));
948
+ return typeof record.credit === "number" && Number.isFinite(record.credit) && Array.isArray(record.accounts) && record.accounts.length > 0 && record.accounts.every((key) => typeof key === "string" && isAccountRef(table[key])) && (record.requestId === void 0 || typeof record.requestId === "string");
923
949
  }
924
950
  /**
925
951
  * 读取一个会话的信息。
@@ -1005,7 +1031,7 @@ async function updateSessionInfo(sessionId, change) {
1005
1031
  * (`$DSH_HOME/data/plugins/llm-codebuddy-power/`,通过 harness 所用的同一个
1006
1032
  * `@deepseek-ai/dsh-home-paths` 解析),而不放在插件包内,因此重装不会让
1007
1033
  * 用户退出登录。写入经 [atomic-file.ts](./atomic-file.ts) 做原子替换并按路径串行化:
1008
- * 多个账号的令牌刷新、设置页的增删与 CLI 的调用都在改同一份文件,不串行化时
1034
+ * 多个账号的令牌刷新、配置页的增删与 CLI 的调用都在改同一份文件,不串行化时
1009
1035
  * 两个并发写入会各自基于自己读到的旧内容覆盖对方。残缺文件会让用户手里只有
1010
1036
  * 一份读不出的凭据,还无法与“从未登录过”区分开。
1011
1037
  *
@@ -1080,9 +1106,8 @@ function trimEmptyAccountFields(account) {
1080
1106
  * 把解析出的 JSON 归一化为账号集合。
1081
1107
  *
1082
1108
  * 同时接受当前形态与仅有一个账号的旧形态(顶层 `auth` + `account`):旧文件
1083
- * 迁移成单元素列表,并让那个账号成为默认账号,于是升级不会改变用户下一次
1084
- * 请求用的是谁。`defaultUid` 与 `lastUid` 指向已不存在的账号时丢弃——它们只是
1085
- * 指针,留着会让“有没有默认账号”与实际情况相反。
1109
+ * 迁移成单元素列表,于是升级不会改变用户下一次请求用的是谁。`lastUid` 指向已不
1110
+ * 存在的账号时丢弃 —— 它只是个指针,留着会让兜底落到一个用不了的账号上。
1086
1111
  * @param parsed - 解析出的 JSON 值。
1087
1112
  * @returns 归一化后的账号集合。
1088
1113
  */
@@ -1098,22 +1123,17 @@ function normalizeStorage(parsed) {
1098
1123
  if (typeof value !== "string") return void 0;
1099
1124
  return accounts.some((a) => a.account.uid === value) ? value : void 0;
1100
1125
  };
1101
- const defaultUid = pick(record.defaultUid);
1102
1126
  const lastUid = pick(record.lastUid);
1103
1127
  return {
1104
1128
  accounts,
1105
- ...defaultUid === void 0 ? {} : { defaultUid },
1106
1129
  ...lastUid === void 0 ? {} : { lastUid }
1107
1130
  };
1108
1131
  }
1109
1132
  if (!isAccountEntry(record)) return emptyStorage();
1110
- return {
1111
- accounts: [{
1112
- auth: record.auth,
1113
- account: trimEmptyAccountFields(record.account)
1114
- }],
1115
- defaultUid: record.account.uid
1116
- };
1133
+ return { accounts: [{
1134
+ auth: record.auth,
1135
+ account: trimEmptyAccountFields(record.account)
1136
+ }] };
1117
1137
  }
1118
1138
  /**
1119
1139
  * 读取已存储的账号集合。
@@ -1153,18 +1173,17 @@ async function updateStorage(change) {
1153
1173
  * 插入一个账号,或替换同 uid 账号的凭据。
1154
1174
  *
1155
1175
  * 同 uid 视为重新登录同一个账号:令牌被替换而不是追加成第二个条目,否则
1156
- * 设置页会出现两个同名徽章,退出其中一个还会留下另一个。
1176
+ * 配置页会出现两个同名徽章,退出其中一个还会留下另一个。
1157
1177
  * @param entry - 刚登录得到的账号。
1158
1178
  */
1159
1179
  async function upsertAccount(entry) {
1160
1180
  await updateStorage((current) => {
1161
1181
  const uid = entry.account.uid;
1162
1182
  const accounts = current.accounts.some((a) => a.account.uid === uid) ? current.accounts.map((a) => a.account.uid === uid ? entry : a) : [...current.accounts, entry];
1163
- const defaultUid = current.defaultUid ?? (current.accounts.length === 0 ? uid : void 0);
1183
+ const lastUid = current.lastUid ?? (current.accounts.length === 0 ? uid : void 0);
1164
1184
  return {
1165
1185
  accounts,
1166
- ...defaultUid === void 0 ? {} : { defaultUid },
1167
- ...current.lastUid === void 0 ? {} : { lastUid: current.lastUid }
1186
+ ...lastUid === void 0 ? {} : { lastUid }
1168
1187
  };
1169
1188
  });
1170
1189
  }
@@ -1198,39 +1217,22 @@ async function reorderAccounts(uids) {
1198
1217
  });
1199
1218
  }
1200
1219
  /**
1201
- * 移除若干账号,并清掉指向它们的默认账号与“最后一次手动切换”的账号。
1220
+ * 移除若干账号,并清掉指向它们的“最后一次手动切换”的账号。
1202
1221
  * @param uids - 要移除的账号 uid。
1203
1222
  */
1204
1223
  async function removeAccounts(uids) {
1205
1224
  const removed = new Set(uids);
1206
1225
  await updateStorage((current) => {
1207
1226
  const accounts = current.accounts.filter((a) => !removed.has(a.account.uid));
1208
- const gone = (uid) => uid !== void 0 && removed.has(uid) ? void 0 : uid;
1209
- const defaultUid = gone(current.defaultUid);
1210
- const lastUid = gone(current.lastUid);
1227
+ const lastUid = current.lastUid !== void 0 && removed.has(current.lastUid) ? void 0 : current.lastUid;
1211
1228
  return {
1212
1229
  accounts,
1213
- ...defaultUid === void 0 ? {} : { defaultUid },
1214
1230
  ...lastUid === void 0 ? {} : { lastUid }
1215
1231
  };
1216
1232
  });
1217
1233
  }
1218
1234
  /**
1219
- * 指定或取消默认账号。
1220
- * @param uid - 默认账号的 uid;传 `undefined` 取消默认账号。
1221
- */
1222
- async function setDefaultAccount(uid) {
1223
- await updateStorage((current) => {
1224
- const next = {
1225
- accounts: current.accounts,
1226
- ...current.lastUid === void 0 ? {} : { lastUid: current.lastUid }
1227
- };
1228
- if (uid !== void 0 && current.accounts.some((a) => a.account.uid === uid)) next.defaultUid = uid;
1229
- return next;
1230
- });
1231
- }
1232
- /**
1233
- * 记下最后一次被手动切换到的账号(没有默认账号时作为兜底)。
1235
+ * 记下最后一次被手动切换到的账号,作为未选用账号的会话的兜底。
1234
1236
  * @param uid - 被切换到的账号 uid。
1235
1237
  */
1236
1238
  async function setLastAccount(uid) {
@@ -1265,7 +1267,8 @@ async function storageFreshness() {
1265
1267
  * 并发调用会互相取到对方的刷新结果,其中一个的 token 永远停留过期。
1266
1268
  *
1267
1269
  * 账号由会话决定:某个会话显式选了账号就用它,否则顺延到派生它的会话(子代理、
1268
- * fork)、默认账号、最后一次手动切换的账号,最后落到列表第一个。目录与计量快照
1270
+ * fork),再落到账号级兜底(最后一次手动切换的账号,其次是列表第一个)。会话在
1271
+ * 第一次真正发请求时把解析到的账号固定下来,此后不再跟随兜底。目录与计量快照
1269
1272
  * 也按账号分别缓存,因为不同账号的可用模型与额度本就可以不同。
1270
1273
  *
1271
1274
  * @module dsh-llm-codebuddy-power/session
@@ -1310,7 +1313,8 @@ function resolveCreditRecord(record, table) {
1310
1313
  }
1311
1314
  return {
1312
1315
  credit: record.credit,
1313
- accounts
1316
+ accounts,
1317
+ ...record.requestId === void 0 ? {} : { requestId: record.requestId }
1314
1318
  };
1315
1319
  }
1316
1320
  /**
@@ -1380,7 +1384,7 @@ var CodeBuddySession = class {
1380
1384
  *
1381
1385
  * 集合只由**带会话身份的**读取填充(聊天请求、模型面板),并供拿不到会话身份的
1382
1386
  * 调用方({@link knownModel}、`listModels`)查询:那些调用方无法知道该用哪个账号
1383
- * 的目录,主动读取只能落在默认账号上 —— 而当前会话用的可能正是另一个账号。
1387
+ * 的目录,主动读取只能落在账号级兜底上 —— 而当前会话用的可能正是另一个账号。
1384
1388
  */
1385
1389
  knownCatalog = /* @__PURE__ */ new Map();
1386
1390
  /** 按账号 uid 单飞的目录读取。 */
@@ -1497,12 +1501,14 @@ var CodeBuddySession = class {
1497
1501
  const existing = track.credits.get(turn);
1498
1502
  const accounts = existing?.accounts ?? [];
1499
1503
  const known = accounts.some((entry) => entry.uid === account.uid);
1504
+ const requestId = existing?.requestId ?? track.turnRequestId;
1500
1505
  const merged = {
1501
1506
  credit: (existing?.credit ?? 0) + credit,
1502
1507
  accounts: known ? accounts : [...accounts, {
1503
1508
  nickname: account.nickname,
1504
1509
  uid: account.uid
1505
- }]
1510
+ }],
1511
+ requestId
1506
1512
  };
1507
1513
  track.credits.set(turn, merged);
1508
1514
  updateSessionInfo(sessionId, (info) => {
@@ -1522,7 +1528,8 @@ var CodeBuddySession = class {
1522
1528
  ...info.credits,
1523
1529
  [String(turn)]: {
1524
1530
  credit: merged.credit,
1525
- accounts: keys
1531
+ accounts: keys,
1532
+ ...merged.requestId === void 0 ? {} : { requestId: merged.requestId }
1526
1533
  }
1527
1534
  }
1528
1535
  };
@@ -1571,7 +1578,7 @@ var CodeBuddySession = class {
1571
1578
  /**
1572
1579
  * 由当前缓存的各账号目录重建合并目录。
1573
1580
  *
1574
- * 账号退出后的处理必须是"重建"而不是"清空":清空会让官方列表塌回默认账号,
1581
+ * 账号退出后的处理必须是"重建"而不是"清空":清空会让官方列表塌回账号级兜底,
1575
1582
  * 而其余仍登录账号的目录本来就在手上 —— 继续并着才是"退出后重新合并当前已有的
1576
1583
  * 列表"。重建同时确定性地解决同 id 的归属:只剩下仍在的账号参与竞争。
1577
1584
  */
@@ -1607,7 +1614,7 @@ var CodeBuddySession = class {
1607
1614
  * 或每记一次“最后切换的账号”都会被当成账号变更,从而丢掉按账号缓存的计量快照并
1608
1615
  * 广播一次模型变更 —— 那些都不是账号增删。真的增删时才会丢弃已退出账号的缓存并
1609
1616
  * 重建合并目录:退出一个账号后官方列表应当继续并着其余账号已读到的目录,而不是
1610
- * 塌回默认账号一个。
1617
+ * 塌回账号级兜底那一个。
1611
1618
  */
1612
1619
  async refreshIfChanged() {
1613
1620
  const storageKey = await storageFreshness();
@@ -1629,8 +1636,9 @@ var CodeBuddySession = class {
1629
1636
  * 该会话自身及其派生祖先显式选用的账号,由近及远。读取某个会话的信息可能要碰盘,
1630
1637
  * 因此本方法是异步的。
1631
1638
  *
1632
- * 走到祖先是因为子代理与 fork 出来的会话在界面上没有自己的账号按钮:它们若不
1633
- * 沿用父会话,就会静默改用默认账号,把一次派发记到另一个账号头上。
1639
+ * 走到祖先是因为子代理与 fork 出来的会话在**固定之前**还没有自己的账号:子代理
1640
+ * 界面上从来就没有账号按钮,若不沿用父会话就会静默改用账号级兜底,把一次派发记到
1641
+ * 另一个账号头上;fork 有自己的按钮,但它在被切换之前也应当跟着父会话走。
1634
1642
  */
1635
1643
  async accountChain(sessionId) {
1636
1644
  const chain = [];
@@ -1647,17 +1655,39 @@ var CodeBuddySession = class {
1647
1655
  /**
1648
1656
  * 该会话此刻生效的账号 uid。
1649
1657
  *
1650
- * 顺序:会话(或其派生祖先)显式选用的账号 → 默认账号 → 最后一次手动切换的
1651
- * 账号 → 列表第一个。指向已退出账号的选择会被跳过,而不是当成“没有账号”;
1652
- * 全部落空时返回 `undefined`,调用方据此报未登录。
1658
+ * 顺序:会话(或其派生祖先)显式选用的账号 → 最后一次手动切换的账号 → 列表第一个。
1659
+ * 指向已退出账号的选择会被跳过,而不是当成“没有账号”;全部落空时返回
1660
+ * `undefined`,调用方据此报未登录。
1653
1661
  */
1654
1662
  async accountUidFor(sessionId) {
1655
1663
  if (sessionId !== void 0) {
1656
1664
  for (const uid of await this.accountChain(sessionId)) if (this.accountEntry(uid) !== void 0) return uid;
1657
1665
  }
1658
- for (const fallback of [this.storage?.defaultUid, this.storage?.lastUid]) if (this.accountEntry(fallback) !== void 0) return fallback;
1666
+ const last = this.storage?.lastUid;
1667
+ if (this.accountEntry(last) !== void 0) return last;
1659
1668
  return this.storage?.accounts[0]?.account.uid;
1660
1669
  }
1670
+ /**
1671
+ * 把该会话此刻解析到的账号写进它自己的文件,使它从此不再跟随账号级兜底。
1672
+ *
1673
+ * 已有选择就不再动它。那有两种可能,而两种都不该被覆盖:早已固定的会话重写只是
1674
+ * 同一份事实的重复落盘;而记录指向一个已退出账号时,读取侧按"没有选择"处理它,
1675
+ * 磁盘上的记录则只增不删 —— 覆盖掉它会让该账号重新登录后不再回到这个会话。
1676
+ * @param sessionId - dsh 会话身份。
1677
+ * @param uid - 本次请求实际使用的账号 uid。
1678
+ */
1679
+ async pinAccount(sessionId, uid) {
1680
+ const info = await this.infoOf(sessionId);
1681
+ if (info.uid !== void 0) return;
1682
+ await updateSessionInfo(sessionId, (current) => current.uid === void 0 ? {
1683
+ ...current,
1684
+ uid
1685
+ } : current);
1686
+ this.infos.set(sessionId, {
1687
+ ...info,
1688
+ uid
1689
+ });
1690
+ }
1661
1691
  identityOf(entry) {
1662
1692
  return {
1663
1693
  accessToken: entry.auth.accessToken,
@@ -1686,13 +1716,21 @@ var CodeBuddySession = class {
1686
1716
  }
1687
1717
  /**
1688
1718
  * 已存账号条目,在首次使用、失效之后以及文件被外部改动时从磁盘读取。
1689
- * @param sessionId - 用哪个会话的账号;会话未选用时按默认账号解析。
1719
+ *
1720
+ * 解析出的账号在返回前固定进该会话自己的文件,因此"这个会话用哪个账号"只在它
1721
+ * 第一次真正发请求时从全局兜底取一次;此后用户别处切换账号不会再改变它。固定
1722
+ * 挂在这里而不是账号解析处(见 {@link accountUidFor}),是因为本方法只被
1723
+ * {@link requestIdentity} 调用,即只有真实请求经过 —— 界面上打开一个空会话所
1724
+ * 触发的用量与目录读取不会落下任何记录。
1725
+ * @param sessionId - 用哪个会话的账号;会话未选用时按账号级兜底解析。
1690
1726
  * @throws NotLoggedInError 未存有任何凭据时。
1691
1727
  */
1692
1728
  async require(sessionId) {
1693
1729
  await this.refreshIfChanged();
1694
- const entry = this.accountEntry(await this.accountUidFor(sessionId));
1730
+ const uid = await this.accountUidFor(sessionId);
1731
+ const entry = this.accountEntry(uid);
1695
1732
  if (entry === void 0) throw new NotLoggedInError("CodeBuddy is not signed in. Run `dsh plugin --profile web codebuddy-power` to sign in through your browser; no API key is required.");
1733
+ if (sessionId !== void 0 && uid !== void 0) await this.pinAccount(sessionId, uid);
1696
1734
  return entry;
1697
1735
  }
1698
1736
  /**
@@ -1752,7 +1790,7 @@ var CodeBuddySession = class {
1752
1790
  * 两者在**同一次**身份解析中一起给出:分开解析会有两次账号选择,而账号是每次请求
1753
1791
  * 解析的(用户可在同一步里切换),两次解析可能落到不同账号上 —— 那样记进这一轮的
1754
1792
  * 账号名就与实际发出去的请求不符。缩写由落盘侧按同一规则算出,故此处给出完整账号。
1755
- * @param sessionId - 请求所属的会话;决定用哪个账号,缺省时用默认账号。
1793
+ * @param sessionId - 请求所属的会话;决定用哪个账号,缺省时按账号级兜底。
1756
1794
  * @returns 身份请求头与发起该请求的账号。
1757
1795
  */
1758
1796
  async requestIdentity(sessionId) {
@@ -1774,19 +1812,16 @@ var CodeBuddySession = class {
1774
1812
  return (this.storage?.accounts.length ?? 0) > 0;
1775
1813
  }
1776
1814
  /**
1777
- * 已存账号及其默认账号,供设置页与会话切换按钮展示。
1778
- * @returns 账号列表(顺序与磁盘一致)与默认账号 uid;未登录时列表为空。
1815
+ * 已存账号,供配置页与会话切换按钮展示。
1816
+ * @returns 账号列表(顺序与磁盘一致);未登录时列表为空。
1779
1817
  */
1780
1818
  async accountList() {
1781
1819
  await this.refreshIfChanged();
1782
- return {
1783
- accounts: (this.storage?.accounts ?? []).map(accountInfo),
1784
- ...this.storage?.defaultUid === void 0 ? {} : { defaultUid: this.storage.defaultUid }
1785
- };
1820
+ return (this.storage?.accounts ?? []).map(accountInfo);
1786
1821
  }
1787
1822
  /**
1788
1823
  * 某会话此刻生效的账号 uid。
1789
- * @param sessionId - dsh 会话身份;缺省时用默认账号。
1824
+ * @param sessionId - dsh 会话身份;缺省时按账号级兜底。
1790
1825
  * @returns 账号 uid;一个账号也没有时为 `undefined`。
1791
1826
  */
1792
1827
  async activeUid(sessionId) {
@@ -1795,6 +1830,10 @@ var CodeBuddySession = class {
1795
1830
  }
1796
1831
  /**
1797
1832
  * 把某会话的账号切换为指定账号,并把它记为最后一次手动切换的账号。
1833
+ *
1834
+ * 该会话已有持久记录才写它自己的文件;一个还从未落过盘的新会话只更新账号级的
1835
+ * `lastUid` —— 它可能永远不会被发过第一条消息,为它建文件会在磁盘上留下无人
1836
+ * 认领的残留文件。这类会话在真正发请求时由 {@link require} 固定下来。
1798
1837
  * @param sessionId - dsh 会话身份。
1799
1838
  * @param uid - 要使用的账号 uid。
1800
1839
  * @returns 该 uid 是已存账号时为 true;否则不做任何改动并返回 false。
@@ -1802,7 +1841,9 @@ var CodeBuddySession = class {
1802
1841
  async activate(sessionId, uid) {
1803
1842
  await this.refreshIfChanged();
1804
1843
  if (this.accountEntry(uid) === void 0) return false;
1844
+ await setLastAccount(uid);
1805
1845
  const info = await this.infoOf(sessionId);
1846
+ if (info.uid === void 0 && Object.keys(info.credits).length === 0) return true;
1806
1847
  await updateSessionInfo(sessionId, (current) => ({
1807
1848
  ...current,
1808
1849
  uid
@@ -1811,13 +1852,12 @@ var CodeBuddySession = class {
1811
1852
  ...info,
1812
1853
  uid
1813
1854
  });
1814
- await setLastAccount(uid);
1815
1855
  return true;
1816
1856
  }
1817
1857
  /**
1818
1858
  * CodeBuddy 模型目录,短暂缓存,并在并发读取者之间共享。
1819
1859
  * @param signal - 底层读取的可选取消信号。
1820
- * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1860
+ * @param sessionId - 用哪个会话的账号;缺省时按账号级兜底解析。
1821
1861
  * @returns 目录中的模型,按服务自身的顺序。
1822
1862
  */
1823
1863
  async models(signal, sessionId) {
@@ -1827,7 +1867,7 @@ var CodeBuddySession = class {
1827
1867
  * 目录及其推广与档位,在同一个 TTL 下按账号一起缓存,并按账号单飞,使列表与
1828
1868
  * 面板富化共享同一次读取。
1829
1869
  * @param signal - 底层读取的可选取消信号。
1830
- * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1870
+ * @param sessionId - 用哪个会话的账号;缺省时按账号级兜底解析。
1831
1871
  * @returns 模型、推广与档位。
1832
1872
  */
1833
1873
  async catalogData(signal, sessionId) {
@@ -1873,7 +1913,7 @@ var CodeBuddySession = class {
1873
1913
  * 从各账号已读到的目录的并集里查一个模型,**不发起任何请求**。
1874
1914
  *
1875
1915
  * 供拿不到会话身份的调用方使用(适配器的 `resolveModel`):它无法知道该用哪个
1876
- * 账号的目录,主动读取只能落在默认账号上,而当前会话用的可能正是另一个账号 ——
1916
+ * 账号的目录,主动读取只能落在账号级兜底上,而当前会话用的可能正是另一个账号 ——
1877
1917
  * 那会把一个账号独有的模型解析成保守的默认能力(上下文窗口、输出上限、图片输入
1878
1918
  * 全部按最保守处理)。并集由带会话身份的读取填充,因此这个查询零成本、也不改变
1879
1919
  * "谁在读目录"这件事。
@@ -1898,7 +1938,7 @@ var CodeBuddySession = class {
1898
1938
  * 目录及其推广/档位;读不到时返回空值 —— 供面板使用的
1899
1939
  * {@link catalogData} 建议性读取孪生体。
1900
1940
  * @param signal - 可选取消信号。
1901
- * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1941
+ * @param sessionId - 用哪个会话的账号;缺省时按账号级兜底解析。
1902
1942
  * @returns 目录与活动,或空列表。
1903
1943
  */
1904
1944
  async catalogDataOrEmpty(signal, sessionId) {
@@ -1922,10 +1962,10 @@ var CodeBuddySession = class {
1922
1962
  /**
1923
1963
  * 目录;读不到时返回空列表。
1924
1964
  *
1925
- * 列出模型是设置页上的浏览动作,所以失败必须降级为“没有可显示的内容”,
1965
+ * 列出模型是配置页上的浏览动作,所以失败必须降级为“没有可显示的内容”,
1926
1966
  * 而不是让页面崩掉。请求路径直接使用 {@link models} 并保留真实失败。
1927
1967
  * @param signal - 可选取消信号。
1928
- * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1968
+ * @param sessionId - 用哪个会话的账号;缺省时按账号级兜底解析。
1929
1969
  * @returns 目录,或空列表。
1930
1970
  */
1931
1971
  async modelsOrEmpty(signal, sessionId) {
@@ -1946,7 +1986,7 @@ var CodeBuddySession = class {
1946
1986
  * 其他每种失败(传输、解析、刷新过期)都在一条警告之后解析为
1947
1987
  * `undefined`。身份通过与聊天请求相同的按账号单飞刷新解析,所以并发的计量
1948
1988
  * 读取绝不会消耗两次刷新 token。
1949
- * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1989
+ * @param sessionId - 用哪个会话的账号;缺省时按账号级兜底解析。
1950
1990
  * @param signal - 可选取消信号。
1951
1991
  * @returns 该快照;未存有任何凭据或计量平面不可达时为 `undefined`。
1952
1992
  */
@@ -1998,12 +2038,12 @@ async function* parseSse(stream, onComment) {
1998
2038
  * 把 harness 消息序列化为 CodeBuddy(OpenAI 兼容)chat 请求。
1999
2039
  *
2000
2040
  * 用户文本被拼接,助手文本成为 `content`,工具调用成为
2001
- * `tool_calls`,每个工具结果成为其自身的 `role: 'tool'` 消息 →
2002
- * harness 把工具结果放在用户消息内部,而这条 wire 路由
2003
- * 不接受这种形式。对于支持图像的模型,图像以 OpenAI 兼容的
2004
- * `image_url` 部件(`data:<type>;base64,...`)出现在用户或工具 content
2005
- * 数组中,与官方 CLI 的发送方式完全一致。工具结果的图只对 `read_image`
2006
- * 这类"看图"工具发送,生成图工具的结果仍走纯文本信封。
2041
+ * `tool_calls`,每个工具结果是一条 `role: 'tool'` 的 harness 消息,直接映射为
2042
+ * 同名 wire 条目。对于支持图像的模型,用户消息里的图**不内联**:改为给出一段
2043
+ * 引导模型自行用 `read_image` 读取的提示词(附件服务解析出的只读绝对路径);
2044
+ * 解析不出路径时退回内联,以免模型既拿不到图也拿不到路径。`read_image` 的工具
2045
+ * 结果仍内联图片本身 —— 那是像素交给模型的唯一途径。生成图工具的结果继续走
2046
+ * 纯文本信封。
2007
2047
  *
2008
2048
  * @module dsh-llm-codebuddy-power/serialize
2009
2049
  */
@@ -2015,6 +2055,77 @@ function flattenText(blocks) {
2015
2055
  function imageDataUrl(version) {
2016
2056
  return `data:${version.mediaType};base64,${Buffer.from(version.data).toString("base64")}`;
2017
2057
  }
2058
+ /** PTC 模式下模型只能直呼 `run_code`,其余工具经生成的 SDK 以 `tools.<name>` 调用。 */
2059
+ const RUN_CODE_TOOL = "run_code";
2060
+ /** PTC 模式 SDK 中读取图片的绑定名。 */
2061
+ const PTC_READ_IMAGE_BINDING = "tools.read_image";
2062
+ /** 产出需要模型看到的图的那个工具:`read_image`。 */
2063
+ const READ_IMAGE_TOOL = "read_image";
2064
+ /**
2065
+ * 本请求中模型能否按提示词自行读取一张图。
2066
+ *
2067
+ * 三种形态分开处理,因为"能不能读"取决于该会话实际暴露的工具,而不只是路径是否
2068
+ * 可解析:
2069
+ * - `'direct'` —— 工具目录里有 `read_image`,可直接调用;标准模式即此。
2070
+ * - `'ptc'` —— 目录里只有 `run_code`(PTC 模式把其余工具放进生成的 SDK,
2071
+ * `read_image` 只是 `tools.read_image` 绑定),提示词须指明经代码调用。
2072
+ * - `'none'` —— 没有可用的读图途径(极简模式只有持久 shell)。此形态不引导,
2073
+ * 因为那会让模型去调一个不存在的工具,比内联更糟;而极简模式的模型尚未形成
2074
+ * "自己不能看图"的想法(标准模式那个想法来自大段系统提示词与 `read_image`
2075
+ * 描述里的 `Requires the current model to accept image input.`),内联即可。
2076
+ * @param tools - 本次请求的 wire 工具目录。
2077
+ * @returns 本请求的读图形态。
2078
+ */
2079
+ function readImageRoute(tools) {
2080
+ const names = new Set((tools ?? []).map((tool) => tool.name));
2081
+ if (names.has(READ_IMAGE_TOOL)) return "direct";
2082
+ if (names.has(RUN_CODE_TOOL)) return "ptc";
2083
+ return "none";
2084
+ }
2085
+ /**
2086
+ * 一张用户消息里的图:有可用的读图途径时只给"自行读取"的提示词,不再内联图片;
2087
+ * 否则退回内联。
2088
+ *
2089
+ * 不内联的理由:网关对非原生多模态模型的兼容路径把图转成文字描述,同时在同一条
2090
+ * 链路上附一句"内容已过滤"的错误声明。图随用户消息到达时,模型会采信那句声明并
2091
+ * 否认看图;改由模型自己调用 `read_image` 取回后,图是它自己取回的返回内容,它
2092
+ * 不再采信那句声明。退回内联则是为了不让模型既拿不到图、又被指去一个不存在的工具。
2093
+ * @param ref - 该次出现的持久化附件引用。
2094
+ * @param version - 该图本次请求已解析的版本,仅在退回内联时使用。
2095
+ * @param resolveAccess - 解析该图在当前执行世界中的只读路径。
2096
+ * @param route - 本请求的读图形态。
2097
+ * @returns 该图的读取提示词,或退回内联时的 image_url 部件。
2098
+ */
2099
+ function userImageParts(ref, version, resolveAccess, route) {
2100
+ const access = resolveAccess?.(ref);
2101
+ if (route === "none" || access === void 0) return [inlineImagePart(version)];
2102
+ return [{
2103
+ type: "text",
2104
+ text: imageReadPrompt(ref, access, route)
2105
+ }];
2106
+ }
2107
+ /** 内联 base64 图部件。工具结果的像素只能这样交给模型。 */
2108
+ function inlineImagePart(version) {
2109
+ return {
2110
+ type: "image_url",
2111
+ image_url: { url: imageDataUrl(version) }
2112
+ };
2113
+ }
2114
+ /**
2115
+ * 组装引导模型自行读取该图的提示词。
2116
+ *
2117
+ * 只陈述路径与动作:图并未内联,任何形如"已发送的预览版本"的措辞都会让模型把
2118
+ * 这段话当成图的替代说明,而不是去读取它的指令。
2119
+ * @param ref - 该次出现的持久化附件引用,身份取自它。
2120
+ * @param access - 该图在当前执行世界中的只读路径。
2121
+ * @param route - 本请求的读图形态,决定用哪种调用方式。
2122
+ * @returns 面向模型的读取提示词。
2123
+ */
2124
+ function imageReadPrompt(ref, access, route) {
2125
+ const identity = ref.name === void 0 ? String(ref.attachmentId) : `${JSON.stringify(ref.name)} (${ref.attachmentId})`;
2126
+ const call = route === "ptc" ? `Call it from your code as ${PTC_READ_IMAGE_BINDING}({ file_path: <path> }).` : `Call the ${READ_IMAGE_TOOL} tool with that path to view it.`;
2127
+ return `[Image ${identity} is at the read-only path ${JSON.stringify(access.readonlyPath)}. ${call} This conversation delivers images through that tool, so read the image yourself before answering about it.]`;
2128
+ }
2018
2129
  /**
2019
2130
  * 在任何文本扁平化可能静默丢弃图像内容之前先拒绝它。
2020
2131
  * @param blocks - 消息内容。
@@ -2026,12 +2137,14 @@ function assertSupportedContent(blocks, supportsImages) {
2026
2137
  /**
2027
2138
  * 序列化一条用户消息中非工具结果的块。纯文本内容保持紧凑的
2028
2139
  * 字符串 wire 形式;含图像的内容变为 OpenAI 兼容的
2029
- * 部件数组,并保持块顺序。
2140
+ * 部件数组(图可按读图形态换成读取提示词),并保持块顺序。
2030
2141
  * @param blocks - 消息中非工具结果的块。
2031
2142
  * @param images - 请求中每个图像已解析的请求版本。
2143
+ * @param resolveAccess - 解析单张图在当前执行世界中的只读路径。
2144
+ * @param route - 本请求的读图形态。
2032
2145
  * @returns 用户角色的 wire 内容。
2033
2146
  */
2034
- function userContent(blocks, images) {
2147
+ function userContent(blocks, images, resolveAccess, route) {
2035
2148
  if (!contentHasImage(blocks)) return flattenText(blocks);
2036
2149
  const parts = [];
2037
2150
  let text = "";
@@ -2052,10 +2165,7 @@ function userContent(blocks, images) {
2052
2165
  const version = images.get(block.attachment.attachmentId);
2053
2166
  if (version === void 0) throw new LlmError(`CodeBuddy request image ${block.attachment.attachmentId} was not prepared.`, "INVALID_REQUEST");
2054
2167
  flush();
2055
- parts.push({
2056
- type: "image_url",
2057
- image_url: { url: imageDataUrl(version) }
2058
- });
2168
+ parts.push(...userImageParts(block.attachment, version, resolveAccess, route));
2059
2169
  }
2060
2170
  flush();
2061
2171
  return parts;
@@ -2081,16 +2191,24 @@ function serializeAssistant(message) {
2081
2191
  }
2082
2192
  /**
2083
2193
  * 按顺序序列化整个对话。
2194
+ *
2195
+ * 工具结果在 harness 里是**独立的 `role: 'tool'` 消息**(`ToolResultMessage`),
2196
+ * 因此这里逐条映射,而不是按块从 user 消息里筛选。
2197
+ * developer 消息由运行时在 `projectToolUpdates` 中剥离(本 adapter 未声明
2198
+ * `toolUpdate`),因此不会到达这里;万一到达即明确拒绝,而不是静默丢弃。
2084
2199
  * @param messages - harness 会话。
2085
2200
  * @param supportsImages - 所选模型是否声明支持图像输入。
2086
2201
  * @param images - 请求中每个图像已解析的请求版本。
2087
- * @returns wire 消息,每个工具结果展开为独立条目。
2202
+ * @param resolveAccess - 解析单张图在当前执行世界中的只读路径。
2203
+ * @param route - 本请求的读图形态。
2204
+ * @returns wire 消息,工具结果各自成为独立条目。
2088
2205
  */
2089
- function serializeMessages(messages, supportsImages, images = /* @__PURE__ */ new Map()) {
2206
+ function serializeMessages(messages, supportsImages, images = /* @__PURE__ */ new Map(), resolveAccess, route = "none") {
2090
2207
  const wire = [];
2091
2208
  const toolNames = collectToolNames(messages);
2092
2209
  for (const message of messages) {
2093
2210
  assertSupportedContent(message.content, supportsImages);
2211
+ if (message.role === "developer") throw new LlmError("CodeBuddy chat does not support developer messages.", "UNSUPPORTED_CONTENT");
2094
2212
  if (message.role === "system") {
2095
2213
  wire.push({
2096
2214
  role: "system",
@@ -2102,47 +2220,55 @@ function serializeMessages(messages, supportsImages, images = /* @__PURE__ */ ne
2102
2220
  wire.push(serializeAssistant(message));
2103
2221
  continue;
2104
2222
  }
2105
- const toolResults = message.content.filter((block) => block.type === "tool-result");
2106
- const content = userContent(message.content.filter((block) => block.type !== "tool-result"), images);
2107
- if (content.length > 0 || toolResults.length === 0) wire.push({
2223
+ if (message.role === "tool") {
2224
+ wire.push(serializeToolResult(message, supportsImages, images, toolNames));
2225
+ continue;
2226
+ }
2227
+ const routeForMessage = message.source?.kind === "user" ? route : "none";
2228
+ wire.push({
2108
2229
  role: "user",
2109
- content
2230
+ content: userContent(message.content, images, resolveAccess, routeForMessage)
2110
2231
  });
2111
- for (const result of toolResults) {
2112
- const text = flattenText(result.content);
2113
- const imageBlocks = result.content.filter((block) => block.type === "image");
2114
- if (!(supportsImages && imageBlocks.length > 0 && toolNames.get(result.toolCallId) === READ_IMAGE_TOOL)) {
2115
- wire.push({
2116
- role: "tool",
2117
- tool_call_id: result.toolCallId,
2118
- content: text.length === 0 ? "(no output)" : text
2119
- });
2120
- continue;
2121
- }
2122
- const parts = [];
2123
- if (text.length > 0) parts.push({
2124
- type: "text",
2125
- text
2126
- });
2127
- for (const block of imageBlocks) {
2128
- const version = images.get(block.attachment.attachmentId);
2129
- if (version === void 0) throw new LlmError(`CodeBuddy request image ${block.attachment.attachmentId} was not prepared.`, "INVALID_REQUEST");
2130
- parts.push({
2131
- type: "image_url",
2132
- image_url: { url: imageDataUrl(version) }
2133
- });
2134
- }
2135
- wire.push({
2136
- role: "tool",
2137
- tool_call_id: result.toolCallId,
2138
- content: parts
2139
- });
2140
- }
2141
2232
  }
2142
2233
  return wire;
2143
2234
  }
2144
- /** 产出需要模型看到的图的那个工具:`read_image`。 */
2145
- const READ_IMAGE_TOOL = "read_image";
2235
+ /**
2236
+ * 序列化一条工具结果消息。
2237
+ *
2238
+ * 工具结果的图是否发给模型取决于**产出它的工具**:`read_image` 的存在意义就是让
2239
+ * 模型看图,必须发送;`codebuddy-draw` 只需模型知道"图已生成"(与官方 image_gen
2240
+ * 的纯文本信封一致),发图纯属浪费 token。实测确认服务端接受 tool 消息里的
2241
+ * image_url,因此直接放进该条目的 content 数组。
2242
+ * @param message - 工具结果消息。
2243
+ * @param supportsImages - 所选模型是否声明支持图像输入。
2244
+ * @param images - 请求中每个图像已解析的请求版本。
2245
+ * @param toolNames - toolCallId → 工具名 的索引。
2246
+ * @returns 该工具结果的 wire 条目。
2247
+ */
2248
+ function serializeToolResult(message, supportsImages, images, toolNames) {
2249
+ const text = flattenText(message.content);
2250
+ const imageBlocks = message.content.filter((block) => block.type === "image");
2251
+ if (!(supportsImages && imageBlocks.length > 0 && toolNames.get(message.toolCallId) === READ_IMAGE_TOOL)) return {
2252
+ role: "tool",
2253
+ tool_call_id: message.toolCallId,
2254
+ content: text.length === 0 ? "(no output)" : text
2255
+ };
2256
+ const parts = [];
2257
+ if (text.length > 0) parts.push({
2258
+ type: "text",
2259
+ text
2260
+ });
2261
+ for (const block of imageBlocks) {
2262
+ const version = images.get(block.attachment.attachmentId);
2263
+ if (version === void 0) throw new LlmError(`CodeBuddy request image ${block.attachment.attachmentId} was not prepared.`, "INVALID_REQUEST");
2264
+ parts.push(inlineImagePart(version));
2265
+ }
2266
+ return {
2267
+ role: "tool",
2268
+ tool_call_id: message.toolCallId,
2269
+ content: parts
2270
+ };
2271
+ }
2146
2272
  /**
2147
2273
  * 建立 toolCallId → 工具名 的索引。
2148
2274
  *
@@ -2166,15 +2292,17 @@ function collectToolNames(messages) {
2166
2292
  * @param options - 组装好的 harness 请求。
2167
2293
  * @param supportsImages - 所选模型是否声明支持图像输入。
2168
2294
  * @param images - 请求中每个图像已解析的请求版本。
2295
+ * @param resolveAccess - 解析单张图在当前执行世界中的只读路径。
2296
+ * @param route - 本请求的读图形态,由工具目录决定;写入前由调用方计算一次。
2169
2297
  * @returns 请求体。
2170
2298
  */
2171
- function serializeRequest(options, supportsImages, images = /* @__PURE__ */ new Map()) {
2299
+ function serializeRequest(options, supportsImages, images = /* @__PURE__ */ new Map(), resolveAccess) {
2172
2300
  const messages = [];
2173
2301
  if (options.system !== void 0) messages.push({
2174
2302
  role: "system",
2175
2303
  content: options.system
2176
2304
  });
2177
- messages.push(...serializeMessages(options.messages, supportsImages, images));
2305
+ messages.push(...serializeMessages(options.messages, supportsImages, images, resolveAccess, readImageRoute(options.tools)));
2178
2306
  const tools = options.tools?.map((tool) => ({
2179
2307
  type: "function",
2180
2308
  function: {
@@ -2518,48 +2646,45 @@ function requestId(headers) {
2518
2646
  const value = headers.get("x-request-id") ?? headers.get("x-requestid");
2519
2647
  return value === null || value.length === 0 ? void 0 : ProviderRequestId(value);
2520
2648
  }
2521
- /** 收集单条消息内容中的全部图像引用,包括工具结果内的引用。 */
2649
+ /** 收集单条消息内容中的全部图像引用。 */
2522
2650
  function collectImageRefs(content, refs) {
2523
2651
  for (const block of content) if (block.type === "image") refs.set(block.attachment.attachmentId, block.attachment);
2524
- else if (block.type === "tool-result") collectImageRefs(block.content, refs);
2525
2652
  }
2526
2653
  /**
2527
- * 把"长边不超过 longEdge"折算为该图的总像素预算。
2528
- *
2529
- * 请求投影只接受总像素上限(`ImageRequestPolicy.maxPixels`),没有长边字段,
2530
- * 而总像素无法约束长边:8000×460 仅 368 万像素,长边却达 8000。投影按
2531
- * `sqrt(maxPixels / (width * height))` 等比缩放,因此令
2532
- * `maxPixels = longEdge² × (短边 / 长边)` 时缩放系数恰为 `longEdge / 长边`,
2533
- * 结果正好把长边压到该档。
2654
+ * 把"长边不超过 longEdge"折算为该图的目标尺寸。
2534
2655
  *
2535
- * 长边本就在档内时返回原图总像素。这**不是**防放大所必需的:投影自身以
2536
- * `min(1, …)` 截断缩放系数,且长边 ≤ longEdge 时恒有
2537
- * `longEdge² × (短边/长边) ≥ 原面积`,故照公式给值也不会被放大。返回原图
2538
- * 面积的作用是让**预算值在各档之间重复**,从而被逐档重试的去重跳过,避免
2539
- * 对同一尺寸重复编码。
2656
+ * 请求投影接受的是目标宽高(`ImageRequestTarget`),因此长边档位直接折算成一对
2657
+ * 宽高,而不必再换算成总像素预算。目标大于源图时投影保持源图尺寸(不放大),
2658
+ * 所以长边本就在档内时直接给源图宽高。
2540
2659
  *
2541
2660
  * 宽高由会话格式的载荷校验强制为正整数(`payload-validation.ts` 的
2542
2661
  * `imageAttachmentValue`),故此处不做防御检查。
2543
2662
  * @param width - 原图宽度(像素)。
2544
2663
  * @param height - 原图高度(像素)。
2545
2664
  * @param longEdge - 本档的长边上限(像素)。
2546
- * @returns 该图在该档下的 `maxPixels` 取值,恒为正整数。
2665
+ * @returns 该图在该档下的目标宽高。
2547
2666
  */
2548
- function pixelBudgetForLongEdge(width, height, longEdge) {
2667
+ function dimensionsForLongEdge(width, height, longEdge) {
2549
2668
  const source = Math.max(width, height);
2550
- if (source <= longEdge) return width * height;
2669
+ if (source <= longEdge) return {
2670
+ width,
2671
+ height
2672
+ };
2551
2673
  const scale = longEdge / source;
2552
- return Math.max(1, Math.round(width * scale)) * Math.max(1, Math.round(height * scale));
2674
+ return {
2675
+ width: Math.max(1, Math.round(width * scale)),
2676
+ height: Math.max(1, Math.round(height * scale))
2677
+ };
2553
2678
  }
2554
2679
  /**
2555
2680
  * 解析一张图的请求版本:源图未超字节目标时原样复用,超过时才逐档收紧长边。
2556
2681
  *
2557
- * 未超目标的一支不做任何转换:`maxPixels` 取原图总像素,投影因此不缩放,附件
2558
- * 服务命中其直通分支返回原始字节,既不重编码也不写请求缓存 —— 降到 1920 会
2559
- * 白白丢掉分辨率与画质,而模型侧的约束只有字节目标。
2682
+ * 未超目标的一支不做任何转换:目标取原图宽高,投影因此不缩放,附件服务命中其
2683
+ * 直通分支返回原始字节,既不重编码也不写请求缓存 —— 降到 1920 会白白丢掉分辨率
2684
+ * 与画质,而模型侧的约束只有字节目标。
2560
2685
  *
2561
2686
  * 超过目标的一支才逐档收紧长边。附件服务的编码阶梯只降质量、不降尺寸,单靠
2562
- * 一档无法保证落到字节目标,故需要换更小的长边重来;预算相同的档位会被跳过。
2687
+ * 一档无法保证落到字节目标,故需要换更小的长边重来;尺寸相同的档位会被跳过。
2563
2688
  * @param ref - 持久化附件引用。
2564
2689
  * @param attachments - 已挂载的附件服务。
2565
2690
  * @param signal - 本次生成的取消信号。
@@ -2568,17 +2693,19 @@ function pixelBudgetForLongEdge(width, height, longEdge) {
2568
2693
  async function requestImageVersion(ref, attachments, signal) {
2569
2694
  const maxBytes = REQUEST_IMAGE_MAX_BYTES;
2570
2695
  if (ref.bytes <= maxBytes) return attachments.readImageRequest(ref, {
2571
- maxPixels: ref.width * ref.height,
2696
+ width: ref.width,
2697
+ height: ref.height,
2572
2698
  maxBytes
2573
2699
  }, signal);
2574
2700
  const tried = /* @__PURE__ */ new Set();
2575
2701
  let smallest;
2576
2702
  for (const longEdge of REQUEST_IMAGE_DIMENSION_LADDER) {
2577
- const maxPixels = pixelBudgetForLongEdge(ref.width, ref.height, longEdge);
2578
- if (tried.has(maxPixels)) continue;
2579
- tried.add(maxPixels);
2703
+ const size = dimensionsForLongEdge(ref.width, ref.height, longEdge);
2704
+ const key = `${size.width}x${size.height}`;
2705
+ if (tried.has(key)) continue;
2706
+ tried.add(key);
2580
2707
  const version = await attachments.readImageRequest(ref, {
2581
- maxPixels,
2708
+ ...size,
2582
2709
  maxBytes
2583
2710
  }, signal);
2584
2711
  if (version.bytes <= maxBytes) return version;
@@ -2588,7 +2715,7 @@ async function requestImageVersion(ref, attachments, signal) {
2588
2715
  }
2589
2716
  /**
2590
2717
  * 在所选模型接受图像输入时,解析会话所携带每张图像的请求版本。持久化 harness 以引用
2591
- * 方式附加;请求字节由附件服务按逐图的像素与字节预算派生,与官方 DeepSeek 适配器
2718
+ * 方式附加;请求字节由附件服务按逐图的目标尺寸与字节预算派生,与官方 DeepSeek 适配器
2592
2719
  * 的准备方式一致。
2593
2720
  * @param messages - 等待序列化的 harness 消息。
2594
2721
  * @param attachments - 持久化附件服务;未挂载时为 undefined。
@@ -2738,7 +2865,8 @@ var CodeBuddyAdapter = class extends LlmAdapter {
2738
2865
  const entry = (await this.config.session.modelsOrEmpty(options.signal, sessionId)).find((candidate) => candidate.id === options.model);
2739
2866
  const supportsImages = entry?.supportsImages === true;
2740
2867
  if (options.tools !== void 0 && options.tools.length > 0 && entry?.supportsToolCall === false) throw new LlmError(`CodeBuddy model "${options.model}" does not support tool calls`, "UNSUPPORTED_OPTION");
2741
- const body = serializeRequest(options, supportsImages, supportsImages ? await prepareRequestImages(options.messages, this.config.resolveAttachments(), options.signal) : /* @__PURE__ */ new Map());
2868
+ const attachments = this.config.resolveAttachments();
2869
+ const body = serializeRequest(options, supportsImages, supportsImages ? await prepareRequestImages(options.messages, attachments, options.signal) : /* @__PURE__ */ new Map(), attachments === void 0 || this.config.resolveImageAccess === void 0 ? void 0 : (ref) => this.config.resolveImageAccess?.(attachments, ref));
2742
2870
  const payload = gzipSync(Buffer.from(JSON.stringify(body), "utf8"));
2743
2871
  const messageId = compactUuid();
2744
2872
  const conversationId = options.sessionId === void 0 ? compactUuid() : this.config.session.conversationIdOf(String(options.sessionId));
@@ -3026,8 +3154,9 @@ function endpointOf(channel, pathname) {
3026
3154
  * @param res - 本路由在整个生命周期内持有的响应。
3027
3155
  */
3028
3156
  async function serveChannel(rpcCtx, channel, handler, req, res) {
3029
- const rejection = rpcCtx.connection.requestRejection(req);
3030
- if (rejection !== void 0) {
3157
+ const admission = rpcCtx.connection.admit(req);
3158
+ if ("rejection" in admission) {
3159
+ const rejection = admission.rejection;
3031
3160
  res.writeHead(rejection);
3032
3161
  res.end(rejection === 401 ? "unauthorized" : "forbidden");
3033
3162
  return;
@@ -3085,7 +3214,7 @@ async function serveChannel(rpcCtx, channel, handler, req, res) {
3085
3214
  res.on("close", () => {
3086
3215
  controller.abort();
3087
3216
  });
3088
- const result = await handler(endpoint, message.payload, controller.signal);
3217
+ const result = await handler(endpoint, message.payload, controller.signal, admission.peer);
3089
3218
  writeJson(res, 200, serverResponse(message.rpcId, result));
3090
3219
  } catch (error) {
3091
3220
  res.writeHead(500);
@@ -3451,10 +3580,11 @@ function enrichModel(model, promotions, tiers, withOverrides) {
3451
3580
  /**
3452
3581
  * 通过私有 RPC 通道暴露给 Web 客户端的宿主侧 OAuth 服务。
3453
3582
  *
3454
- * 浏览器登录是长时操作(要等待用户完成登录),因此拆成两个 RPC endpoint:
3455
- * `startLogin` 生成握手并返回用户必须打开的 URL,`pollLogin` 检查该握手是否已完成;
3583
+ * 浏览器登录是长时操作(要等待用户完成登录),因此拆成三个 RPC endpoint:
3584
+ * `startLogin` 生成握手并返回用户必须打开的 URL,`pollLogin` 检查该握手是否已完成,
3585
+ * `pendingLogin` 报出仍在轮询的握手(浏览器刷新后据此接回);
3456
3586
  * 一次完成的登录会**追加**一个账号,而不是取代已有的。`accounts`、`activate`、
3457
- * `setDefault`、`logout` 是设置页与会话账号按钮其余时间驱动的读写组合。
3587
+ * `logout`、`reorder` 是配置页与会话账号按钮其余时间驱动的读写组合。
3458
3588
  *
3459
3589
  * @module dsh-llm-codebuddy-power/auth-service
3460
3590
  */
@@ -3496,6 +3626,7 @@ const CREDIT_UID_DISPLAY_CHARS = 8;
3496
3626
  *
3497
3627
  * uid 在**呈现边界**才截断:存储与解析都保留完整值,截断只影响这一个提示行。把它放在
3498
3628
  * 这里而不是存储层,是因为存储层丢掉的信息无法再恢复,而显示层的取舍可以随时改。
3629
+ * 请求 id 不截断,因为它要整个被复制出去。
3499
3630
  * @param record - 已按账号表展开的轮次记录。
3500
3631
  * @returns 对客户端安全的投影。
3501
3632
  */
@@ -3505,7 +3636,8 @@ function projectCreditRecord(record) {
3505
3636
  accounts: record.accounts.map((account) => ({
3506
3637
  nickname: account.nickname,
3507
3638
  uidPrefix: account.uid.slice(0, CREDIT_UID_DISPLAY_CHARS)
3508
- }))
3639
+ })),
3640
+ ...record.requestId === void 0 ? {} : { requestId: record.requestId }
3509
3641
  };
3510
3642
  }
3511
3643
  /**
@@ -3525,13 +3657,18 @@ function projectWindow(window) {
3525
3657
  /**
3526
3658
  * CodeBuddy 认证 RPC 服务。
3527
3659
  *
3528
- * 握手由 `startLogin` 发起,由 `pollLogin` 轮询至完成,其账号由适配器的
3529
- * `CodeBuddySession` 在下一个请求中取用 → 因此通过 UI 完成的登录无需重启
3530
- * 即可到达运行中的 harness。`logout` 移除指定账号并使会话缓存失效。
3660
+ * 握手由 `startLogin` 发起并**脱手**开跑,客户端经 `pollLogin` 反复查问它是否已结算,
3661
+ * 其账号由适配器的 `CodeBuddySession` 在下一个请求中取用 → 因此通过 UI 完成的登录
3662
+ * 无需重启即可到达运行中的 harness。`logout` 移除指定账号并使会话缓存失效。
3531
3663
  */
3532
3664
  var CodeBuddyAuthService = class {
3533
3665
  session;
3534
- /** 按 state id 索引的进行中握手。 */
3666
+ /**
3667
+ * 按 state id 索引的握手。
3668
+ *
3669
+ * 同时存放进行中的与**刚结算、尚在保留窗口内**的条目 —— 后者供客户端取回结果,
3670
+ * 由 `settle()` 安排回收,因此本表不是"进行中握手"的集合。
3671
+ */
3535
3672
  pending = /* @__PURE__ */ new Map();
3536
3673
  constructor(ctx, session) {
3537
3674
  this.session = session;
@@ -3542,14 +3679,11 @@ var CodeBuddyAuthService = class {
3542
3679
  switch (endpoint) {
3543
3680
  case "accounts": return ok(await this.accounts());
3544
3681
  case "startLogin": return ok(await this.startLogin());
3545
- case "pollLogin": return ok(await this.pollLogin(stringField(payload, "state")));
3682
+ case "pollLogin": return ok(this.pollLogin(stringField(payload, "state")));
3683
+ case "pendingLogin": return ok(this.pendingLogin());
3546
3684
  case "cancelLogin": return this.cancelLogin(stringField(payload, "state")) ? ok(void 0) : err("bad-request", "a login handshake id is required");
3547
3685
  case "sessionAccount": return ok(await this.sessionAccount(stringField(payload, "sessionId")));
3548
3686
  case "activate": return await this.activate(stringField(payload, "sessionId"), stringField(payload, "uid")) ? ok(void 0) : err("unknown-account", "the requested account is not stored");
3549
- case "setDefault": {
3550
- const uid = stringField(payload, "uid");
3551
- return await this.setDefault(uid.length === 0 ? void 0 : uid) ? ok(void 0) : err("unknown-account", "the requested account is not stored");
3552
- }
3553
3687
  case "logout":
3554
3688
  await this.logout(stringListField(payload, "uids"));
3555
3689
  return ok(void 0);
@@ -3572,46 +3706,79 @@ var CodeBuddyAuthService = class {
3572
3706
  }
3573
3707
  }
3574
3708
  /**
3575
- * 读取已存账号及其默认账号,不要求必须存在。
3709
+ * 读取已存账号,不要求必须存在。
3576
3710
  * @returns 账号集合;未存任何内容时为空列表。
3577
3711
  */
3578
3712
  async accounts() {
3579
3713
  if (this.session === void 0) return { accounts: [] };
3580
- return this.session.accountList();
3714
+ return { accounts: await this.session.accountList() };
3581
3715
  }
3582
3716
  /**
3583
3717
  * 发起一次浏览器登录握手。
3584
- * @returns 用户必须打开的 URL。
3718
+ * @returns 用户必须打开的 URL,以及该握手的 id 与发起时刻。
3585
3719
  */
3586
3720
  async startLogin() {
3587
3721
  const handshake = await requestAuthState();
3588
3722
  const controller = new AbortController();
3723
+ const startedAt = Date.now();
3589
3724
  const pending = {
3590
3725
  state: handshake.state,
3591
- promise: this.runLogin(handshake.state, controller.signal),
3726
+ startedAt,
3727
+ account: void 0,
3728
+ settled: false,
3592
3729
  controller
3593
3730
  };
3594
3731
  this.pending.set(handshake.state, pending);
3595
- pending.promise.finally(() => {
3596
- if (this.pending.get(handshake.state) === pending) this.pending.delete(handshake.state);
3597
- });
3732
+ this.runLogin(handshake.state, controller.signal).catch(() => {});
3598
3733
  return {
3599
3734
  authUrl: handshake.authUrl,
3600
- state: handshake.state
3735
+ state: handshake.state,
3736
+ startedAt
3737
+ };
3738
+ }
3739
+ /**
3740
+ * 仍在进行的握手。
3741
+ *
3742
+ * 客户端的「登录中」状态只活在浏览器内存里,刷新即丢,而本表里的握手仍在轮询,
3743
+ * 浏览器重新挂载后据此接回(否则界面显示可再次登录,而宿主那边已有一次握手在等,
3744
+ * 用户点了就会平白多出第二次)。
3745
+ *
3746
+ * **只报尚未结算的握手**:已结算的条目会在表里多留一会儿供客户端取结果,若把它们
3747
+ * 也算作"进行中",刷新后接回的就是一次已经结束(甚至已经失败)的登录,界面会重新
3748
+ * 显示「登录中…」并空转到截止时间。
3749
+ *
3750
+ * 同时存在多次时给出最近发起的那次:客户端的轮询、取消与超时都只针对一个握手,
3751
+ * 而最晚发起的那次正是用户此刻在等的那次。
3752
+ * @returns 该握手;没有进行中的登录时缺省。
3753
+ */
3754
+ pendingLogin() {
3755
+ let latest;
3756
+ for (const pending of this.pending.values()) {
3757
+ if (pending.settled) continue;
3758
+ if (latest === void 0 || pending.startedAt > latest.startedAt) latest = pending;
3759
+ }
3760
+ return latest === void 0 ? void 0 : {
3761
+ state: latest.state,
3762
+ startedAt: latest.startedAt
3601
3763
  };
3602
3764
  }
3603
3765
  /**
3604
3766
  * 检查已发起的握手是否完成。
3767
+ *
3768
+ * **立即返回**,不等待后台握手:`runLogin` 可能还要跑满十分钟,若在这里 await 它,
3769
+ * 浏览器的一次请求就会在整个登录窗口内停在待处理,占住一条连接不放;而客户端是
3770
+ * 按自己的间隔反复来问的,让它每次都立刻拿到"还没好"才是这条 endpoint 的职责。
3605
3771
  * @param state - 来自 `startLogin` 的握手 id。
3606
3772
  * @returns 登录是否完成、账号是否已持久化。
3607
3773
  */
3608
- async pollLogin(state) {
3774
+ pollLogin(state) {
3609
3775
  const pending = this.pending.get(state);
3610
3776
  if (pending === void 0) return { done: false };
3611
- const entry = await pending.promise;
3777
+ if (!pending.settled) return { done: false };
3778
+ const account = pending.account;
3612
3779
  return {
3613
- done: entry !== void 0,
3614
- ...entry === void 0 ? {} : { nickname: entry.account.nickname }
3780
+ done: account !== void 0,
3781
+ ...account === void 0 ? {} : { nickname: account.account.nickname }
3615
3782
  };
3616
3783
  }
3617
3784
  /**
@@ -3652,19 +3819,6 @@ var CodeBuddyAuthService = class {
3652
3819
  return true;
3653
3820
  }
3654
3821
  /**
3655
- * 指定或取消默认账号。
3656
- * @param uid - 默认账号 uid;`undefined` 表示取消默认账号。
3657
- * @returns 指定时该账号已存、或取消成功时为 true。
3658
- */
3659
- async setDefault(uid) {
3660
- if (uid !== void 0) {
3661
- if (!(await this.accounts()).accounts.some((account) => account.uid === uid)) return false;
3662
- }
3663
- await setDefaultAccount(uid);
3664
- this.session?.invalidate();
3665
- return true;
3666
- }
3667
- /**
3668
3822
  * 移除若干账号。
3669
3823
  * @param uids - 要移除的账号 uid。
3670
3824
  */
@@ -3676,7 +3830,7 @@ var CodeBuddyAuthService = class {
3676
3830
  /**
3677
3831
  * 按给定顺序重排账号。
3678
3832
  *
3679
- * 顺序即展示顺序,也是没有默认账号、也没有手动切换记录时的兜底账号,因此这里真的
3833
+ * 顺序即展示顺序,也是没有手动切换记录时的兜底账号,因此这里真的
3680
3834
  * 改变"用谁"。重排会让输入区控件组读到的账号集合顺序变化,故同样使会话缓存失效。
3681
3835
  * @param uids - 目标顺序上的账号 uid。
3682
3836
  */
@@ -3714,27 +3868,49 @@ var CodeBuddyAuthService = class {
3714
3868
  };
3715
3869
  }
3716
3870
  /**
3717
- * 把一次握手推进到持久化的账号。
3871
+ * 把一次握手推进到持久化的账号,并把结果写回 {@link pending} 里对应条目。
3718
3872
  *
3719
3873
  * 复用 `buildAccount`,使落盘结构与 CLI 登录完全一致。
3720
- * 任何失败都返回 `undefined`,让客户端的轮询得到 `done: false`,
3874
+ * 任何失败都记为"结算为失败",让客户端的轮询得到 `done: false`,
3721
3875
  * 从而可以从 `startLogin` 重试。
3876
+ *
3877
+ * 本方法由 `startLogin` **脱手**调用,`pollLogin` 不 await 它;因此它自己不返回
3878
+ * 结果,只负责把结果写进条目并安排回收。
3722
3879
  */
3723
3880
  async runLogin(state, signal) {
3881
+ let entry;
3724
3882
  try {
3725
3883
  const token = await pollAuthToken(state, signal);
3726
- if (token === void 0) return void 0;
3727
- const entry = buildAccount(token, await getLoginAccount(state, token.accessToken, token.domain));
3728
- await upsertAccount(entry);
3729
- this.session?.invalidate();
3730
- return entry;
3884
+ if (token !== void 0) {
3885
+ entry = buildAccount(token, await getLoginAccount(state, token.accessToken, token.domain));
3886
+ await upsertAccount(entry);
3887
+ this.session?.invalidate();
3888
+ }
3731
3889
  } catch {
3732
- return;
3890
+ entry = void 0;
3733
3891
  }
3892
+ this.settle(state, entry);
3893
+ }
3894
+ /**
3895
+ * 记录一次握手的结局,并在保留窗口之后回收该条目。
3896
+ *
3897
+ * 回收之所以要延迟:客户端是按自己的间隔来问结果的,一结算就删会让那次本已成功的
3898
+ * 登录再也答不出 `done: true`。取消过的握手已不在表里,这里的写入自然丢弃。
3899
+ * @param state - 握手 id。
3900
+ * @param account - 已落盘的账号;失败或取消时为 `undefined`。
3901
+ */
3902
+ settle(state, account) {
3903
+ const pending = this.pending.get(state);
3904
+ if (pending === void 0) return;
3905
+ pending.settled = true;
3906
+ pending.account = account;
3907
+ setTimeout(() => {
3908
+ if (this.pending.get(state) === pending) this.pending.delete(state);
3909
+ }, LOGIN_RESULT_RETENTION_MS).unref();
3734
3910
  }
3735
3911
  /**
3736
3912
  * 供客户端面板使用的增强版 CodeBuddy 目录:应用了促销/等级展示信息与覆盖配置的模型。
3737
- * @param sessionId - 用哪个会话的账号读目录;为空时用默认账号。
3913
+ * @param sessionId - 用哪个会话的账号读目录;为空时按账号级兜底。
3738
3914
  * @returns 增强结果;会话不可用或该账号没有目录时为空行。
3739
3915
  */
3740
3916
  async enrichedModels(sessionId) {
@@ -3774,21 +3950,6 @@ var CodeBuddyAuthService = class {
3774
3950
  };
3775
3951
  //#endregion
3776
3952
  //#region lib/types/prefs.js
3777
- /**
3778
- * CodeBuddy 界面偏好的命名空间契约,Host 与 Client 两面共享。
3779
- *
3780
- * 这些偏好是**插件自己的设置命名空间**里的一段:`Config` 提供部署级 base,
3781
- * 用户在 Web 设置页的写入落在 harness 的用户设置文档里,因此换浏览器、重启
3782
- * harness 后仍然生效 —— 其中「极简模式画图工具」还会被宿主读取来决定
3783
- * `codebuddy-draw` 对模型是否可见,存在浏览器里会让同一次部署因浏览器而异。
3784
- *
3785
- * 本文件必须保持零依赖:两面都会编译它,而 Client 面若因此引入 schemastery
3786
- * 就会把它打进浏览器产物(校验 schema 是宿主面的事,见 `./settings.ts`)。
3787
- *
3788
- * @module dsh-llm-codebuddy-power/prefs
3789
- */
3790
- /** 本插件拥有的设置命名空间,与插件名一致。 */
3791
- const CODEBUDDY_SETTINGS_NAMESPACE = "llm-codebuddy-power";
3792
3953
  /** 未覆盖任何一层时使用的偏好默认值。 */
3793
3954
  const DEFAULT_CODEBUDDY_SETTINGS = {
3794
3955
  showUsage: true,
@@ -3798,11 +3959,11 @@ const DEFAULT_CODEBUDDY_SETTINGS = {
3798
3959
  //#endregion
3799
3960
  //#region lib/types/settings.js
3800
3961
  /**
3801
- * 本插件设置命名空间的宿主 schema。
3962
+ * 本插件的 `Config` schema —— 连接事实与界面偏好合一。
3802
3963
  *
3803
- * 校验只在宿主发生:浏览器面若也引入 schemastery,它会连同校验器一起被打进
3804
- * `lib/client.js`,而客户端本来就把服务端返回的数据当作已校验结果接收。因此
3805
- * schema 只在此文件出现,浏览器面经 `./prefs.ts` 取同一份类型与默认值。
3964
+ * 设置表单只投影**入口自己的 `Config`** 里声明为 `.volatile()` 的字段(入口 id
3965
+ * 即命名空间),因此界面偏好字段与连接字段同在 `Config` 中,只有偏好那三个是
3966
+ * volatile。
3806
3967
  *
3807
3968
  * 字段的默认值一律取自 `DEFAULT_CODEBUDDY_SETTINGS`,不在此重复字面量 ——
3808
3969
  * 两处各写一份时,改了一处就会出现「宿主默认 90、界面显示 80」这种只有用户
@@ -3810,12 +3971,12 @@ const DEFAULT_CODEBUDDY_SETTINGS = {
3810
3971
  *
3811
3972
  * @module dsh-llm-codebuddy-power/settings
3812
3973
  */
3813
- /** 用户设置文档中本插件命名空间的 schema。 */
3814
- const CodeBuddySettingsSchema = z.object({
3815
- showUsage: z.boolean().default(DEFAULT_CODEBUDDY_SETTINGS.showUsage),
3816
- dangerPct: z.number().step(1).min(1).max(100).default(DEFAULT_CODEBUDDY_SETTINGS.dangerPct),
3817
- drawToolInMinimal: z.boolean().default(DEFAULT_CODEBUDDY_SETTINGS.drawToolInMinimal)
3818
- });
3974
+ /** `Config.showUsage` 的 schema。 */
3975
+ const ShowUsageField = z.boolean().default(DEFAULT_CODEBUDDY_SETTINGS.showUsage).volatile();
3976
+ /** `Config.dangerPct` 的 schema。 */
3977
+ const DangerPctField = z.number().step(1).min(1).max(100).default(DEFAULT_CODEBUDDY_SETTINGS.dangerPct).volatile();
3978
+ /** `Config.drawToolInMinimal` 的 schema。 */
3979
+ const DrawToolInMinimalField = z.boolean().default(DEFAULT_CODEBUDDY_SETTINGS.drawToolInMinimal).volatile();
3819
3980
  //#endregion
3820
3981
  //#region lib/types/index.js
3821
3982
  /**
@@ -3834,9 +3995,9 @@ const CodeBuddySettingsSchema = z.object({
3834
3995
  const name = "llm-codebuddy-power";
3835
3996
  /**
3836
3997
  * 本插件的激活服务。`llm` 承载适配器路由,`tools` 接受文生图工具注册,
3837
- * `agents` 用于极简模式会话中按 agent 限制画图工具可见性。生成图片经
3838
- * 标准附件通道(attachments.saveImage + uiConversation.imageUrl)持久化与
3839
- * 展示,工具注册在附件服务挂载后(`ctx.inject(['attachments'])`)。
3998
+ * `agents` 用于极简模式会话中按 agent 限制画图工具可见性。生成图片经标准附件通道
3999
+ * (attachments.saveImage + uiConversation.imageUrl)持久化与展示,工具注册在
4000
+ * 附件服务挂载后(`ctx.inject(['attachments'])`)。
3840
4001
  */
3841
4002
  const inject = [
3842
4003
  "llm",
@@ -3844,14 +4005,29 @@ const inject = [
3844
4005
  "agents"
3845
4006
  ];
3846
4007
  /**
3847
- * 校验并补全原始配置。
4008
+ * {@link Config} 的运行时 schema。
4009
+ *
4010
+ * 三个界面偏好字段必须是 volatile 才会被设置表单投影,而 volatile 只从
4011
+ * schemastery schema 产生;连接字段保持普通字段,因此不在表单里暴露 ——
4012
+ * 它们由 profile 的 cordis.patch.yml 配置。
4013
+ */
4014
+ const Config = z.object({
4015
+ baseURL: z.string(),
4016
+ defaultContextWindow: z.number().step(1).min(1),
4017
+ defaultMaxTokens: z.number().step(1).min(1),
4018
+ streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE),
4019
+ showUsage: ShowUsageField,
4020
+ dangerPct: DangerPctField,
4021
+ drawToolInMinimal: DrawToolInMinimalField
4022
+ });
4023
+ /**
4024
+ * 从配置解析连接事实。
3848
4025
  *
3849
- * 编程式构造可以绕过任何 schema,因此边界在此判定,坏值在加载时就带字段名
3850
- * 失败,而不是等到请求中途。
3851
- * @param config - 原始入口配置。
4026
+ * 连接字段在 `Config` 里都是可选的,缺省值即公开的 CodeBuddy 服务。
4027
+ * @param config - 入口配置。
3852
4028
  * @returns 解析出的连接事实。
3853
4029
  */
3854
- function resolveConnectionOptions(config = {}) {
4030
+ function resolveConnectionOptions(config) {
3855
4031
  const positiveInteger = (value, field, fallback) => {
3856
4032
  if (value === void 0) return fallback;
3857
4033
  if (!Number.isInteger(value) || value <= 0) throw new Error(`dsh-llm-codebuddy-power: ${field} must be a positive integer`);
@@ -3869,34 +4045,23 @@ function resolveConnectionOptions(config = {}) {
3869
4045
  };
3870
4046
  }
3871
4047
  /**
3872
- * 解析界面偏好的部署级 base 层。用户设置文档中的写入覆盖它。
3873
- *
3874
- * 与 {@link resolveConnectionOptions} 同理,编程式构造可以绕过任何 schema,
3875
- * 因此坏值在此判定并在加载时失败。
3876
- * @param config - 原始入口配置。
3877
- * @returns 设置命名空间的 base 值。
4048
+ * 读取界面偏好的当前值。
4049
+ * @param config - 入口配置。
4050
+ * @returns 三个界面偏好。
3878
4051
  */
3879
- function resolvePrefsBase(config = {}) {
3880
- const dangerPct = config.dangerPct ?? DEFAULT_CODEBUDDY_SETTINGS.dangerPct;
3881
- if (!Number.isInteger(dangerPct) || dangerPct < 1 || dangerPct > 100) throw new Error(`dsh-llm-codebuddy-power: dangerPct must be an integer within 1..100`);
4052
+ function resolvePrefs(config) {
3882
4053
  return {
3883
- showUsage: config.showUsage ?? DEFAULT_CODEBUDDY_SETTINGS.showUsage,
3884
- dangerPct,
3885
- drawToolInMinimal: config.drawToolInMinimal ?? DEFAULT_CODEBUDDY_SETTINGS.drawToolInMinimal
4054
+ showUsage: config.showUsage.get(),
4055
+ dangerPct: config.dangerPct.get(),
4056
+ drawToolInMinimal: config.drawToolInMinimal.get()
3886
4057
  };
3887
4058
  }
3888
4059
  /** 挂载插件:解析配置,然后注册路由。 */
3889
- function apply(ctx, config = {}) {
4060
+ function apply(ctx, config) {
3890
4061
  const resolved = resolveConnectionOptions(config);
3891
- const prefsBase = resolvePrefsBase(config);
3892
- let prefsSource = () => prefsBase;
4062
+ const prefsSource = () => resolvePrefs(config);
3893
4063
  ctx.inject(["settings"], (settingsCtx) => {
3894
- settingsCtx.settings.installSection(ctx, CODEBUDDY_SETTINGS_NAMESPACE, CodeBuddySettingsSchema, prefsBase, {
3895
- setSource: (current) => {
3896
- prefsSource = current;
3897
- },
3898
- onChange: () => {}
3899
- });
4064
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
3900
4065
  });
3901
4066
  const session = new CodeBuddySession(ctx.logger, () => {
3902
4067
  ctx.emit("llm/adapters-updated");
@@ -3905,6 +4070,7 @@ function apply(ctx, config = {}) {
3905
4070
  session,
3906
4071
  options: () => resolved,
3907
4072
  resolveAttachments: () => ctx.get("attachments"),
4073
+ resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => ctx.get("fs")?.processPathFromHostPath(hostPath), ref),
3908
4074
  resolveOverrides: async (modelId) => {
3909
4075
  return (await loadOverrides())[modelId];
3910
4076
  }
@@ -3950,8 +4116,8 @@ function apply(ctx, config = {}) {
3950
4116
  ctx.on("session/disposed", onSessionDisposed, { global: true });
3951
4117
  session.isLoggedIn().then((loggedIn) => {
3952
4118
  if (loggedIn) return;
3953
- ctx.logger.info("llm-codebuddy-power: no CodeBuddy session stored; sign in through the Settings → CodeBuddy page, or run `dsh plugin --profile web codebuddy-power` (no API key needed).");
4119
+ ctx.logger.info("llm-codebuddy-power: no CodeBuddy session stored; sign in through this plugin's card on the Web sidebar's Plugins page, or run `dsh plugin --profile web codebuddy-power` (no API key needed).");
3954
4120
  }).catch(() => {});
3955
4121
  }
3956
4122
  //#endregion
3957
- export { AUTH_PENDING_CODE, BLOCKED_MODEL_IDS, CODEBUDDY_AUTH_CHANNEL, CODEBUDDY_AXIOS_USER_AGENT, CODEBUDDY_CHAT_BASE, CODEBUDDY_DISPLAY_NAME, CODEBUDDY_ENDPOINT, CODEBUDDY_IDE_VERSION, CODEBUDDY_PRODUCT_NAME, CODEBUDDY_PROVIDER, CodeBuddyAdapter, CodeBuddyAuthService, CodeBuddySession, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, LOGIN_POLL_INTERVAL_MS, LOGIN_TIMEOUT_MS, NotLoggedInError, REQUEST_IMAGE_DIMENSION_LADDER, REQUEST_IMAGE_MAX_BYTES, apply, fetchEnterpriseUsage, fetchPersonalUsage, fetchUsage, getStoragePath, hasDisclosedCapacity, httpErrorCode, inject, isBlockedModelId, loadSessionInfo, loadStorage, login, name, parseUsage, removeAccounts, resolveConnectionOptions, resolvePrefsBase, setDefaultAccount, setLastAccount, updateSessionInfo, updateStorage, upsertAccount };
4123
+ export { AUTH_PENDING_CODE, BLOCKED_MODEL_IDS, CODEBUDDY_AUTH_CHANNEL, CODEBUDDY_AXIOS_USER_AGENT, CODEBUDDY_CHAT_BASE, CODEBUDDY_DISPLAY_NAME, CODEBUDDY_ENDPOINT, CODEBUDDY_GIFT_SUB_PRODUCT_CODE, CODEBUDDY_IDE_VERSION, CODEBUDDY_PRODUCT_NAME, CODEBUDDY_PROVIDER, CodeBuddyAdapter, CodeBuddyAuthService, CodeBuddySession, Config, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, LOGIN_POLL_INTERVAL_MS, LOGIN_RESULT_RETENTION_MS, LOGIN_TIMEOUT_MS, NotLoggedInError, REQUEST_IMAGE_DIMENSION_LADDER, REQUEST_IMAGE_MAX_BYTES, apply, fetchEnterpriseUsage, fetchPersonalUsage, fetchUsage, getStoragePath, hasDisclosedCapacity, httpErrorCode, inject, isBlockedModelId, loadSessionInfo, loadStorage, login, name, parseUsage, removeAccounts, resolveConnectionOptions, resolvePrefs, setLastAccount, updateSessionInfo, updateStorage, upsertAccount };