dsh-router-core 0.4.2 → 0.4.3
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/README.md +9 -10
- package/cordis.patch.yml +10 -0
- package/docs/ext.md +64 -2
- package/lib/client-registry.js +633 -50
- package/lib/client.js +633 -50
- package/lib/contract-D4BWZ7Xe.js +13 -0
- package/lib/ext-prompt/index.js +527 -0
- package/lib/ext-prompt/locale/en.json +6 -0
- package/lib/ext-prompt/locale/zh.json +6 -0
- package/lib/ext-prompt/package.json +5 -0
- package/lib/ext-test/index.js +1 -12
- package/lib/index.js +249 -117
- package/lib/types/ext/contract.d.ts +113 -0
- package/lib/types/ext-prompt/content.d.ts +57 -0
- package/lib/types/ext-prompt/index.d.ts +53 -0
- package/lib/types/ext-prompt/logo.d.ts +17 -0
- package/lib/types/ext-prompt/mount.d.ts +51 -0
- package/lib/types/ext-prompt/plugin.d.ts +32 -0
- package/lib/types/ext-prompt/render.d.ts +105 -0
- package/lib/types/host-version.d.ts +7 -8
- package/lib/types/index.d.ts +1 -1
- package/lib/types/shared.d.ts +33 -0
- package/package.json +17 -7
- package/src/client/EndpointTab.tsx +15 -18
- package/src/client/ExtDetail.tsx +552 -9
- package/src/client/index.tsx +7 -28
- package/src/client/router.css +132 -62
- package/src/ext/contract.ts +111 -0
- package/src/ext-prompt/assembly.test.ts +79 -0
- package/src/ext-prompt/content.ts +174 -0
- package/src/ext-prompt/ext-prompt.test.ts +432 -0
- package/src/ext-prompt/index.ts +104 -0
- package/src/ext-prompt/locale/en.json +6 -0
- package/src/ext-prompt/locale/zh.json +6 -0
- package/src/ext-prompt/logo.svg +14 -0
- package/src/ext-prompt/logo.ts +18 -0
- package/src/ext-prompt/mount.test.ts +95 -0
- package/src/ext-prompt/mount.ts +79 -0
- package/src/ext-prompt/plugin.ts +223 -0
- package/src/ext-prompt/render.test.ts +256 -0
- package/src/ext-prompt/render.ts +216 -0
- package/src/ext-prompt/row.json +5 -0
- package/src/ext-route.test.ts +78 -0
- package/src/host-version.test.ts +21 -28
- package/src/host-version.ts +39 -39
- package/src/index.ts +216 -94
- package/src/llm/adapter.test.ts +19 -42
- package/src/llm/adapter.ts +20 -47
- package/src/shared.ts +34 -0
package/lib/index.js
CHANGED
|
@@ -2687,11 +2687,10 @@ function projectImageDimensions(width, height, maxPixels) {
|
|
|
2687
2687
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2688
2688
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2689
2689
|
*
|
|
2690
|
-
*
|
|
2691
|
-
*
|
|
2692
|
-
* 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
|
|
2690
|
+
* 工具结果只有一种形态:独立 `role:'tool'` 消息、toolCallId 挂消息级,wire 上原样
|
|
2691
|
+
* 发 `role:'tool'`。见主循环里的 `role === 'tool'` 分支。
|
|
2693
2692
|
*/
|
|
2694
|
-
/** tool
|
|
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
|
|
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
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
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
|
-
*
|
|
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
|
}
|
|
@@ -4614,37 +4598,46 @@ function loopbackBaseURL(readPort, path = "/v1") {
|
|
|
4614
4598
|
//#endregion
|
|
4615
4599
|
//#region src/host-version.ts
|
|
4616
4600
|
/**
|
|
4617
|
-
* 宿主 DSH 版本探测 ——
|
|
4601
|
+
* 宿主 DSH 版本探测 —— 现在只剩两件事:**报版本**(日志 + `/router/api/health`)与
|
|
4602
|
+
* **守版本地板**。
|
|
4603
|
+
*
|
|
4604
|
+
* ## 为什么不再有兼容分叉
|
|
4618
4605
|
*
|
|
4619
|
-
*
|
|
4620
|
-
*
|
|
4621
|
-
*
|
|
4622
|
-
* profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
|
|
4606
|
+
* 原来这里有三处 `>= 0.1.7` 的分叉(设置卡片注册路径、「最近命中」徽章座位、adapter
|
|
4607
|
+
* 的工具结果消息形态),彼此独立判断、还容易误判。本插件现已按 **0.2.0 单面**编写,
|
|
4608
|
+
* 三处的旧分支全部删除:
|
|
4623
4609
|
*
|
|
4624
|
-
* 1.
|
|
4625
|
-
*
|
|
4626
|
-
*
|
|
4627
|
-
*
|
|
4628
|
-
*
|
|
4610
|
+
* 1. 设置卡片:0.1.5 走 `settings.installSection`,0.1.7+ 走导出的 volatile Config。
|
|
4611
|
+
* 0.2.0 只有后者 → installSection 分支删除。
|
|
4612
|
+
* 2. 「最近命中」徽章:0.1.5 渲染在 InputBar 之后的独立块(位置不对),0.1.7+ 才在
|
|
4613
|
+
* composer 底部操作行。0.2.0 起无条件挂载,不再问支持位。
|
|
4614
|
+
* 3. adapter 工具结果形态:旧的「user 内嵌 tool-result 块」0.1.7 起退役;0.2.0 的
|
|
4615
|
+
* v3→v4 会话迁移还主动拒绝该包装,所以它到不了 adapter → 分支删除。
|
|
4616
|
+
*
|
|
4617
|
+
* ## 为什么仍要读版本
|
|
4618
|
+
*
|
|
4619
|
+
* 因为**跑在老宿主上必须出声**。删掉分叉不等于「老宿主也能跑」:0.1.x 上本插件会
|
|
4620
|
+
* 静默走错路径(卡片不出、徽章错位、工具结果丢失),而用户看到的现象与「插件坏了」
|
|
4621
|
+
* 无法区分。所以这里只保留一个判据 `isHostSupported`(>= 0.2.0),仅用于 apply 时
|
|
4622
|
+
* 打一条醒目的警告 —— 不是兼容,是可诊断。
|
|
4629
4623
|
*
|
|
4630
4624
|
* 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
|
|
4631
4625
|
* 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
|
|
4632
|
-
* 会沿 profile 的 node_modules
|
|
4633
|
-
*
|
|
4626
|
+
* 会沿 profile 的 node_modules 向上解析到全局安装根。拿不到就回落直接拼路径,再不行
|
|
4627
|
+
* 返回 undefined(按「无法确认」处理,不抛)。
|
|
4634
4628
|
*
|
|
4635
|
-
* 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl
|
|
4636
|
-
*
|
|
4629
|
+
* 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层解析到的是插件自己
|
|
4630
|
+
* 的依赖,不能代表宿主。
|
|
4637
4631
|
*/
|
|
4638
|
-
/**
|
|
4639
|
-
|
|
4640
|
-
const V0_1_7 = [
|
|
4632
|
+
/** 版本地板:低于此版本的宿主本插件不再兼容(0.1.5/0.1.6/0.1.7 全部落此下)。 */
|
|
4633
|
+
const V0_2_0 = [
|
|
4641
4634
|
0,
|
|
4642
|
-
|
|
4643
|
-
|
|
4635
|
+
2,
|
|
4636
|
+
0
|
|
4644
4637
|
];
|
|
4645
4638
|
/**
|
|
4646
4639
|
* 解析并比较 semver(只取前导数字段,预发布标识忽略)。
|
|
4647
|
-
* `0.
|
|
4640
|
+
* `0.2.0-rc.1` → [0,2,0],`0.1.7-alpha.2` → [0,1,7]。
|
|
4648
4641
|
* 返回负数/0/正数,语义同 `<`/`==`/`>`。
|
|
4649
4642
|
*/
|
|
4650
4643
|
function compareVersion(a, b) {
|
|
@@ -4655,7 +4648,7 @@ function compareVersion(a, b) {
|
|
|
4655
4648
|
}
|
|
4656
4649
|
return 0;
|
|
4657
4650
|
}
|
|
4658
|
-
/** `0.
|
|
4651
|
+
/** `0.2.0-rc.1` → [0,2,0];解析失败返回 null。 */
|
|
4659
4652
|
function parseVersion(raw) {
|
|
4660
4653
|
const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim());
|
|
4661
4654
|
if (m === null) return null;
|
|
@@ -4668,7 +4661,7 @@ function parseVersion(raw) {
|
|
|
4668
4661
|
/**
|
|
4669
4662
|
* 探测 profile 里安装的 DSH 宿主版本。
|
|
4670
4663
|
* @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
|
|
4671
|
-
* @returns 形如 `0.
|
|
4664
|
+
* @returns 形如 `0.2.0-rc.1`;读不到返回 undefined
|
|
4672
4665
|
*/
|
|
4673
4666
|
function detectHostVersion(baseUrl) {
|
|
4674
4667
|
const profile = profileDirOf(baseUrl);
|
|
@@ -4682,26 +4675,19 @@ function detectHostVersion(baseUrl) {
|
|
|
4682
4675
|
if (typeof parsed.version === "string" && parsed.version !== "") return parsed.version;
|
|
4683
4676
|
} catch {}
|
|
4684
4677
|
}
|
|
4685
|
-
/**
|
|
4686
|
-
|
|
4678
|
+
/**
|
|
4679
|
+
* 宿主是否在本插件支持的地板之上(>= 0.2.0)。
|
|
4680
|
+
*
|
|
4681
|
+
* **只用于报警,不用于分流** —— 低于地板时插件照常加载,但要在日志里说清「这台宿主
|
|
4682
|
+
* 不再兼容、会出现什么症状」,把静默的错变成可查的错。读不到版本 → false(当作
|
|
4683
|
+
* 「无法确认」,同样值得说一句)。
|
|
4684
|
+
*/
|
|
4685
|
+
function isHostSupported(baseUrl) {
|
|
4687
4686
|
const v = detectHostVersion(baseUrl);
|
|
4688
4687
|
if (v === void 0) return false;
|
|
4689
4688
|
const parsed = parseVersion(v);
|
|
4690
4689
|
if (parsed === null) return false;
|
|
4691
|
-
return compareVersion(parsed,
|
|
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);
|
|
4690
|
+
return compareVersion(parsed, V0_2_0) >= 0;
|
|
4705
4691
|
}
|
|
4706
4692
|
//#endregion
|
|
4707
4693
|
//#region src/ext/store.ts
|
|
@@ -4823,26 +4809,23 @@ const name = "dsh-router-core";
|
|
|
4823
4809
|
/** Services required before mounting: the webserver (routes) + llm (设置-模型). */
|
|
4824
4810
|
const inject = ["webServer", "llm"];
|
|
4825
4811
|
/**
|
|
4826
|
-
* `Config` 导出:让 Router
|
|
4812
|
+
* `Config` 导出:让 Router 卡片进设置-模型的镜像(0.2.0 的收录链是「插件导出的
|
|
4813
|
+
* Config + 配置树行 id」)。
|
|
4827
4814
|
*
|
|
4828
|
-
*
|
|
4829
|
-
*
|
|
4830
|
-
*
|
|
4831
|
-
*
|
|
4832
|
-
*
|
|
4833
|
-
* 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
|
|
4834
|
-
* 能力检测恰好就是**对**的判据。
|
|
4815
|
+
* **这处保留能力检测,不看宿主版本号** —— 因为它是**模块顶层导出**,loader 一 import
|
|
4816
|
+
* 就求值,那时还没有 `ctx.baseUrl`;而 schemastery 是本插件的**普通依赖**,解析到的
|
|
4817
|
+
* 是插件自己 node_modules 里那一份,跟宿主实际加载的版本无关。既然这里拿不到宿主
|
|
4818
|
+
* 版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 —— 能力检测
|
|
4819
|
+
* 恰好就是**对**的判据。
|
|
4835
4820
|
*
|
|
4836
|
-
*
|
|
4837
|
-
*
|
|
4838
|
-
*
|
|
4839
|
-
*
|
|
4840
|
-
* - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
|
|
4841
|
-
* installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
|
|
4821
|
+
* 为什么要 `.volatile()`:空 object 会被 volatileForm 过滤掉,顶层标 volatile 才能让
|
|
4822
|
+
* 卡片进镜像。`.volatile()` 是 schemastery 3.18.4 才有的原型方法,直写
|
|
4823
|
+
* `Schema.object({}).volatile()` 在没有它的版本上会抛 TypeError → **整个插件加载
|
|
4824
|
+
* 失败**。所以:无 volatile 就回落普通空 schema(不抛,代价是卡片不出现,并有日志)。
|
|
4842
4825
|
*/
|
|
4843
4826
|
const emptySchema = Schema.object({});
|
|
4844
4827
|
/**
|
|
4845
|
-
*
|
|
4828
|
+
* 设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
|
|
4846
4829
|
* 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
|
|
4847
4830
|
* Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
|
|
4848
4831
|
*/
|
|
@@ -4880,9 +4863,9 @@ function apply(rawContext) {
|
|
|
4880
4863
|
const stateFile = join(dataDir, "state.json");
|
|
4881
4864
|
log(`data dir: ${dataDir}`);
|
|
4882
4865
|
const hostVersion = detectHostVersion(ctx.baseUrl);
|
|
4883
|
-
const
|
|
4884
|
-
|
|
4885
|
-
|
|
4866
|
+
const hostSupported = isHostSupported(ctx.baseUrl);
|
|
4867
|
+
log(`host version: ${hostVersion ?? "未知"} (>=0.2.0: ${hostSupported ? "yes" : "no"})`);
|
|
4868
|
+
if (!hostSupported) ctx.logger.warn(`[dsh-router] 宿主版本 ${hostVersion ?? "未知"} 低于本插件支持的 0.2.0 —— 插件仍会加载,但设置卡片、「最近命中」徽章与工具结果转发会走错路径。请把宿主升到 0.2.0 及以上。`);
|
|
4886
4869
|
const store = new SupplierConfigStore(stateFile);
|
|
4887
4870
|
const credentials = new CredentialStore(join(dataDir, "auths"));
|
|
4888
4871
|
const router = new Router(stateFile, store, log);
|
|
@@ -5050,7 +5033,7 @@ function apply(rawContext) {
|
|
|
5050
5033
|
writeJson(res, 200, {
|
|
5051
5034
|
ok: true,
|
|
5052
5035
|
hostVersion: hostVersion ?? "",
|
|
5053
|
-
lastHitDock,
|
|
5036
|
+
lastHitDock: true,
|
|
5054
5037
|
suppliers: suppliers.map((s) => {
|
|
5055
5038
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
|
|
5056
5039
|
return {
|
|
@@ -5264,6 +5247,39 @@ function apply(rawContext) {
|
|
|
5264
5247
|
requireApiKey: keys.requireApiKey
|
|
5265
5248
|
});
|
|
5266
5249
|
});
|
|
5250
|
+
/**
|
|
5251
|
+
* 把 `router.ext` 表映射成面板要的 `ExtInfo[]`。**GET 与 PATCH 共用**。
|
|
5252
|
+
*
|
|
5253
|
+
* ⚠️ 2026-09-30:op 成功时我复制了一份这段映射来就地回表 —— 两份必然漂移
|
|
5254
|
+
* (改一处只生效一半,本仓反复栽的坑)⇒ 抽成函数,一处定义两处调用。
|
|
5255
|
+
*/
|
|
5256
|
+
function extInfos(table, isEnabled) {
|
|
5257
|
+
return Object.values(table).map((raw) => {
|
|
5258
|
+
const e = raw;
|
|
5259
|
+
if (!e || typeof e.id !== "string" || e.id === "") return void 0;
|
|
5260
|
+
const st = typeof e.getState === "function" ? e.getState() : { ready: false };
|
|
5261
|
+
return {
|
|
5262
|
+
id: e.id,
|
|
5263
|
+
name: e.name ?? e.id,
|
|
5264
|
+
...e.description !== void 0 ? { description: e.description } : {},
|
|
5265
|
+
...e.icon !== void 0 ? { icon: e.icon } : {},
|
|
5266
|
+
enabled: isEnabled(e.id),
|
|
5267
|
+
ready: st?.ready === true,
|
|
5268
|
+
...raw.source === "builtin" ? { source: "builtin" } : {},
|
|
5269
|
+
...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) => ({
|
|
5270
|
+
id: c.id,
|
|
5271
|
+
title: c.title,
|
|
5272
|
+
on: c.on === true,
|
|
5273
|
+
...c.detail !== void 0 ? { detail: c.detail } : {},
|
|
5274
|
+
...typeof c.body === "string" ? { body: c.body } : {},
|
|
5275
|
+
...c.editable === true ? { editable: true } : {},
|
|
5276
|
+
custom: c.custom === true,
|
|
5277
|
+
overridden: c.overridden === true
|
|
5278
|
+
})) } : {},
|
|
5279
|
+
...st?.detail !== void 0 ? { detail: st.detail } : {}
|
|
5280
|
+
};
|
|
5281
|
+
}).filter((e) => e !== void 0);
|
|
5282
|
+
}
|
|
5267
5283
|
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
5268
5284
|
if (req.method === "PATCH") {
|
|
5269
5285
|
let body;
|
|
@@ -5276,6 +5292,7 @@ function apply(rawContext) {
|
|
|
5276
5292
|
});
|
|
5277
5293
|
return;
|
|
5278
5294
|
}
|
|
5295
|
+
let addedId = null;
|
|
5279
5296
|
const ext = typeof body.id === "string" ? exts[body.id] : void 0;
|
|
5280
5297
|
if (!ext) {
|
|
5281
5298
|
writeJson(res, 404, {
|
|
@@ -5284,6 +5301,152 @@ function apply(rawContext) {
|
|
|
5284
5301
|
});
|
|
5285
5302
|
return;
|
|
5286
5303
|
}
|
|
5304
|
+
if (body.op !== void 0) {
|
|
5305
|
+
const op = body.op;
|
|
5306
|
+
if (op === "reorder") {
|
|
5307
|
+
if (!Array.isArray(body.ids)) {
|
|
5308
|
+
writeJson(res, 400, {
|
|
5309
|
+
ok: false,
|
|
5310
|
+
error: "ids must be an array"
|
|
5311
|
+
});
|
|
5312
|
+
return;
|
|
5313
|
+
}
|
|
5314
|
+
if (typeof ext.setControlOrder !== "function" || !ext.setControlOrder(body.ids)) {
|
|
5315
|
+
writeJson(res, 400, {
|
|
5316
|
+
ok: false,
|
|
5317
|
+
error: "排序保存失败"
|
|
5318
|
+
});
|
|
5319
|
+
return;
|
|
5320
|
+
}
|
|
5321
|
+
} else if (op === "remove" || op === "reset") {
|
|
5322
|
+
if (typeof body.controlId !== "string" || body.controlId === "") {
|
|
5323
|
+
writeJson(res, 400, {
|
|
5324
|
+
ok: false,
|
|
5325
|
+
error: "controlId must be a non-empty string"
|
|
5326
|
+
});
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
const fn = op === "remove" ? ext.removeCustomControl : ext.resetControlText;
|
|
5330
|
+
if (typeof fn !== "function" || !fn.call(ext, body.controlId)) {
|
|
5331
|
+
writeJson(res, 400, {
|
|
5332
|
+
ok: false,
|
|
5333
|
+
error: op === "remove" ? "只能删除自定义准则(内置条目请用开关关掉)" : "只能还原内置准则,且它得先被改过"
|
|
5334
|
+
});
|
|
5335
|
+
return;
|
|
5336
|
+
}
|
|
5337
|
+
} else if (op === "add") {
|
|
5338
|
+
if (typeof ext.addCustomControl !== "function") {
|
|
5339
|
+
writeJson(res, 400, {
|
|
5340
|
+
ok: false,
|
|
5341
|
+
error: "extension does not support custom controls"
|
|
5342
|
+
});
|
|
5343
|
+
return;
|
|
5344
|
+
}
|
|
5345
|
+
addedId = ext.addCustomControl(typeof body.title === "string" ? body.title : "", typeof body.body === "string" ? body.body : "");
|
|
5346
|
+
if (addedId === null) {
|
|
5347
|
+
writeJson(res, 400, {
|
|
5348
|
+
ok: false,
|
|
5349
|
+
error: "标题与内容都不能为空"
|
|
5350
|
+
});
|
|
5351
|
+
return;
|
|
5352
|
+
}
|
|
5353
|
+
} else {
|
|
5354
|
+
writeJson(res, 400, {
|
|
5355
|
+
ok: false,
|
|
5356
|
+
error: `unknown op: ${String(op)}`
|
|
5357
|
+
});
|
|
5358
|
+
return;
|
|
5359
|
+
}
|
|
5360
|
+
writeJson(res, 200, addedId === null ? {
|
|
5361
|
+
ok: true,
|
|
5362
|
+
enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
|
|
5363
|
+
} : {
|
|
5364
|
+
ok: true,
|
|
5365
|
+
id: addedId,
|
|
5366
|
+
enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
|
|
5367
|
+
});
|
|
5368
|
+
return;
|
|
5369
|
+
}
|
|
5370
|
+
if (body.title !== void 0 || body.body !== void 0) {
|
|
5371
|
+
if (typeof body.controlId !== "string" || body.controlId === "") {
|
|
5372
|
+
writeJson(res, 400, {
|
|
5373
|
+
ok: false,
|
|
5374
|
+
error: "controlId must be a non-empty string"
|
|
5375
|
+
});
|
|
5376
|
+
return;
|
|
5377
|
+
}
|
|
5378
|
+
if (typeof body.title !== "undefined" && typeof body.title !== "string") {
|
|
5379
|
+
writeJson(res, 400, {
|
|
5380
|
+
ok: false,
|
|
5381
|
+
error: "title must be a string"
|
|
5382
|
+
});
|
|
5383
|
+
return;
|
|
5384
|
+
}
|
|
5385
|
+
if (typeof body.body !== "undefined" && typeof body.body !== "string") {
|
|
5386
|
+
writeJson(res, 400, {
|
|
5387
|
+
ok: false,
|
|
5388
|
+
error: "body must be a string"
|
|
5389
|
+
});
|
|
5390
|
+
return;
|
|
5391
|
+
}
|
|
5392
|
+
if (typeof ext.setControlText !== "function") {
|
|
5393
|
+
writeJson(res, 400, {
|
|
5394
|
+
ok: false,
|
|
5395
|
+
error: "extension controls are not editable"
|
|
5396
|
+
});
|
|
5397
|
+
return;
|
|
5398
|
+
}
|
|
5399
|
+
const patch = {};
|
|
5400
|
+
if (typeof body.title === "string") patch.title = body.title;
|
|
5401
|
+
if (typeof body.body === "string") patch.body = body.body;
|
|
5402
|
+
if (!ext.setControlText(body.controlId, patch)) {
|
|
5403
|
+
writeJson(res, 400, {
|
|
5404
|
+
ok: false,
|
|
5405
|
+
error: "unknown controlId"
|
|
5406
|
+
});
|
|
5407
|
+
return;
|
|
5408
|
+
}
|
|
5409
|
+
writeJson(res, 200, {
|
|
5410
|
+
ok: true,
|
|
5411
|
+
enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
|
|
5412
|
+
});
|
|
5413
|
+
return;
|
|
5414
|
+
}
|
|
5415
|
+
if (body.controlId !== void 0) {
|
|
5416
|
+
if (typeof body.controlId !== "string" || body.controlId === "") {
|
|
5417
|
+
writeJson(res, 400, {
|
|
5418
|
+
ok: false,
|
|
5419
|
+
error: "controlId must be a non-empty string"
|
|
5420
|
+
});
|
|
5421
|
+
return;
|
|
5422
|
+
}
|
|
5423
|
+
if (typeof body.on !== "boolean") {
|
|
5424
|
+
writeJson(res, 400, {
|
|
5425
|
+
ok: false,
|
|
5426
|
+
error: "on must be a boolean"
|
|
5427
|
+
});
|
|
5428
|
+
return;
|
|
5429
|
+
}
|
|
5430
|
+
if (typeof ext.setControl !== "function") {
|
|
5431
|
+
writeJson(res, 400, {
|
|
5432
|
+
ok: false,
|
|
5433
|
+
error: "extension has no controls"
|
|
5434
|
+
});
|
|
5435
|
+
return;
|
|
5436
|
+
}
|
|
5437
|
+
if (!ext.setControl(body.controlId, body.on)) {
|
|
5438
|
+
writeJson(res, 400, {
|
|
5439
|
+
ok: false,
|
|
5440
|
+
error: "unknown controlId"
|
|
5441
|
+
});
|
|
5442
|
+
return;
|
|
5443
|
+
}
|
|
5444
|
+
writeJson(res, 200, {
|
|
5445
|
+
ok: true,
|
|
5446
|
+
enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
|
|
5447
|
+
});
|
|
5448
|
+
return;
|
|
5449
|
+
}
|
|
5287
5450
|
if (typeof body.enabled !== "boolean") {
|
|
5288
5451
|
writeJson(res, 400, {
|
|
5289
5452
|
ok: false,
|
|
@@ -5305,21 +5468,7 @@ function apply(rawContext) {
|
|
|
5305
5468
|
}
|
|
5306
5469
|
writeJson(res, 200, {
|
|
5307
5470
|
ok: true,
|
|
5308
|
-
enhancers:
|
|
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)
|
|
5471
|
+
enhancers: extInfos(exts, (id) => extStore.isEnabled(id))
|
|
5323
5472
|
});
|
|
5324
5473
|
});
|
|
5325
5474
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
@@ -5382,24 +5531,7 @@ function apply(rawContext) {
|
|
|
5382
5531
|
disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
|
|
5383
5532
|
return (await router.combos()).map((c) => ({ id: c.name }));
|
|
5384
5533
|
}));
|
|
5385
|
-
|
|
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
|
-
});
|
|
5534
|
+
log(`settings section driven by exported Config (${settingsNs})`);
|
|
5403
5535
|
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(loopbackBaseURL(() => ctx.webServer?.port), { comboModels: async () => (await router.combos()).map((c) => {
|
|
5404
5536
|
const w = router.comboContextWindow(c);
|
|
5405
5537
|
return {
|
|
@@ -61,11 +61,124 @@ export interface RouterExt {
|
|
|
61
61
|
* 再在下面列一遍就是同一个东西显示两处。
|
|
62
62
|
*/
|
|
63
63
|
readonly source?: 'builtin';
|
|
64
|
+
/**
|
|
65
|
+
* 扩展自带的**子开关**(可选;不声明 = 只有总开关)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么需要:有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如分层
|
|
68
|
+
* 提示词有 14 个准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
|
|
69
|
+
* 要么全不给。
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ **与总开关的关系**:子开关是**叠加**在总开关之上的,总开关关 ⇒ 全部子开关
|
|
72
|
+
* 一起失效(但**不清零**用户的子开关选择)。详情页在总开关关时把它们显示为
|
|
73
|
+
* 禁用而不是隐藏 —— 隐藏会让用户以为它们不存在。
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ **值存哪**:核心**不认识**这些子开关的语义,只负责搬运。落盘一律走
|
|
76
|
+
* `ExtStoreService.writeData`(`data` 抽屉),由扩展自己读回。核心不替扩展
|
|
77
|
+
* 存一份 —— 那样会变成两份可能漂移的真相。
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* 扩展自报的**子开关**列表。
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ **实现方应当每次读都重算**(getter),而不是在构造时算好一个数组快照:
|
|
83
|
+
* 核心每次 `GET /ext` 都读它,面板也靠它刷新。若它是快照,改完文本再刷新
|
|
84
|
+
* 面板拿到的仍是**旧值** —— 用户会看到"保存了、又变回去了"
|
|
85
|
+
* (实测踩过:2026-09-29 第一版用 `CATEGORIES.map(...)`,编辑生效但列表
|
|
86
|
+
* 不跟着变,而注释里还写着"现算不缓存"——**注释与事实不符**,比没写更坏)。
|
|
87
|
+
*/
|
|
88
|
+
readonly controls?: ExtControl[];
|
|
89
|
+
/**
|
|
90
|
+
* 改一个子开关(面板调用;可选 —— 没有 `controls` 就不需要)。
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ **为什么由扩展自己写、核心不代劳**:子开关的落盘形状归扩展所有
|
|
93
|
+
* (本仓是 `data.categories[id]`),核心一旦代写就得认识那个形状 ——
|
|
94
|
+
* 换个扩展就是换个形状,核心会变成「认识所有扩展的私有数据格式」的地方。
|
|
95
|
+
* 核心只负责:把 `{ controlId, on }` 递进来,并把**成功/失败**如实回给面板。
|
|
96
|
+
*
|
|
97
|
+
* @param controlId - 要改的子开关 id。
|
|
98
|
+
* @param on - 目标状态。
|
|
99
|
+
* @returns 落盘结果。返回 false 时核心回 400,且**不写盘**。
|
|
100
|
+
*/
|
|
101
|
+
setControl?(controlId: string, on: boolean): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* 改一个子开关的**文本**(标题 / 正文),可选 —— 不实现就是不可编辑。
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ **与 `setControl` 分开而不是合并**:开关是**布尔**、文本是**字符串**,
|
|
106
|
+
* 合成一个方法就得在里面判类型,面板那边也要问"这次改的是哪个" ——
|
|
107
|
+
* 那是两个独立动作、两种失败原因(未知 id / 空标题)。
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ 落盘形状仍归扩展(本仓是 `data.text[id]`,与开关的 `data.categories[id]`
|
|
110
|
+
* **分开两个键**):开关是"开不开",文本是"写成什么样",混在一个键里
|
|
111
|
+
* 改标题会顺手碰到开关状态。
|
|
112
|
+
*
|
|
113
|
+
* @param controlId - 要改的子开关 id。
|
|
114
|
+
* @param patch - 要覆盖的字段;**未出现的字段保持原样**(部分更新)。
|
|
115
|
+
* @returns 落盘结果。返回 false 时核心回 400,且**不写盘**。
|
|
116
|
+
*/
|
|
117
|
+
setControlText?(controlId: string, patch: {
|
|
118
|
+
title?: string;
|
|
119
|
+
body?: string;
|
|
120
|
+
}): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* 改**展示顺序**(拖动)。落盘一串 id;扩展负责过滤自己不认识的 id。
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ 与前三者都不同:它改的是**列表本身**,不是某一条的内容。
|
|
125
|
+
*/
|
|
126
|
+
setControlOrder?(ids: string[]): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* 新增一条**自建**条目,返回新 id(由扩展生成,避免前端编 id 撞内置)。
|
|
129
|
+
* @returns 新 id;失败(如空标题/空正文)返回 null。
|
|
130
|
+
*/
|
|
131
|
+
addCustomControl?(title: string, body: string): string | null;
|
|
132
|
+
/**
|
|
133
|
+
* 删一条 —— **扩展应当只允许删自建条目**(内置的删除手段是关开关)。
|
|
134
|
+
* @returns 落盘结果;false 时核心回 400 且不写盘。
|
|
135
|
+
*/
|
|
136
|
+
removeCustomControl?(controlId: string): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* 还原一条的文本(丢掉覆盖,回到扩展自带的内容)。
|
|
139
|
+
* 自建条目**没有"自带内容"**可回退,扩展应当对它返回 false。
|
|
140
|
+
*/
|
|
141
|
+
resetControlText?(controlId: string): boolean;
|
|
64
142
|
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
65
143
|
getState(): ExtState;
|
|
66
144
|
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
67
145
|
dispose?(): void;
|
|
68
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* 一个可独立开关的子项(如准则的某一条)。
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ `id` 是**持久化键**(落进 `data` 抽屉),**改名会让用户已保存的选择丢失**。
|
|
151
|
+
*/
|
|
152
|
+
export interface ExtControl {
|
|
153
|
+
/** 稳定 id(持久化键)。 */
|
|
154
|
+
readonly id: string;
|
|
155
|
+
/** 面板显示名。 */
|
|
156
|
+
readonly title: string;
|
|
157
|
+
/** 当前是否开启(扩展自算:默认值 + 用户已存的选择)。 */
|
|
158
|
+
readonly on: boolean;
|
|
159
|
+
/** 一句话说明这一条管什么(面板副行,可选)。 */
|
|
160
|
+
readonly detail?: string;
|
|
161
|
+
/**
|
|
162
|
+
* 这一条的**原文**(可选)—— 面板点开即可读到。
|
|
163
|
+
*
|
|
164
|
+
* 为什么值得加:不给出原文的话,用户是在**盲切** —— 只看到「结构」「交付」
|
|
165
|
+
* 这样的名字,不知道它到底写了什么,也无从判断该不该关掉这一条。给出原文
|
|
166
|
+
* ⇒「看内容 → 决定开关」这个动作才成立。
|
|
167
|
+
*/
|
|
168
|
+
readonly body?: string;
|
|
169
|
+
/**
|
|
170
|
+
* 这一条的**文本可被编辑**(可选;缺省 = 只读)。
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ 为什么要显式声明而不是「有 `setControlText` 就可编辑」:扩展实现了写入
|
|
173
|
+
* 能力但某些条目**刻意锁住**(例如标题是分类 id 的可读形式,改了会和
|
|
174
|
+
* `data` 里的键失去对应关系)。由扩展逐条说了算。
|
|
175
|
+
*/
|
|
176
|
+
readonly editable?: boolean;
|
|
177
|
+
/** 自建条目(可删除)。缺省 = 内置(不可删除,只有开关与还原)。 */
|
|
178
|
+
readonly custom?: boolean;
|
|
179
|
+
/** 文本被用户改过(决定「还原」按钮是否可点)。 */
|
|
180
|
+
readonly overridden?: boolean;
|
|
181
|
+
}
|
|
69
182
|
/** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
|
|
70
183
|
export interface ExtInfo {
|
|
71
184
|
id: string;
|