dsh-router-core 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +9 -10
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +64 -2
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +286 -127
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +64 -0
  15. package/lib/types/ext-prompt/index.d.ts +53 -0
  16. package/lib/types/ext-prompt/logo.d.ts +17 -0
  17. package/lib/types/ext-prompt/mount.d.ts +51 -0
  18. package/lib/types/ext-prompt/plugin.d.ts +32 -0
  19. package/lib/types/ext-prompt/render.d.ts +105 -0
  20. package/lib/types/host-version.d.ts +7 -8
  21. package/lib/types/index.d.ts +1 -1
  22. package/lib/types/shared.d.ts +33 -0
  23. package/package.json +17 -7
  24. package/src/client/EndpointTab.tsx +15 -18
  25. package/src/client/ExtDetail.tsx +552 -9
  26. package/src/client/index.tsx +7 -28
  27. package/src/client/router.css +132 -62
  28. package/src/ext/contract.ts +111 -0
  29. package/src/ext-prompt/assembly.test.ts +79 -0
  30. package/src/ext-prompt/content.ts +181 -0
  31. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  32. package/src/ext-prompt/index.ts +104 -0
  33. package/src/ext-prompt/locale/en.json +6 -0
  34. package/src/ext-prompt/locale/zh.json +6 -0
  35. package/src/ext-prompt/logo.svg +14 -0
  36. package/src/ext-prompt/logo.ts +18 -0
  37. package/src/ext-prompt/mount.test.ts +95 -0
  38. package/src/ext-prompt/mount.ts +79 -0
  39. package/src/ext-prompt/plugin.ts +223 -0
  40. package/src/ext-prompt/render.test.ts +256 -0
  41. package/src/ext-prompt/render.ts +216 -0
  42. package/src/ext-prompt/row.json +5 -0
  43. package/src/ext-route.test.ts +78 -0
  44. package/src/host-version.test.ts +21 -28
  45. package/src/host-version.ts +39 -39
  46. package/src/index.ts +216 -94
  47. package/src/llm/adapter.test.ts +19 -42
  48. package/src/llm/adapter.ts +20 -47
  49. package/src/shared.ts +34 -0
  50. package/src/suppliers/registry.test.ts +56 -0
  51. package/src/suppliers/registry.ts +40 -10
package/lib/index.js CHANGED
@@ -2687,11 +2687,10 @@ function projectImageDimensions(width, height, maxPixels) {
2687
2687
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
2688
2688
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
2689
2689
  *
2690
- * 工具结果双模型兼容:0.1.5/0.1.6 里 tool result 是 user 消息里的 'tool-result' 块
2691
- * (旧路径);0.1.7+ 改成了独立 role:'tool' 消息、toolCallId 挂消息级(新分支)。
2692
- * 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
2690
+ * 工具结果只有一种形态:独立 `role:'tool'` 消息、toolCallId 挂消息级,wire 上原样
2691
+ * 发 `role:'tool'`。见主循环里的 `role === 'tool'` 分支。
2693
2692
  */
2694
- /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2693
+ /** tool 消息里的图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2695
2694
  const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
2696
2695
  /**
2697
2696
  * 流空闲超时:上游连上后这么久没吐**任何**字节就判失败(抛 TIMEOUT,可重试)。
@@ -2706,7 +2705,7 @@ const STREAM_IDLE_TIMEOUT_MS = 3e5;
2706
2705
  async function wireMessages(options, system, attachments, log) {
2707
2706
  const out = [];
2708
2707
  /**
2709
- * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
2708
+ * 攒着「tool 消息里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
2710
2709
  * user 消息补在后面。
2711
2710
  *
2712
2711
  * 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
@@ -2774,26 +2773,12 @@ async function wireMessages(options, system, attachments, log) {
2774
2773
  pendingToolImages.push(...imageParts);
2775
2774
  continue;
2776
2775
  }
2777
- const toolResults = message.content.filter((b) => b.type === "tool-result");
2778
- const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal, log);
2779
- if (ownParts.length > 0 || toolResults.length === 0) {
2780
- flushToolImages();
2781
- out.push({
2782
- role: "user",
2783
- content: compactUserContent(ownParts)
2784
- });
2785
- }
2786
- for (const result of toolResults) {
2787
- const parts = await contentParts(result.content, attachments, options.signal, log);
2788
- const imageParts = parts.filter((p) => p.type !== "text");
2789
- const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2790
- out.push({
2791
- role: "tool",
2792
- tool_call_id: result.toolCallId,
2793
- content: text
2794
- });
2795
- pendingToolImages.push(...imageParts);
2796
- }
2776
+ const userParts = await contentParts(message.content, attachments, options.signal, log);
2777
+ flushToolImages();
2778
+ out.push({
2779
+ role: "user",
2780
+ content: compactUserContent(userParts)
2781
+ });
2797
2782
  }
2798
2783
  flushToolImages();
2799
2784
  return out;
@@ -2811,7 +2796,7 @@ function compactUserContent(parts) {
2811
2796
  return texts.join("");
2812
2797
  }
2813
2798
  /**
2814
- * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
2799
+ * 一条 DSH 消息的内容块序列化成 OpenAI content parts(text + image_url)。
2815
2800
  * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
2816
2801
  * attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
2817
2802
  */
@@ -2831,7 +2816,6 @@ async function contentParts(blocks, attachments, signal, log) {
2831
2816
  parts.push(...await imageParts(ref, attachments, signal, log));
2832
2817
  continue;
2833
2818
  }
2834
- if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal, log));
2835
2819
  }
2836
2820
  return parts;
2837
2821
  }
@@ -4025,6 +4009,33 @@ function writeJson$1(res, status, body) {
4025
4009
  });
4026
4010
  res.end(payload);
4027
4011
  }
4012
+ /**
4013
+ * 包住会调插件的 handler:插件抛错时回 **JSON 500**,别让异常裸奔到 webServer。
4014
+ *
4015
+ * 为什么必须有这一层:webServer 的兜底是 `res.writeHead(400); res.end()`——**空
4016
+ * body**。面板拿到空 body,`response.json()` 抛 "Unexpected end of JSON input",
4017
+ * 真实原因(上游 500 / token 失效…)全丢,用户只看到一句解析错误。
4018
+ *
4019
+ * 这条路径是插件契约「出错就抛」(docs/suppliers.md「模型统一策略」) 的下游:插件
4020
+ * 老实抛了,核心就得老实接住并说清楚。以前 `listModels` 静默吞错,所以从没暴露过
4021
+ * 这个缺口(traework 2026-10-10 「获取模型只有几个」修完才浮现)。
4022
+ */
4023
+ function guard(handler) {
4024
+ return async (req, res) => {
4025
+ try {
4026
+ await handler(req, res);
4027
+ } catch (err) {
4028
+ if (res.headersSent) {
4029
+ res.end();
4030
+ return;
4031
+ }
4032
+ writeJson$1(res, err instanceof SyntaxError ? 400 : 500, {
4033
+ ok: false,
4034
+ error: err.message
4035
+ });
4036
+ }
4037
+ };
4038
+ }
4028
4039
  async function readBody$1(req, limit = 65536) {
4029
4040
  const chunks = [];
4030
4041
  let size = 0;
@@ -4083,14 +4094,14 @@ function supplierRoutes(base, loaded, store, router) {
4083
4094
  routes.push({
4084
4095
  kind: "exact",
4085
4096
  path: `${p}/models`,
4086
- handler: async (_req, res) => {
4097
+ handler: guard(async (_req, res) => {
4087
4098
  const models = await router.modelsOf(s.id);
4088
4099
  writeJson$1(res, 200, {
4089
4100
  ok: true,
4090
4101
  alias: s.getAlias(),
4091
4102
  models
4092
4103
  });
4093
- }
4104
+ })
4094
4105
  });
4095
4106
  routes.push({
4096
4107
  kind: "exact",
@@ -4218,7 +4229,7 @@ function supplierRoutes(base, loaded, store, router) {
4218
4229
  routes.push({
4219
4230
  kind: "exact",
4220
4231
  path: `${p}/models/toggle`,
4221
- handler: async (req, res) => {
4232
+ handler: guard(async (req, res) => {
4222
4233
  const body = JSON.parse(await readBody$1(req));
4223
4234
  store.get(s.id);
4224
4235
  const models = await router.modelsOf(s.id);
@@ -4232,12 +4243,12 @@ function supplierRoutes(base, loaded, store, router) {
4232
4243
  store.setModelEnabled(s.id, body.id, !!body.enabled);
4233
4244
  router.invalidateModels(s.id);
4234
4245
  writeJson$1(res, 200, { ok: true });
4235
- }
4246
+ })
4236
4247
  });
4237
4248
  routes.push({
4238
4249
  kind: "exact",
4239
4250
  path: `${p}/models/add`,
4240
- handler: async (req, res) => {
4251
+ handler: guard(async (req, res) => {
4241
4252
  const id = (JSON.parse(await readBody$1(req)).id ?? "").trim();
4242
4253
  if (id === "") {
4243
4254
  writeJson$1(res, 400, {
@@ -4256,7 +4267,7 @@ function supplierRoutes(base, loaded, store, router) {
4256
4267
  store.addCustomModel(s.id, id);
4257
4268
  router.invalidateModels(s.id);
4258
4269
  writeJson$1(res, 200, { ok: true });
4259
- }
4270
+ })
4260
4271
  });
4261
4272
  routes.push({
4262
4273
  kind: "exact",
@@ -4279,13 +4290,13 @@ function supplierRoutes(base, loaded, store, router) {
4279
4290
  routes.push({
4280
4291
  kind: "exact",
4281
4292
  path: `${p}/models/bulk`,
4282
- handler: async (req, res) => {
4293
+ handler: guard(async (req, res) => {
4283
4294
  const body = JSON.parse(await readBody$1(req));
4284
4295
  const models = await router.modelsOf(s.id);
4285
4296
  store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
4286
4297
  router.invalidateModels(s.id);
4287
4298
  writeJson$1(res, 200, { ok: true });
4288
- }
4299
+ })
4289
4300
  });
4290
4301
  routes.push({
4291
4302
  kind: "exact",
@@ -4313,12 +4324,12 @@ function supplierRoutes(base, loaded, store, router) {
4313
4324
  routes.push({
4314
4325
  kind: "exact",
4315
4326
  path: `${p}/models/fetch`,
4316
- handler: async (_req, res) => {
4327
+ handler: guard(async (_req, res) => {
4317
4328
  writeJson$1(res, 200, {
4318
4329
  ok: true,
4319
4330
  models: await router.modelsOf(s.id, true)
4320
4331
  });
4321
- }
4332
+ })
4322
4333
  });
4323
4334
  routes.push({
4324
4335
  kind: "exact",
@@ -4614,37 +4625,46 @@ function loopbackBaseURL(readPort, path = "/v1") {
4614
4625
  //#endregion
4615
4626
  //#region src/host-version.ts
4616
4627
  /**
4617
- * 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
4628
+ * 宿主 DSH 版本探测 —— 现在只剩两件事:**报版本**(日志 + `/router/api/health`)与
4629
+ * **守版本地板**。
4618
4630
  *
4619
- * 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
4620
- * ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
4621
- * 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
4622
- * profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
4631
+ * ## 为什么不再有兼容分叉
4623
4632
  *
4624
- * 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
4625
- * 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
4626
- * Config 驱动(无此方法)→ 按版本走对应注册路径。
4627
- * 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
4628
- * 块;0.1.7+ = 独立 role:'tool' 消息。
4633
+ * 原来这里有三处 `>= 0.1.7` 的分叉(设置卡片注册路径、「最近命中」徽章座位、adapter
4634
+ * 的工具结果消息形态),彼此独立判断、还容易误判。本插件现已按 **0.2.0 单面**编写,
4635
+ * 三处的旧分支全部删除:
4636
+ *
4637
+ * 1. 设置卡片:0.1.5 走 `settings.installSection`,0.1.7+ 走导出的 volatile Config。
4638
+ * 0.2.0 只有后者 → installSection 分支删除。
4639
+ * 2. 「最近命中」徽章:0.1.5 渲染在 InputBar 之后的独立块(位置不对),0.1.7+ 才在
4640
+ * composer 底部操作行。0.2.0 起无条件挂载,不再问支持位。
4641
+ * 3. adapter 工具结果形态:旧的「user 内嵌 tool-result 块」0.1.7 起退役;0.2.0 的
4642
+ * v3→v4 会话迁移还主动拒绝该包装,所以它到不了 adapter → 分支删除。
4643
+ *
4644
+ * ## 为什么仍要读版本
4645
+ *
4646
+ * 因为**跑在老宿主上必须出声**。删掉分叉不等于「老宿主也能跑」:0.1.x 上本插件会
4647
+ * 静默走错路径(卡片不出、徽章错位、工具结果丢失),而用户看到的现象与「插件坏了」
4648
+ * 无法区分。所以这里只保留一个判据 `isHostSupported`(>= 0.2.0),仅用于 apply 时
4649
+ * 打一条醒目的警告 —— 不是兼容,是可诊断。
4629
4650
  *
4630
4651
  * 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
4631
4652
  * 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
4632
- * 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
4633
- * 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
4653
+ * 会沿 profile 的 node_modules 向上解析到全局安装根。拿不到就回落直接拼路径,再不行
4654
+ * 返回 undefined(按「无法确认」处理,不抛)。
4634
4655
  *
4635
- * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
4636
- * 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
4656
+ * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层解析到的是插件自己
4657
+ * 的依赖,不能代表宿主。
4637
4658
  */
4638
- /** 各兼容分叉的版本门槛。 */
4639
- /** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
4640
- const V0_1_7 = [
4659
+ /** 版本地板:低于此版本的宿主本插件不再兼容(0.1.5/0.1.6/0.1.7 全部落此下)。 */
4660
+ const V0_2_0 = [
4641
4661
  0,
4642
- 1,
4643
- 7
4662
+ 2,
4663
+ 0
4644
4664
  ];
4645
4665
  /**
4646
4666
  * 解析并比较 semver(只取前导数字段,预发布标识忽略)。
4647
- * `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
4667
+ * `0.2.0-rc.1` → [0,2,0],`0.1.7-alpha.2` → [0,1,7]。
4648
4668
  * 返回负数/0/正数,语义同 `<`/`==`/`>`。
4649
4669
  */
4650
4670
  function compareVersion(a, b) {
@@ -4655,7 +4675,7 @@ function compareVersion(a, b) {
4655
4675
  }
4656
4676
  return 0;
4657
4677
  }
4658
- /** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
4678
+ /** `0.2.0-rc.1` → [0,2,0];解析失败返回 null。 */
4659
4679
  function parseVersion(raw) {
4660
4680
  const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim());
4661
4681
  if (m === null) return null;
@@ -4668,7 +4688,7 @@ function parseVersion(raw) {
4668
4688
  /**
4669
4689
  * 探测 profile 里安装的 DSH 宿主版本。
4670
4690
  * @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
4671
- * @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
4691
+ * @returns 形如 `0.2.0-rc.1`;读不到返回 undefined
4672
4692
  */
4673
4693
  function detectHostVersion(baseUrl) {
4674
4694
  const profile = profileDirOf(baseUrl);
@@ -4682,26 +4702,19 @@ function detectHostVersion(baseUrl) {
4682
4702
  if (typeof parsed.version === "string" && parsed.version !== "") return parsed.version;
4683
4703
  } catch {}
4684
4704
  }
4685
- /** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
4686
- function atLeast(baseUrl, min) {
4705
+ /**
4706
+ * 宿主是否在本插件支持的地板之上(>= 0.2.0)。
4707
+ *
4708
+ * **只用于报警,不用于分流** —— 低于地板时插件照常加载,但要在日志里说清「这台宿主
4709
+ * 不再兼容、会出现什么症状」,把静默的错变成可查的错。读不到版本 → false(当作
4710
+ * 「无法确认」,同样值得说一句)。
4711
+ */
4712
+ function isHostSupported(baseUrl) {
4687
4713
  const v = detectHostVersion(baseUrl);
4688
4714
  if (v === void 0) return false;
4689
4715
  const parsed = parseVersion(v);
4690
4716
  if (parsed === null) return false;
4691
- return compareVersion(parsed, min) >= 0;
4692
- }
4693
- /**
4694
- * 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
4695
- * - 「最近命中」徽章(composer.dock 位置正确)
4696
- * - 设置命名空间(Config 驱动而非 installSection)
4697
- * - adapter 的独立 role:'tool' 消息形态
4698
- */
4699
- function isHost017Plus(baseUrl) {
4700
- return atLeast(baseUrl, V0_1_7);
4701
- }
4702
- /** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
4703
- function supportsLastHitDock(baseUrl) {
4704
- return isHost017Plus(baseUrl);
4717
+ return compareVersion(parsed, V0_2_0) >= 0;
4705
4718
  }
4706
4719
  //#endregion
4707
4720
  //#region src/ext/store.ts
@@ -4823,26 +4836,23 @@ const name = "dsh-router-core";
4823
4836
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
4824
4837
  const inject = ["webServer", "llm"];
4825
4838
  /**
4826
- * `Config` 导出:让 Router 卡片进 0.1.7 的设置镜像。
4839
+ * `Config` 导出:让 Router 卡片进设置-模型的镜像(0.2.0 的收录链是「插件导出的
4840
+ * Config + 配置树行 id」)。
4827
4841
  *
4828
- * **这处刻意保留能力检测,不用宿主版本号** —— 与另两处兼容点(installSection /
4829
- * adapter 消息形态)不同。原因(已实测):这是**模块顶层导出**,loader 一 import
4830
- * 就求值;而 schemastery 是本插件的**普通依赖**(`^3.18.4`,见 package.json),
4831
- * 解析到的是**插件自己 node_modules 里那一份**,跟宿主实际加载的版本无关
4832
- * (实测:插件解析到 3.18.4,而 profile 里是 3.18.3、全局是 3.18.2)。既然这里
4833
- * 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
4834
- * 能力检测恰好就是**对**的判据。
4842
+ * **这处保留能力检测,不看宿主版本号** —— 因为它是**模块顶层导出**,loader 一 import
4843
+ * 就求值,那时还没有 `ctx.baseUrl`;而 schemastery 是本插件的**普通依赖**,解析到的
4844
+ * 是插件自己 node_modules 里那一份,跟宿主实际加载的版本无关。既然这里拿不到宿主
4845
+ * 版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 —— 能力检测
4846
+ * 恰好就是**对**的判据。
4835
4847
  *
4836
- * 定位说明(非兼容分叉):`.volatile()` 是 schemastery 3.18.4 才有的原型方法;
4837
- * 老版本上直写 `Schema.object({}).volatile()` 会 `.volatile` 为 undefined →
4838
- * 抛 TypeError → **整个插件加载失败**(不只是卡片,是 dsh-router 完全挂掉)。
4839
- * - 有 volatile:走 volatile,空 object 不被 volatileForm 过滤 → 卡片进镜像;
4840
- * - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
4841
- * installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
4848
+ * 为什么要 `.volatile()`:空 object 会被 volatileForm 过滤掉,顶层标 volatile 才能让
4849
+ * 卡片进镜像。`.volatile()` 是 schemastery 3.18.4 才有的原型方法,直写
4850
+ * `Schema.object({}).volatile()` 在没有它的版本上会抛 TypeError → **整个插件加载
4851
+ * 失败**。所以:无 volatile 就回落普通空 schema(不抛,代价是卡片不出现,并有日志)。
4842
4852
  */
4843
4853
  const emptySchema = Schema.object({});
4844
4854
  /**
4845
- * 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
4855
+ * 设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
4846
4856
  * 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
4847
4857
  * Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
4848
4858
  */
@@ -4880,9 +4890,9 @@ function apply(rawContext) {
4880
4890
  const stateFile = join(dataDir, "state.json");
4881
4891
  log(`data dir: ${dataDir}`);
4882
4892
  const hostVersion = detectHostVersion(ctx.baseUrl);
4883
- const host017Plus = isHost017Plus(ctx.baseUrl);
4884
- const lastHitDock = supportsLastHitDock(ctx.baseUrl);
4885
- log(`host version: ${hostVersion ?? "未知"} (>=0.1.7: ${host017Plus ? "yes" : "no"}, last-hit dock: ${lastHitDock ? "on" : "off"})`);
4893
+ const hostSupported = isHostSupported(ctx.baseUrl);
4894
+ log(`host version: ${hostVersion ?? "未知"} (>=0.2.0: ${hostSupported ? "yes" : "no"})`);
4895
+ if (!hostSupported) ctx.logger.warn(`[dsh-router] 宿主版本 ${hostVersion ?? "未知"} 低于本插件支持的 0.2.0 —— 插件仍会加载,但设置卡片、「最近命中」徽章与工具结果转发会走错路径。请把宿主升到 0.2.0 及以上。`);
4886
4896
  const store = new SupplierConfigStore(stateFile);
4887
4897
  const credentials = new CredentialStore(join(dataDir, "auths"));
4888
4898
  const router = new Router(stateFile, store, log);
@@ -5050,7 +5060,7 @@ function apply(rawContext) {
5050
5060
  writeJson(res, 200, {
5051
5061
  ok: true,
5052
5062
  hostVersion: hostVersion ?? "",
5053
- lastHitDock,
5063
+ lastHitDock: true,
5054
5064
  suppliers: suppliers.map((s) => {
5055
5065
  const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
5056
5066
  return {
@@ -5264,6 +5274,39 @@ function apply(rawContext) {
5264
5274
  requireApiKey: keys.requireApiKey
5265
5275
  });
5266
5276
  });
5277
+ /**
5278
+ * 把 `router.ext` 表映射成面板要的 `ExtInfo[]`。**GET 与 PATCH 共用**。
5279
+ *
5280
+ * ⚠️ 2026-09-30:op 成功时我复制了一份这段映射来就地回表 —— 两份必然漂移
5281
+ * (改一处只生效一半,本仓反复栽的坑)⇒ 抽成函数,一处定义两处调用。
5282
+ */
5283
+ function extInfos(table, isEnabled) {
5284
+ return Object.values(table).map((raw) => {
5285
+ const e = raw;
5286
+ if (!e || typeof e.id !== "string" || e.id === "") return void 0;
5287
+ const st = typeof e.getState === "function" ? e.getState() : { ready: false };
5288
+ return {
5289
+ id: e.id,
5290
+ name: e.name ?? e.id,
5291
+ ...e.description !== void 0 ? { description: e.description } : {},
5292
+ ...e.icon !== void 0 ? { icon: e.icon } : {},
5293
+ enabled: isEnabled(e.id),
5294
+ ready: st?.ready === true,
5295
+ ...raw.source === "builtin" ? { source: "builtin" } : {},
5296
+ ...Array.isArray(e.controls) && e.controls.length > 0 ? { controls: e.controls.filter((c) => !!c && typeof c.id === "string" && c.id !== "" && typeof c.title === "string").map((c) => ({
5297
+ id: c.id,
5298
+ title: c.title,
5299
+ on: c.on === true,
5300
+ ...c.detail !== void 0 ? { detail: c.detail } : {},
5301
+ ...typeof c.body === "string" ? { body: c.body } : {},
5302
+ ...c.editable === true ? { editable: true } : {},
5303
+ custom: c.custom === true,
5304
+ overridden: c.overridden === true
5305
+ })) } : {},
5306
+ ...st?.detail !== void 0 ? { detail: st.detail } : {}
5307
+ };
5308
+ }).filter((e) => e !== void 0);
5309
+ }
5267
5310
  route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
5268
5311
  if (req.method === "PATCH") {
5269
5312
  let body;
@@ -5276,6 +5319,7 @@ function apply(rawContext) {
5276
5319
  });
5277
5320
  return;
5278
5321
  }
5322
+ let addedId = null;
5279
5323
  const ext = typeof body.id === "string" ? exts[body.id] : void 0;
5280
5324
  if (!ext) {
5281
5325
  writeJson(res, 404, {
@@ -5284,6 +5328,152 @@ function apply(rawContext) {
5284
5328
  });
5285
5329
  return;
5286
5330
  }
5331
+ if (body.op !== void 0) {
5332
+ const op = body.op;
5333
+ if (op === "reorder") {
5334
+ if (!Array.isArray(body.ids)) {
5335
+ writeJson(res, 400, {
5336
+ ok: false,
5337
+ error: "ids must be an array"
5338
+ });
5339
+ return;
5340
+ }
5341
+ if (typeof ext.setControlOrder !== "function" || !ext.setControlOrder(body.ids)) {
5342
+ writeJson(res, 400, {
5343
+ ok: false,
5344
+ error: "排序保存失败"
5345
+ });
5346
+ return;
5347
+ }
5348
+ } else if (op === "remove" || op === "reset") {
5349
+ if (typeof body.controlId !== "string" || body.controlId === "") {
5350
+ writeJson(res, 400, {
5351
+ ok: false,
5352
+ error: "controlId must be a non-empty string"
5353
+ });
5354
+ return;
5355
+ }
5356
+ const fn = op === "remove" ? ext.removeCustomControl : ext.resetControlText;
5357
+ if (typeof fn !== "function" || !fn.call(ext, body.controlId)) {
5358
+ writeJson(res, 400, {
5359
+ ok: false,
5360
+ error: op === "remove" ? "只能删除自定义准则(内置条目请用开关关掉)" : "只能还原内置准则,且它得先被改过"
5361
+ });
5362
+ return;
5363
+ }
5364
+ } else if (op === "add") {
5365
+ if (typeof ext.addCustomControl !== "function") {
5366
+ writeJson(res, 400, {
5367
+ ok: false,
5368
+ error: "extension does not support custom controls"
5369
+ });
5370
+ return;
5371
+ }
5372
+ addedId = ext.addCustomControl(typeof body.title === "string" ? body.title : "", typeof body.body === "string" ? body.body : "");
5373
+ if (addedId === null) {
5374
+ writeJson(res, 400, {
5375
+ ok: false,
5376
+ error: "标题与内容都不能为空"
5377
+ });
5378
+ return;
5379
+ }
5380
+ } else {
5381
+ writeJson(res, 400, {
5382
+ ok: false,
5383
+ error: `unknown op: ${String(op)}`
5384
+ });
5385
+ return;
5386
+ }
5387
+ writeJson(res, 200, addedId === null ? {
5388
+ ok: true,
5389
+ enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
5390
+ } : {
5391
+ ok: true,
5392
+ id: addedId,
5393
+ enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
5394
+ });
5395
+ return;
5396
+ }
5397
+ if (body.title !== void 0 || body.body !== void 0) {
5398
+ if (typeof body.controlId !== "string" || body.controlId === "") {
5399
+ writeJson(res, 400, {
5400
+ ok: false,
5401
+ error: "controlId must be a non-empty string"
5402
+ });
5403
+ return;
5404
+ }
5405
+ if (typeof body.title !== "undefined" && typeof body.title !== "string") {
5406
+ writeJson(res, 400, {
5407
+ ok: false,
5408
+ error: "title must be a string"
5409
+ });
5410
+ return;
5411
+ }
5412
+ if (typeof body.body !== "undefined" && typeof body.body !== "string") {
5413
+ writeJson(res, 400, {
5414
+ ok: false,
5415
+ error: "body must be a string"
5416
+ });
5417
+ return;
5418
+ }
5419
+ if (typeof ext.setControlText !== "function") {
5420
+ writeJson(res, 400, {
5421
+ ok: false,
5422
+ error: "extension controls are not editable"
5423
+ });
5424
+ return;
5425
+ }
5426
+ const patch = {};
5427
+ if (typeof body.title === "string") patch.title = body.title;
5428
+ if (typeof body.body === "string") patch.body = body.body;
5429
+ if (!ext.setControlText(body.controlId, patch)) {
5430
+ writeJson(res, 400, {
5431
+ ok: false,
5432
+ error: "unknown controlId"
5433
+ });
5434
+ return;
5435
+ }
5436
+ writeJson(res, 200, {
5437
+ ok: true,
5438
+ enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
5439
+ });
5440
+ return;
5441
+ }
5442
+ if (body.controlId !== void 0) {
5443
+ if (typeof body.controlId !== "string" || body.controlId === "") {
5444
+ writeJson(res, 400, {
5445
+ ok: false,
5446
+ error: "controlId must be a non-empty string"
5447
+ });
5448
+ return;
5449
+ }
5450
+ if (typeof body.on !== "boolean") {
5451
+ writeJson(res, 400, {
5452
+ ok: false,
5453
+ error: "on must be a boolean"
5454
+ });
5455
+ return;
5456
+ }
5457
+ if (typeof ext.setControl !== "function") {
5458
+ writeJson(res, 400, {
5459
+ ok: false,
5460
+ error: "extension has no controls"
5461
+ });
5462
+ return;
5463
+ }
5464
+ if (!ext.setControl(body.controlId, body.on)) {
5465
+ writeJson(res, 400, {
5466
+ ok: false,
5467
+ error: "unknown controlId"
5468
+ });
5469
+ return;
5470
+ }
5471
+ writeJson(res, 200, {
5472
+ ok: true,
5473
+ enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
5474
+ });
5475
+ return;
5476
+ }
5287
5477
  if (typeof body.enabled !== "boolean") {
5288
5478
  writeJson(res, 400, {
5289
5479
  ok: false,
@@ -5305,21 +5495,7 @@ function apply(rawContext) {
5305
5495
  }
5306
5496
  writeJson(res, 200, {
5307
5497
  ok: true,
5308
- enhancers: Object.values(exts).map((raw) => {
5309
- const e = raw;
5310
- if (!e || typeof e.id !== "string" || e.id === "") return void 0;
5311
- const st = typeof e.getState === "function" ? e.getState() : { ready: false };
5312
- return {
5313
- id: e.id,
5314
- name: e.name ?? e.id,
5315
- ...e.description !== void 0 ? { description: e.description } : {},
5316
- ...e.icon !== void 0 ? { icon: e.icon } : {},
5317
- enabled: extStore.isEnabled(e.id),
5318
- ready: st?.ready === true,
5319
- ...raw.source === "builtin" ? { source: "builtin" } : {},
5320
- ...st?.detail !== void 0 ? { detail: st.detail } : {}
5321
- };
5322
- }).filter((e) => e !== void 0)
5498
+ enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
5323
5499
  });
5324
5500
  });
5325
5501
  /** 解析并校验周期参数(不接受任意字符串)。 */
@@ -5382,24 +5558,7 @@ function apply(rawContext) {
5382
5558
  disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
5383
5559
  return (await router.combos()).map((c) => ({ id: c.name }));
5384
5560
  }));
5385
- const routerSettingsSchema = Schema.object({});
5386
- if (isHost017Plus(ctx.baseUrl)) log(`settings section driven by exported Config (${settingsNs})`);
5387
- else ctx.inject(["settings"], (sctx) => {
5388
- const settings = sctx.settings;
5389
- if (settings === void 0) {
5390
- log("settings service absent — skip Router settings-section registration");
5391
- return;
5392
- }
5393
- if (settings.installSection === void 0) {
5394
- log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`);
5395
- return;
5396
- }
5397
- settings.installSection(rawContext, settingsNs, routerSettingsSchema, {}, {
5398
- setSource: () => {},
5399
- onChange: () => {}
5400
- });
5401
- log(`Router settings section registered (${settingsNs})`);
5402
- });
5561
+ log(`settings section driven by exported Config (${settingsNs})`);
5403
5562
  disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(loopbackBaseURL(() => ctx.webServer?.port), { comboModels: async () => (await router.combos()).map((c) => {
5404
5563
  const w = router.comboContextWindow(c);
5405
5564
  return {