dsh-router-core 0.4.1 → 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 +18 -15
- package/cordis.patch.yml +10 -0
- package/docs/ext.md +65 -3
- 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 +298 -119
- 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-base-url.d.ts +31 -0
- package/lib/types/host-version.d.ts +7 -8
- package/lib/types/index.d.ts +1 -1
- package/lib/types/llm/adapter.d.ts +6 -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-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/host-version.test.ts +21 -28
- package/src/host-version.ts +39 -39
- package/src/index.ts +230 -96
- package/src/llm/adapter.test.ts +19 -42
- package/src/llm/adapter.ts +32 -50
- package/src/shared.ts +34 -0
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Host half of dsh-router — a simplified 9router.
|
|
3
3
|
*
|
|
4
4
|
* dsh-router IS the router: it exposes an OpenAI-compatible `/v1/*` endpoint
|
|
5
|
-
* on the DSH web server (http://
|
|
5
|
+
* on the DSH web server (http://127.0.0.1:<宿主监听端口>/v1 — 端口随启动通道
|
|
6
|
+
* 变,`dsh web` 默认 3080,见 host-base-url.ts), and routes requests to
|
|
6
7
|
* internal suppliers.
|
|
7
8
|
*
|
|
8
9
|
* Routes:
|
|
@@ -42,9 +43,10 @@ import type { SupplierEnv, SupplierModule, SupplierRegistry } from './suppliers/
|
|
|
42
43
|
import { SupplierConfigStore } from './supplier-config.ts'
|
|
43
44
|
import { CredentialStore } from './credential-store.ts'
|
|
44
45
|
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
45
|
-
import {
|
|
46
|
+
import { loopbackBaseURL } from './host-base-url.ts'
|
|
47
|
+
import { detectHostVersion, isHostSupported } from './host-version.ts'
|
|
46
48
|
import { ExtStore } from './ext/store.ts'
|
|
47
|
-
import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
49
|
+
import type { ExtControl, ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
52
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -56,28 +58,25 @@ export const name = 'dsh-router-core'
|
|
|
56
58
|
export const inject = ['webServer', 'llm']
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
|
-
* `Config` 导出:让 Router
|
|
61
|
+
* `Config` 导出:让 Router 卡片进设置-模型的镜像(0.2.0 的收录链是「插件导出的
|
|
62
|
+
* Config + 配置树行 id」)。
|
|
60
63
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
|
|
67
|
-
* 能力检测恰好就是**对**的判据。
|
|
64
|
+
* **这处保留能力检测,不看宿主版本号** —— 因为它是**模块顶层导出**,loader 一 import
|
|
65
|
+
* 就求值,那时还没有 `ctx.baseUrl`;而 schemastery 是本插件的**普通依赖**,解析到的
|
|
66
|
+
* 是插件自己 node_modules 里那一份,跟宿主实际加载的版本无关。既然这里拿不到宿主
|
|
67
|
+
* 版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 —— 能力检测
|
|
68
|
+
* 恰好就是**对**的判据。
|
|
68
69
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
|
|
74
|
-
* installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
|
|
70
|
+
* 为什么要 `.volatile()`:空 object 会被 volatileForm 过滤掉,顶层标 volatile 才能让
|
|
71
|
+
* 卡片进镜像。`.volatile()` 是 schemastery 3.18.4 才有的原型方法,直写
|
|
72
|
+
* `Schema.object({}).volatile()` 在没有它的版本上会抛 TypeError → **整个插件加载
|
|
73
|
+
* 失败**。所以:无 volatile 就回落普通空 schema(不抛,代价是卡片不出现,并有日志)。
|
|
75
74
|
*/
|
|
76
75
|
const emptySchema = Schema.object({})
|
|
77
76
|
const hasVolatile = typeof (emptySchema as { volatile?: unknown }).volatile === 'function'
|
|
78
77
|
|
|
79
78
|
/**
|
|
80
|
-
*
|
|
79
|
+
* 设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
|
|
81
80
|
* 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
|
|
82
81
|
* Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
|
|
83
82
|
*/
|
|
@@ -91,6 +90,12 @@ interface WebServerRoute {
|
|
|
91
90
|
}
|
|
92
91
|
interface WebServer {
|
|
93
92
|
register: (route: WebServerRoute) => () => void
|
|
93
|
+
/**
|
|
94
|
+
* 宿主**实际监听**的端口(配置 port=0 时是 OS 分配的那个)。
|
|
95
|
+
* listen 之前为 undefined —— adapter 的端点按请求现读它,见 host-base-url.ts
|
|
96
|
+
* 与 issue #9。
|
|
97
|
+
*/
|
|
98
|
+
readonly port?: number
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/** Minimal shape of the llm service faces used here (设置-模型 提供方/模型目录). */
|
|
@@ -124,29 +129,6 @@ interface Llm {
|
|
|
124
129
|
registerAdapter: (providers: readonly string[], adapter: unknown) => () => void
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
/**
|
|
128
|
-
* 设置服务(`ctx.settings`,dsh-settings)的最小切面。0.1.5 用 `installSection`
|
|
129
|
-
* 注册命名空间;0.1.7 已移除该方法、改由插件导出的 Config 驱动 —— 故声明为可选,
|
|
130
|
-
* 调用处做能力检测(不是版本分支)。dsh-settings 是宿主注入的 peer service,不深绑类型。
|
|
131
|
-
*/
|
|
132
|
-
interface SettingsServiceFace {
|
|
133
|
-
installSection?: (
|
|
134
|
-
owner: CordisContext,
|
|
135
|
-
ns: string,
|
|
136
|
-
schema: unknown,
|
|
137
|
-
entry: unknown,
|
|
138
|
-
hooks: { setSource: (current: () => unknown) => void; onChange: () => void },
|
|
139
|
-
) => void
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* cordis Context 的最小切面。`installSection(owner)` 只会对 owner 调
|
|
144
|
-
* `ctx.effect(...)` 并在卸载时读 `fiber.state`,所以这里只声明 `effect`。
|
|
145
|
-
*/
|
|
146
|
-
interface CordisContext {
|
|
147
|
-
effect: (fn: () => () => void, label?: string) => unknown
|
|
148
|
-
}
|
|
149
|
-
|
|
150
132
|
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
151
133
|
const payload = JSON.stringify(body)
|
|
152
134
|
res.writeHead(status, {
|
|
@@ -197,12 +179,18 @@ export function apply(rawContext: unknown): void {
|
|
|
197
179
|
const dataDir = dataDirOf(ctx.baseUrl)
|
|
198
180
|
const stateFile = join(dataDir, 'state.json')
|
|
199
181
|
log(`data dir: ${dataDir}`)
|
|
200
|
-
//
|
|
201
|
-
//
|
|
182
|
+
// 宿主版本:只用于**报版本**与**守地板**(不再是兼容分叉的判据,见 host-version.ts)。
|
|
183
|
+
// 低于 0.2.0 的宿主上本插件会静默走错路径,所以这里必须出声,把「插件坏了」和
|
|
184
|
+
// 「宿主太老」区分开 —— 否则用户只能对着症状猜。
|
|
202
185
|
const hostVersion = detectHostVersion(ctx.baseUrl)
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
186
|
+
const hostSupported = isHostSupported(ctx.baseUrl)
|
|
187
|
+
log(`host version: ${hostVersion ?? '未知'} (>=0.2.0: ${hostSupported ? 'yes' : 'no'})`)
|
|
188
|
+
if (!hostSupported) {
|
|
189
|
+
ctx.logger.warn(
|
|
190
|
+
`[dsh-router] 宿主版本 ${hostVersion ?? '未知'} 低于本插件支持的 0.2.0 —— 插件仍会加载,` +
|
|
191
|
+
'但设置卡片、「最近命中」徽章与工具结果转发会走错路径。请把宿主升到 0.2.0 及以上。',
|
|
192
|
+
)
|
|
193
|
+
}
|
|
206
194
|
const store = new SupplierConfigStore(stateFile)
|
|
207
195
|
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
208
196
|
const router = new Router(stateFile, store, log)
|
|
@@ -395,10 +383,11 @@ export function apply(rawContext: unknown): void {
|
|
|
395
383
|
const { suppliers } = router.status()
|
|
396
384
|
writeJson(res, 200, {
|
|
397
385
|
ok: true,
|
|
398
|
-
//
|
|
399
|
-
//
|
|
386
|
+
// 宿主版本(客户端/排障用)。`lastHitDock` 是给 0.1.5→0.1.7 那个位置差异留的
|
|
387
|
+
// 能力位,0.2.0 起恒为 true —— 保留字段形状是为了不打断已有读取方,客户端
|
|
388
|
+
// 已不再依赖它做分支(见 client/index.tsx)。
|
|
400
389
|
hostVersion: hostVersion ?? '',
|
|
401
|
-
lastHitDock:
|
|
390
|
+
lastHitDock: true,
|
|
402
391
|
suppliers: suppliers.map((s) => {
|
|
403
392
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
|
|
404
393
|
return {
|
|
@@ -582,21 +571,198 @@ export function apply(rawContext: unknown): void {
|
|
|
582
571
|
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
583
572
|
})
|
|
584
573
|
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* 把 `router.ext` 表映射成面板要的 `ExtInfo[]`。**GET 与 PATCH 共用**。
|
|
577
|
+
*
|
|
578
|
+
* ⚠️ 2026-09-30:op 成功时我复制了一份这段映射来就地回表 —— 两份必然漂移
|
|
579
|
+
* (改一处只生效一半,本仓反复栽的坑)⇒ 抽成函数,一处定义两处调用。
|
|
580
|
+
*/
|
|
581
|
+
function extInfos(table: Record<string, unknown>, isEnabled: (id: string) => boolean): ExtInfo[] {
|
|
582
|
+
// ⚠️ **必须用参数 `isEnabled`,不能抓 `extStore`**(2026-09-30 实测的卡死):
|
|
583
|
+
// 本函数是**模块顶层**,而 `extStore` 是 `apply()` 里的局部变量
|
|
584
|
+
// ⇒ 顶层函数引用它会 `extStore is not defined`。而这个异常发生在
|
|
585
|
+
// 启动/请求路径上被吞掉,表现是**接口一直转圈、不报错** —— 最坏的一种。
|
|
586
|
+
// (我抽出这个函数时加了参数、却忘了把函数体里的引用换掉。)
|
|
587
|
+
|
|
588
|
+
return Object.values(table as RouterExtService)
|
|
589
|
+
.map((raw): ExtInfo | undefined => {
|
|
590
|
+
const e = raw as RouterExt
|
|
591
|
+
if (!e || typeof e.id !== 'string' || e.id === '') return undefined
|
|
592
|
+
const st = typeof e.getState === 'function' ? e.getState() : { ready: false }
|
|
593
|
+
return {
|
|
594
|
+
id: e.id,
|
|
595
|
+
name: e.name ?? e.id,
|
|
596
|
+
...(e.description !== undefined ? { description: e.description } : {}),
|
|
597
|
+
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
598
|
+
enabled: isEnabled(e.id),
|
|
599
|
+
ready: st?.ready === true,
|
|
600
|
+
// 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
|
|
601
|
+
...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
|
|
602
|
+
// 子开关:扩展自报,核心只搬运(形状归扩展,见 ExtControl 的注释)。
|
|
603
|
+
// ⚠️ **必须过滤掉形状不对的条目**:落盘的 controls 不可信,一条
|
|
604
|
+
// `undefined` 的 title 会让面板渲染出空行且**不报错**(假绿)。
|
|
605
|
+
...(Array.isArray(e.controls) && e.controls.length > 0
|
|
606
|
+
? {
|
|
607
|
+
controls: e.controls
|
|
608
|
+
.filter((c): c is ExtControl =>
|
|
609
|
+
!!c && typeof c.id === 'string' && c.id !== '' && typeof c.title === 'string')
|
|
610
|
+
.map((c) => ({ id: c.id, title: c.title, on: c.on === true, ...(c.detail !== undefined ? { detail: c.detail } : {}), ...(typeof c.body === 'string' ? { body: c.body } : {}), ...(c.editable === true ? { editable: true } : {}), custom: c.custom === true, overridden: c.overridden === true })),
|
|
611
|
+
}
|
|
612
|
+
: {}),
|
|
613
|
+
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
.filter((e): e is ExtInfo => e !== undefined)
|
|
617
|
+
}
|
|
585
618
|
// ---- 扩展 (Ext):扩展器列表 + 开关 ----
|
|
586
619
|
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
587
620
|
if (req.method === 'PATCH') {
|
|
588
|
-
|
|
621
|
+
// ⚠️ `on` 与 `enabled` **都声明为 unknown**:请求体不可信,真正判类型的地方
|
|
622
|
+
// 在下面(`typeof … !== 'boolean'` ⇒ 400)。这里只是让它在类型上可访问。
|
|
623
|
+
let body: { id?: unknown; enabled?: unknown; controlId?: unknown; on?: unknown; title?: unknown; body?: unknown; op?: unknown; ids?: unknown }
|
|
589
624
|
try {
|
|
590
|
-
body = JSON.parse(await readBody(req, 64 << 10)) as
|
|
625
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as typeof body
|
|
591
626
|
} catch {
|
|
592
627
|
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
593
628
|
return
|
|
594
629
|
}
|
|
630
|
+
// `addedId` 只给 add 用:面板要靠它定位刚建的那条。声明放在 PATCH 顶层,
|
|
631
|
+
// 因为**成功路径会落到末尾统一响应**(op 块内声明的话末尾够不着)。
|
|
632
|
+
let addedId: string | null = null
|
|
595
633
|
const ext = typeof body.id === 'string' ? exts[body.id] as RouterExtService[string] : undefined
|
|
596
634
|
if (!ext) {
|
|
597
635
|
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
598
636
|
return
|
|
599
637
|
}
|
|
638
|
+
// ---- 子开关的四个列表级动作(`op`)----
|
|
639
|
+
//
|
|
640
|
+
// 与单条动作(开关/文本)**分开**:它们改的是**列表本身**(顺序、增、删),
|
|
641
|
+
// 不是一个字段。合在一起会让面板每次都得问"这次改的是哪一层"。
|
|
642
|
+
if (body.op !== undefined) {
|
|
643
|
+
const op = body.op
|
|
644
|
+
// ⚠️⚠️ **成功不早退**(2026-09-30 实测修的真 bug):原来四个分支各自
|
|
645
|
+
// `writeJson(res, 200, {ok:true}); return` —— **不回 `enhancers`**。
|
|
646
|
+
// 客户端 `act()` 靠回发的整张表刷新 ⇒ 增删改/排序之后**界面不更新**
|
|
647
|
+
// (用户看着像没生效,刷新一下又"变了")。
|
|
648
|
+
// ⇒ 失败早退(400),**成功一律落到末尾**的统一响应,那里会带上新列表。
|
|
649
|
+
// 这是旧的两个分支(开关 / 文本)本来就有的行为,我加新分支时漏了。
|
|
650
|
+
//
|
|
651
|
+
if (op === 'reorder') {
|
|
652
|
+
if (!Array.isArray(body.ids)) {
|
|
653
|
+
writeJson(res, 400, { ok: false, error: 'ids must be an array' })
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
if (typeof ext.setControlOrder !== 'function' || !ext.setControlOrder(body.ids as string[])) {
|
|
657
|
+
writeJson(res, 400, { ok: false, error: '排序保存失败' })
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
} else if (op === 'remove' || op === 'reset') {
|
|
661
|
+
if (typeof body.controlId !== 'string' || body.controlId === '') {
|
|
662
|
+
writeJson(res, 400, { ok: false, error: 'controlId must be a non-empty string' })
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
const fn = op === 'remove' ? ext.removeCustomControl : ext.resetControlText
|
|
666
|
+
if (typeof fn !== 'function' || !fn.call(ext, body.controlId)) {
|
|
667
|
+
// ⚠️ **失败要说得清是哪个**:`remove` 拒的是内置条目、`reset` 拒的是
|
|
668
|
+
// 自建条目或没改过的 —— 一句 "op failed" 会让人以为是 bug。
|
|
669
|
+
writeJson(res, 400, {
|
|
670
|
+
ok: false,
|
|
671
|
+
error: op === 'remove'
|
|
672
|
+
? '只能删除自定义准则(内置条目请用开关关掉)'
|
|
673
|
+
: '只能还原内置准则,且它得先被改过',
|
|
674
|
+
})
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
} else if (op === 'add') {
|
|
678
|
+
if (typeof ext.addCustomControl !== 'function') {
|
|
679
|
+
writeJson(res, 400, { ok: false, error: 'extension does not support custom controls' })
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
addedId = ext.addCustomControl(
|
|
683
|
+
typeof body.title === 'string' ? body.title : '',
|
|
684
|
+
typeof body.body === 'string' ? body.body : '',
|
|
685
|
+
)
|
|
686
|
+
if (addedId === null) {
|
|
687
|
+
writeJson(res, 400, { ok: false, error: '标题与内容都不能为空' })
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
writeJson(res, 400, { ok: false, error: `unknown op: ${String(op)}` })
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
// ✅ 成功:回整张表(与 GET 走**同一个**构造函数,不复制一份)。
|
|
695
|
+
// ⚠️ 必须带 `enhancers`:客户端 `act()` 靠它刷新;少了它,界面停在旧状态
|
|
696
|
+
// (2026-09-30 实测:增删改/排序后看起来"没生效",刷新一下又变了)。
|
|
697
|
+
writeJson(res, 200, addedId === null
|
|
698
|
+
? { ok: true, enhancers: extInfos(exts, (id) => extStore.isEnabled(id)) }
|
|
699
|
+
: { ok: true, id: addedId, enhancers: extInfos(exts, (id) => extStore.isEnabled(id)) })
|
|
700
|
+
return
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---- 子开关文本(`{ controlId, title?, body? }`)----
|
|
705
|
+
//
|
|
706
|
+
// 与下面的开关**分开判**:一个是字符串、一个是布尔,面板问的也是两个问题。
|
|
707
|
+
// 判据是**有没有带 title/body**,而不是「controlId 在不在」——后者两个
|
|
708
|
+
// 分支都会满足,分不开。
|
|
709
|
+
if (body.title !== undefined || body.body !== undefined) {
|
|
710
|
+
if (typeof body.controlId !== 'string' || body.controlId === '') {
|
|
711
|
+
writeJson(res, 400, { ok: false, error: 'controlId must be a non-empty string' })
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
if (typeof body.title !== 'undefined' && typeof body.title !== 'string') {
|
|
715
|
+
writeJson(res, 400, { ok: false, error: 'title must be a string' })
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
if (typeof body.body !== 'undefined' && typeof body.body !== 'string') {
|
|
719
|
+
writeJson(res, 400, { ok: false, error: 'body must be a string' })
|
|
720
|
+
return
|
|
721
|
+
}
|
|
722
|
+
if (typeof ext.setControlText !== 'function') {
|
|
723
|
+
writeJson(res, 400, { ok: false, error: 'extension controls are not editable' })
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
const patch: { title?: string; body?: string } = {}
|
|
727
|
+
if (typeof body.title === 'string') patch.title = body.title
|
|
728
|
+
if (typeof body.body === 'string') patch.body = body.body
|
|
729
|
+
if (!ext.setControlText(body.controlId, patch)) {
|
|
730
|
+
writeJson(res, 400, { ok: false, error: 'unknown controlId' })
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
// ✅ 成功:回整张表(下面统一出口那句够不着这里 —— 它在别的 `if` 里,
|
|
734
|
+
// 靠 `return` 出去会**不写响应** ⇒ 请求挂死、开关卡在 loading)。
|
|
735
|
+
writeJson(res, 200, { ok: true, enhancers: extInfos(exts, (id) => extStore.isEnabled(id)) })
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ---- 子开关(`{ controlId, on }`)----
|
|
740
|
+
//
|
|
741
|
+
// 与总开关**分开判**:总开关管「这个扩展在不在」,子开关管「它自己的行为细节」。
|
|
742
|
+
// 混在一个 PATCH 里会让面板多问一句「这次到底改的是哪个」—— 那是两个独立动作。
|
|
743
|
+
if (body.controlId !== undefined) {
|
|
744
|
+
if (typeof body.controlId !== 'string' || body.controlId === '') {
|
|
745
|
+
writeJson(res, 400, { ok: false, error: 'controlId must be a non-empty string' })
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
if (typeof body.on !== 'boolean') {
|
|
749
|
+
writeJson(res, 400, { ok: false, error: 'on must be a boolean' })
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
if (typeof ext.setControl !== 'function') {
|
|
753
|
+
writeJson(res, 400, { ok: false, error: 'extension has no controls' })
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
// 扩展自写(落盘形状归它,核心不代劳 —— 见 ExtControl.setControl 注释)。
|
|
757
|
+
// 写失败**如实回错**且不改盘:面板据此把开关弹回去,而不是假装成功。
|
|
758
|
+
if (!ext.setControl(body.controlId, body.on)) {
|
|
759
|
+
writeJson(res, 400, { ok: false, error: 'unknown controlId' })
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
writeJson(res, 200, { ok: true, enhancers: extInfos(exts, (id) => extStore.isEnabled(id)) })
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
|
|
600
766
|
if (typeof body.enabled !== 'boolean') {
|
|
601
767
|
writeJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
|
|
602
768
|
return
|
|
@@ -614,24 +780,11 @@ export function apply(rawContext: unknown): void {
|
|
|
614
780
|
// 开关归核心持久化。
|
|
615
781
|
extStore.setEnabled(ext.id, body.enabled)
|
|
616
782
|
}
|
|
617
|
-
const list: ExtInfo[] =
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
return {
|
|
623
|
-
id: e.id,
|
|
624
|
-
name: e.name ?? e.id,
|
|
625
|
-
...(e.description !== undefined ? { description: e.description } : {}),
|
|
626
|
-
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
627
|
-
enabled: extStore.isEnabled(e.id),
|
|
628
|
-
ready: st?.ready === true,
|
|
629
|
-
// 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
|
|
630
|
-
...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
|
|
631
|
-
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
632
|
-
}
|
|
633
|
-
})
|
|
634
|
-
.filter((e): e is ExtInfo => e !== undefined)
|
|
783
|
+
const list: ExtInfo[] = extInfos(exts, (id) => extStore.isEnabled(id))
|
|
784
|
+
// ⚠️⚠️ **这行是 GET 的唯一出口**(2026-09-30 实测的「一直加载中」):
|
|
785
|
+
// 上一轮我以为「成功路径统一落到末尾响应」就把它删了,结果**没有任何地方
|
|
786
|
+
// 写响应** ⇒ 请求永不结束 ⇒ 面板一直转圈。
|
|
787
|
+
// 症状极其恶劣:不报错、不 500、就挂在那儿。
|
|
635
788
|
writeJson(res, 200, { ok: true, enhancers: list })
|
|
636
789
|
})
|
|
637
790
|
|
|
@@ -712,37 +865,18 @@ export function apply(rawContext: unknown): void {
|
|
|
712
865
|
// Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
|
|
713
866
|
// 「路由系统」面板;设置-模型 这边必须让 settingsNs 在设置镜像里能解析出来
|
|
714
867
|
// (见 dsh-client-ui-settings-models 的 configurable 过滤),否则卡片不出现。
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
// 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
|
|
719
|
-
const routerSettingsSchema = Schema.object({})
|
|
720
|
-
if (isHost017Plus(ctx.baseUrl)) {
|
|
721
|
-
log(`settings section driven by exported Config (${settingsNs})`)
|
|
722
|
-
} else {
|
|
723
|
-
ctx.inject(['settings'], (sctx: unknown) => {
|
|
724
|
-
const settings = (sctx as { settings?: SettingsServiceFace }).settings
|
|
725
|
-
if (settings === undefined) {
|
|
726
|
-
log('settings service absent — skip Router settings-section registration')
|
|
727
|
-
return
|
|
728
|
-
}
|
|
729
|
-
if (settings.installSection === undefined) {
|
|
730
|
-
// 版本说该走 installSection 但它不在(异常宿主):只记日志,不抛。
|
|
731
|
-
log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`)
|
|
732
|
-
return
|
|
733
|
-
}
|
|
734
|
-
settings.installSection(rawContext as CordisContext, settingsNs, routerSettingsSchema, {}, {
|
|
735
|
-
setSource: () => {},
|
|
736
|
-
onChange: () => {},
|
|
737
|
-
})
|
|
738
|
-
log(`Router settings section registered (${settingsNs})`)
|
|
739
|
-
})
|
|
740
|
-
}
|
|
868
|
+
// 0.2.0 的镜像行来自**顶部导出的 Config**(空 object + `.volatile()`,ns = 行 id),
|
|
869
|
+
// 这里无需任何注册动作 —— 0.1.5 那条 `settings.installSection` 路径已随兼容分叉删除。
|
|
870
|
+
log(`settings section driven by exported Config (${settingsNs})`)
|
|
741
871
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
742
872
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
743
873
|
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
|
744
874
|
// 是宿主注入的 service,插件不直接 import 它,只依赖 duck-typed 切面)。
|
|
745
|
-
|
|
875
|
+
// 端点**按宿主实际监听端口现算**:`/v1` 路由注册在宿主 webServer 上,端口由
|
|
876
|
+
// 启动通道给(dsh web 默认 3080,桌面端/port=0 时不是),写死 3080 会让
|
|
877
|
+
// Router 在那些宿主上每一轮都 `fetch failed`(issue #9)。
|
|
878
|
+
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter(
|
|
879
|
+
loopbackBaseURL(() => ctx.webServer?.port), {
|
|
746
880
|
comboModels: async () => (await router.combos()).map((c) => {
|
|
747
881
|
const w = router.comboContextWindow(c)
|
|
748
882
|
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -473,17 +473,17 @@ test('wire:reasoningEffort 为 none/off 时删字段,且无请求时不硬
|
|
|
473
473
|
test('wire:空工具结果发空串,不伪造 (no output) 字面量', async () => {
|
|
474
474
|
// 模型会以为工具真打印了那句话 —— 对「bash 无输出」是误导。
|
|
475
475
|
const body = await captureBody({
|
|
476
|
-
messages: [{ role: '
|
|
476
|
+
messages: [{ role: 'tool', toolCallId: 'c1', content: [] }],
|
|
477
477
|
})
|
|
478
478
|
const tool = (body.messages as Array<{ role: string; content: string }>).find((m) => m.role === 'tool')
|
|
479
479
|
assert.equal(tool?.content, '', '空结果就该是空串')
|
|
480
480
|
})
|
|
481
481
|
|
|
482
|
-
// 0.
|
|
483
|
-
// ContentBlockMap
|
|
482
|
+
// 0.2.0 消息模型:tool 结果是**独立 role:"tool" 消息**、toolCallId 挂消息级,
|
|
483
|
+
// ContentBlockMap 里**没有** 'tool-result' 块。必须原样发 role:"tool",否则工具结果被
|
|
484
484
|
// 序列化成 role:"user"、夹在 assistant(tool_calls) 与 tool 之间,上游判定失配 → 400
|
|
485
|
-
// code=11148
|
|
486
|
-
test('wire
|
|
485
|
+
// code=11148。
|
|
486
|
+
test('wire:独立 role:"tool" 消息序列化为 role:"tool",正确带 tool_call_id', async () => {
|
|
487
487
|
const body = await captureBody({
|
|
488
488
|
messages: [
|
|
489
489
|
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
@@ -492,13 +492,13 @@ test('wire:0.1.7+ 独立 role:"tool" 消息序列化为 role:"tool",正确
|
|
|
492
492
|
})
|
|
493
493
|
const messages = body.messages as Array<{ role: string; tool_call_id?: string; content: unknown }>
|
|
494
494
|
const tool = messages.find((m) => m.role === 'tool')
|
|
495
|
-
assert.ok(tool, '
|
|
495
|
+
assert.ok(tool, 'tool 消息必须原样发 role:"tool"')
|
|
496
496
|
assert.equal(tool.tool_call_id, 'c1', 'tool_call_id 取自消息级 toolCallId')
|
|
497
497
|
assert.equal(tool.content, 'out', '纯文本重组为字符串')
|
|
498
498
|
assert.equal(messages.filter((m) => m.role === 'user').length, 0, '不容许被误序列化成 user 消息')
|
|
499
499
|
})
|
|
500
500
|
|
|
501
|
-
test('wire
|
|
501
|
+
test('wire:独立 role:"tool" 消息不带图时不在其后插 user(图)', async () => {
|
|
502
502
|
// 图片才攒批补 user(图);纯文本 tool 后面绝不能冒出 user,配对接续必须完整。
|
|
503
503
|
const body = await captureBody({
|
|
504
504
|
messages: [
|
|
@@ -516,7 +516,7 @@ test('wire:0.1.7+ 独立 role:"tool" 消息不带图时不在其后插 user(
|
|
|
516
516
|
}
|
|
517
517
|
})
|
|
518
518
|
|
|
519
|
-
test('wire
|
|
519
|
+
test('wire:空 tool 结果发空串(不伪造字面量)', async () => {
|
|
520
520
|
const body = await captureBody({
|
|
521
521
|
messages: [{ role: 'tool', toolCallId: 'c2', content: [] }],
|
|
522
522
|
})
|
|
@@ -524,34 +524,11 @@ test('wire:0.1.7+ 空 tool 结果发空串(不伪造字面量)', async ()
|
|
|
524
524
|
assert.equal(tool?.content, '', '空结果该是空串')
|
|
525
525
|
})
|
|
526
526
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
* (user 消息里的 'tool-result' 块)。所以 adapter 不能按版本号判形态对错,
|
|
533
|
-
* 只能按形状分派;这条锁死两种形状在**同一份 adapter**下产物一致。
|
|
534
|
-
*/
|
|
535
|
-
test('wire:tool-result 新/旧两种形态都序列化为 role:"tool"(按形状分派,与版本无关)', async () => {
|
|
536
|
-
const newShapeMsgs = [
|
|
537
|
-
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
538
|
-
{ role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] },
|
|
539
|
-
]
|
|
540
|
-
const oldShapeMsgs = [
|
|
541
|
-
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
542
|
-
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] }] },
|
|
543
|
-
]
|
|
544
|
-
// 新形态:原样发 role:'tool'
|
|
545
|
-
const n = await captureBody({ messages: newShapeMsgs })
|
|
546
|
-
const nt = (n.messages as Array<{ role: string; tool_call_id?: string }>).find((m) => m.role === 'tool')
|
|
547
|
-
assert.ok(nt, '新形态应发 role:"tool"')
|
|
548
|
-
assert.equal(nt.tool_call_id, 'c1')
|
|
549
|
-
// 旧形态:由 'tool-result' 块转出 role:'tool'(同样带 tool_call_id)
|
|
550
|
-
const o = await captureBody({ messages: oldShapeMsgs })
|
|
551
|
-
const ot = (o.messages as Array<{ role: string; tool_call_id?: string }>).find((m) => m.role === 'tool')
|
|
552
|
-
assert.ok(ot, '旧形态也应转出 role:"tool"')
|
|
553
|
-
assert.equal(ot.tool_call_id, 'c1')
|
|
554
|
-
})
|
|
527
|
+
// 这里曾有一条「tool-result 新/旧两种形态都序列化成 role:tool」的用例,随 0.1.x
|
|
528
|
+
// 兼容一起删除:0.2.0 的 ContentBlockMap 里已无 'tool-result' 块,且 v3→v4 会话迁移
|
|
529
|
+
// 主动拒绝该包装(老会话要么被迁移展开、要么拒绝打开),这条形态到不了 adapter。
|
|
530
|
+
// 剩下的新形态已由上面「独立 role:"tool" 消息…」那条锁死。删它的理由与事实出处见
|
|
531
|
+
// src/llm/adapter.ts 的对应注释与 src/host-version.ts。
|
|
555
532
|
|
|
556
533
|
/**
|
|
557
534
|
* 为什么要有这一条:只不发 `block-end` **并不能**拦住残缺调用。
|
|
@@ -878,8 +855,8 @@ test('wire:一步两个 read_image(每条一个 user 消息,同现场)
|
|
|
878
855
|
{ type: 'tool-call', id: 'c2', name: 'read_image', arguments: '{}' },
|
|
879
856
|
],
|
|
880
857
|
},
|
|
881
|
-
{ role: '
|
|
882
|
-
{ role: '
|
|
858
|
+
{ role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'tree_assembly.png' }, { type: 'image', attachment: ref }] },
|
|
859
|
+
{ role: 'tool', toolCallId: 'c2', content: [{ type: 'text', text: 'tree_part.png' }, { type: 'image', attachment: ref }] },
|
|
883
860
|
],
|
|
884
861
|
}, fakeAttachments())
|
|
885
862
|
const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }>; content: unknown }>
|
|
@@ -905,7 +882,7 @@ test('wire:一步两个 read_image(每条一个 user 消息,同现场)
|
|
|
905
882
|
})
|
|
906
883
|
|
|
907
884
|
/**
|
|
908
|
-
* 为什么要有这一组:`read_image` 的结果是**带图的 tool
|
|
885
|
+
* 为什么要有这一组:`read_image` 的结果是**带图的 tool 消息**。适配器曾把
|
|
909
886
|
* 图片和文本一起提成一条 `user` 消息、排在 `tool` 消息**之前**,于是 wire 里
|
|
910
887
|
* 出现 `assistant(tool_calls) → user(image) → tool`。上游判定 tool_call 与
|
|
911
888
|
* tool_result 失配,回 400 网关码 11148「tool calls and tool results do not
|
|
@@ -946,17 +923,17 @@ function assertToolPairingIntact(messages: Array<{ role: string; tool_call_id?:
|
|
|
946
923
|
}
|
|
947
924
|
}
|
|
948
925
|
|
|
949
|
-
test('wire:纯文本 tool
|
|
926
|
+
test('wire:纯文本 tool 消息不因图片逻辑改变顺序(tool 紧跟 assistant)', async () => {
|
|
950
927
|
const body = await captureBodyWith({
|
|
951
928
|
messages: [
|
|
952
929
|
{ role: 'user', content: [{ type: 'text', text: '跑一下' }] },
|
|
953
930
|
{ role: 'assistant', content: [{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
954
|
-
{ role: '
|
|
931
|
+
{ role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] },
|
|
955
932
|
],
|
|
956
933
|
}, fakeAttachments())
|
|
957
934
|
const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>
|
|
958
935
|
assertToolPairingIntact(messages)
|
|
959
|
-
assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool
|
|
936
|
+
assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool 消息仍紧跟 assistant')
|
|
960
937
|
})
|
|
961
938
|
|
|
962
939
|
/* ---------------- 流空闲超时:上游停摆不能永久挂住(issue #6) ---------------- */
|