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.
- 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 +286 -127
- package/lib/types/ext/contract.d.ts +113 -0
- package/lib/types/ext-prompt/content.d.ts +64 -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 +181 -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/src/suppliers/registry.test.ts +56 -0
- package/src/suppliers/registry.ts +40 -10
package/src/index.ts
CHANGED
|
@@ -44,9 +44,9 @@ import { SupplierConfigStore } from './supplier-config.ts'
|
|
|
44
44
|
import { CredentialStore } from './credential-store.ts'
|
|
45
45
|
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
46
46
|
import { loopbackBaseURL } from './host-base-url.ts'
|
|
47
|
-
import { detectHostVersion,
|
|
47
|
+
import { detectHostVersion, isHostSupported } from './host-version.ts'
|
|
48
48
|
import { ExtStore } from './ext/store.ts'
|
|
49
|
-
import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
49
|
+
import type { ExtControl, ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -58,28 +58,25 @@ export const name = 'dsh-router-core'
|
|
|
58
58
|
export const inject = ['webServer', 'llm']
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* `Config` 导出:让 Router
|
|
61
|
+
* `Config` 导出:让 Router 卡片进设置-模型的镜像(0.2.0 的收录链是「插件导出的
|
|
62
|
+
* Config + 配置树行 id」)。
|
|
62
63
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
|
|
69
|
-
* 能力检测恰好就是**对**的判据。
|
|
64
|
+
* **这处保留能力检测,不看宿主版本号** —— 因为它是**模块顶层导出**,loader 一 import
|
|
65
|
+
* 就求值,那时还没有 `ctx.baseUrl`;而 schemastery 是本插件的**普通依赖**,解析到的
|
|
66
|
+
* 是插件自己 node_modules 里那一份,跟宿主实际加载的版本无关。既然这里拿不到宿主
|
|
67
|
+
* 版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 —— 能力检测
|
|
68
|
+
* 恰好就是**对**的判据。
|
|
70
69
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
|
|
76
|
-
* installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
|
|
70
|
+
* 为什么要 `.volatile()`:空 object 会被 volatileForm 过滤掉,顶层标 volatile 才能让
|
|
71
|
+
* 卡片进镜像。`.volatile()` 是 schemastery 3.18.4 才有的原型方法,直写
|
|
72
|
+
* `Schema.object({}).volatile()` 在没有它的版本上会抛 TypeError → **整个插件加载
|
|
73
|
+
* 失败**。所以:无 volatile 就回落普通空 schema(不抛,代价是卡片不出现,并有日志)。
|
|
77
74
|
*/
|
|
78
75
|
const emptySchema = Schema.object({})
|
|
79
76
|
const hasVolatile = typeof (emptySchema as { volatile?: unknown }).volatile === 'function'
|
|
80
77
|
|
|
81
78
|
/**
|
|
82
|
-
*
|
|
79
|
+
* 设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
|
|
83
80
|
* 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
|
|
84
81
|
* Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
|
|
85
82
|
*/
|
|
@@ -132,29 +129,6 @@ interface Llm {
|
|
|
132
129
|
registerAdapter: (providers: readonly string[], adapter: unknown) => () => void
|
|
133
130
|
}
|
|
134
131
|
|
|
135
|
-
/**
|
|
136
|
-
* 设置服务(`ctx.settings`,dsh-settings)的最小切面。0.1.5 用 `installSection`
|
|
137
|
-
* 注册命名空间;0.1.7 已移除该方法、改由插件导出的 Config 驱动 —— 故声明为可选,
|
|
138
|
-
* 调用处做能力检测(不是版本分支)。dsh-settings 是宿主注入的 peer service,不深绑类型。
|
|
139
|
-
*/
|
|
140
|
-
interface SettingsServiceFace {
|
|
141
|
-
installSection?: (
|
|
142
|
-
owner: CordisContext,
|
|
143
|
-
ns: string,
|
|
144
|
-
schema: unknown,
|
|
145
|
-
entry: unknown,
|
|
146
|
-
hooks: { setSource: (current: () => unknown) => void; onChange: () => void },
|
|
147
|
-
) => void
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* cordis Context 的最小切面。`installSection(owner)` 只会对 owner 调
|
|
152
|
-
* `ctx.effect(...)` 并在卸载时读 `fiber.state`,所以这里只声明 `effect`。
|
|
153
|
-
*/
|
|
154
|
-
interface CordisContext {
|
|
155
|
-
effect: (fn: () => () => void, label?: string) => unknown
|
|
156
|
-
}
|
|
157
|
-
|
|
158
132
|
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
159
133
|
const payload = JSON.stringify(body)
|
|
160
134
|
res.writeHead(status, {
|
|
@@ -205,12 +179,18 @@ export function apply(rawContext: unknown): void {
|
|
|
205
179
|
const dataDir = dataDirOf(ctx.baseUrl)
|
|
206
180
|
const stateFile = join(dataDir, 'state.json')
|
|
207
181
|
log(`data dir: ${dataDir}`)
|
|
208
|
-
//
|
|
209
|
-
//
|
|
182
|
+
// 宿主版本:只用于**报版本**与**守地板**(不再是兼容分叉的判据,见 host-version.ts)。
|
|
183
|
+
// 低于 0.2.0 的宿主上本插件会静默走错路径,所以这里必须出声,把「插件坏了」和
|
|
184
|
+
// 「宿主太老」区分开 —— 否则用户只能对着症状猜。
|
|
210
185
|
const hostVersion = detectHostVersion(ctx.baseUrl)
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
}
|
|
214
194
|
const store = new SupplierConfigStore(stateFile)
|
|
215
195
|
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
216
196
|
const router = new Router(stateFile, store, log)
|
|
@@ -403,10 +383,11 @@ export function apply(rawContext: unknown): void {
|
|
|
403
383
|
const { suppliers } = router.status()
|
|
404
384
|
writeJson(res, 200, {
|
|
405
385
|
ok: true,
|
|
406
|
-
//
|
|
407
|
-
//
|
|
386
|
+
// 宿主版本(客户端/排障用)。`lastHitDock` 是给 0.1.5→0.1.7 那个位置差异留的
|
|
387
|
+
// 能力位,0.2.0 起恒为 true —— 保留字段形状是为了不打断已有读取方,客户端
|
|
388
|
+
// 已不再依赖它做分支(见 client/index.tsx)。
|
|
408
389
|
hostVersion: hostVersion ?? '',
|
|
409
|
-
lastHitDock:
|
|
390
|
+
lastHitDock: true,
|
|
410
391
|
suppliers: suppliers.map((s) => {
|
|
411
392
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
|
|
412
393
|
return {
|
|
@@ -590,21 +571,198 @@ export function apply(rawContext: unknown): void {
|
|
|
590
571
|
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
591
572
|
})
|
|
592
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
|
+
}
|
|
593
618
|
// ---- 扩展 (Ext):扩展器列表 + 开关 ----
|
|
594
619
|
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
595
620
|
if (req.method === 'PATCH') {
|
|
596
|
-
|
|
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 }
|
|
597
624
|
try {
|
|
598
|
-
body = JSON.parse(await readBody(req, 64 << 10)) as
|
|
625
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as typeof body
|
|
599
626
|
} catch {
|
|
600
627
|
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
601
628
|
return
|
|
602
629
|
}
|
|
630
|
+
// `addedId` 只给 add 用:面板要靠它定位刚建的那条。声明放在 PATCH 顶层,
|
|
631
|
+
// 因为**成功路径会落到末尾统一响应**(op 块内声明的话末尾够不着)。
|
|
632
|
+
let addedId: string | null = null
|
|
603
633
|
const ext = typeof body.id === 'string' ? exts[body.id] as RouterExtService[string] : undefined
|
|
604
634
|
if (!ext) {
|
|
605
635
|
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
606
636
|
return
|
|
607
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
|
+
|
|
608
766
|
if (typeof body.enabled !== 'boolean') {
|
|
609
767
|
writeJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
|
|
610
768
|
return
|
|
@@ -622,24 +780,11 @@ export function apply(rawContext: unknown): void {
|
|
|
622
780
|
// 开关归核心持久化。
|
|
623
781
|
extStore.setEnabled(ext.id, body.enabled)
|
|
624
782
|
}
|
|
625
|
-
const list: ExtInfo[] =
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
return {
|
|
631
|
-
id: e.id,
|
|
632
|
-
name: e.name ?? e.id,
|
|
633
|
-
...(e.description !== undefined ? { description: e.description } : {}),
|
|
634
|
-
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
635
|
-
enabled: extStore.isEnabled(e.id),
|
|
636
|
-
ready: st?.ready === true,
|
|
637
|
-
// 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
|
|
638
|
-
...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
|
|
639
|
-
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
640
|
-
}
|
|
641
|
-
})
|
|
642
|
-
.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、就挂在那儿。
|
|
643
788
|
writeJson(res, 200, { ok: true, enhancers: list })
|
|
644
789
|
})
|
|
645
790
|
|
|
@@ -720,32 +865,9 @@ export function apply(rawContext: unknown): void {
|
|
|
720
865
|
// Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
|
|
721
866
|
// 「路由系统」面板;设置-模型 这边必须让 settingsNs 在设置镜像里能解析出来
|
|
722
867
|
// (见 dsh-client-ui-settings-models 的 configurable 过滤),否则卡片不出现。
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
// 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
|
|
727
|
-
const routerSettingsSchema = Schema.object({})
|
|
728
|
-
if (isHost017Plus(ctx.baseUrl)) {
|
|
729
|
-
log(`settings section driven by exported Config (${settingsNs})`)
|
|
730
|
-
} else {
|
|
731
|
-
ctx.inject(['settings'], (sctx: unknown) => {
|
|
732
|
-
const settings = (sctx as { settings?: SettingsServiceFace }).settings
|
|
733
|
-
if (settings === undefined) {
|
|
734
|
-
log('settings service absent — skip Router settings-section registration')
|
|
735
|
-
return
|
|
736
|
-
}
|
|
737
|
-
if (settings.installSection === undefined) {
|
|
738
|
-
// 版本说该走 installSection 但它不在(异常宿主):只记日志,不抛。
|
|
739
|
-
log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`)
|
|
740
|
-
return
|
|
741
|
-
}
|
|
742
|
-
settings.installSection(rawContext as CordisContext, settingsNs, routerSettingsSchema, {}, {
|
|
743
|
-
setSource: () => {},
|
|
744
|
-
onChange: () => {},
|
|
745
|
-
})
|
|
746
|
-
log(`Router settings section registered (${settingsNs})`)
|
|
747
|
-
})
|
|
748
|
-
}
|
|
868
|
+
// 0.2.0 的镜像行来自**顶部导出的 Config**(空 object + `.volatile()`,ns = 行 id),
|
|
869
|
+
// 这里无需任何注册动作 —— 0.1.5 那条 `settings.installSection` 路径已随兼容分叉删除。
|
|
870
|
+
log(`settings section driven by exported Config (${settingsNs})`)
|
|
749
871
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
750
872
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
751
873
|
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
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) ---------------- */
|
package/src/llm/adapter.ts
CHANGED
|
@@ -123,11 +123,10 @@ export interface RouterAdapterSource {
|
|
|
123
123
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
124
124
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
125
125
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
|
|
126
|
+
* 工具结果只有一种形态:独立 `role:'tool'` 消息、toolCallId 挂消息级,wire 上原样
|
|
127
|
+
* 发 `role:'tool'`。见主循环里的 `role === 'tool'` 分支。
|
|
129
128
|
*/
|
|
130
|
-
/** tool
|
|
129
|
+
/** tool 消息里的图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
131
130
|
const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
|
|
132
131
|
|
|
133
132
|
/**
|
|
@@ -144,7 +143,7 @@ const STREAM_IDLE_TIMEOUT_MS = 300_000
|
|
|
144
143
|
async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined, log?: (msg: string) => void): Promise<Array<Record<string, unknown>>> {
|
|
145
144
|
const out: Array<Record<string, unknown>> = []
|
|
146
145
|
/**
|
|
147
|
-
* 攒着「tool
|
|
146
|
+
* 攒着「tool 消息里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
|
|
148
147
|
* user 消息补在后面。
|
|
149
148
|
*
|
|
150
149
|
* 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
|
|
@@ -188,14 +187,13 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
188
187
|
})
|
|
189
188
|
continue
|
|
190
189
|
}
|
|
191
|
-
// tool
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
if (isToolRoleMessage) {
|
|
190
|
+
// tool 结果:0.2.0 起**只有一种形态** —— 独立 role:'tool' 消息、toolCallId 挂消息级。
|
|
191
|
+
// 旧的「user 消息内嵌 'tool-result' 块」(0.1.5/0.1.6 时代,0.1.7 起退役)**不再
|
|
192
|
+
// 支持**:0.2.0 的 v3→v4 会话迁移主动拒绝 tool-result 包装
|
|
193
|
+
// (`must not contain a released tool-result wrapper`),老会话要么被迁移展开成
|
|
194
|
+
// tool 消息、要么直接拒绝打开 —— 这条形态到不了 adapter(见 host-version.ts 的
|
|
195
|
+
// 版本地板)。
|
|
196
|
+
if ((message.role as string) === 'tool') {
|
|
199
197
|
// tool 消息必须紧跟 assistant(tool_calls):中间插进任何 user 消息,上游都会判定
|
|
200
198
|
// tool_call 与 tool_result 失配 → codebuddy 直接 400 网关码 11148(bad_request
|
|
201
199
|
// 按核心设计不罚账号,面板上看不到异常,易误判成额度/风控)。
|
|
@@ -214,35 +212,11 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
214
212
|
pendingToolImages.push(...imageParts)
|
|
215
213
|
continue
|
|
216
214
|
}
|
|
217
|
-
// user
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
attachments,
|
|
223
|
-
options.signal,
|
|
224
|
-
log,
|
|
225
|
-
)
|
|
226
|
-
// 有实质 user 内容(或压根没有 tool 结果)就先发这条 user 消息;
|
|
227
|
-
// 只有 tool 结果的「纯工具」消息不发空 user,图片留到 pending 里合并。
|
|
228
|
-
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
229
|
-
flushToolImages()
|
|
230
|
-
out.push({ role: 'user', content: compactUserContent(ownParts) })
|
|
231
|
-
}
|
|
232
|
-
for (const result of toolResults) {
|
|
233
|
-
// **tool 消息必须紧跟 assistant(tool_calls)**:中间插进任何 user 消息,
|
|
234
|
-
// 上游都会判定 tool_call 与 tool_result 失配 —— codebuddy 直接 400 网关码
|
|
235
|
-
// 11148「tool calls and tool results do not match」。
|
|
236
|
-
// 图片不能放进 tool 消息(实测模型会把两张不同的图认成同一张),
|
|
237
|
-
// 也不能插在两条 tool 之间(同样 400),所以先攒起来、发完整串再补一条 user。
|
|
238
|
-
const parts = await contentParts(result.content, attachments, options.signal, log)
|
|
239
|
-
const imageParts = parts.filter((p) => p.type !== 'text')
|
|
240
|
-
const text = parts.filter((p) => p.type === 'text').map((p) => (p as { text?: string }).text ?? '').join('')
|
|
241
|
-
// 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
|
|
242
|
-
// 以为工具真的打印了那句话(9router 也是补 content: "")。
|
|
243
|
-
out.push({ role: 'tool', tool_call_id: result.toolCallId, content: text })
|
|
244
|
-
pendingToolImages.push(...imageParts)
|
|
245
|
-
}
|
|
215
|
+
// user:文本 + 图片。**即使内容为空也照发** —— 这条 user 消息在历史里存在过,
|
|
216
|
+
// 悄悄省掉等于让模型看到一段断掉的话轮。
|
|
217
|
+
const userParts = await contentParts(message.content, attachments, options.signal, log)
|
|
218
|
+
flushToolImages()
|
|
219
|
+
out.push({ role: 'user', content: compactUserContent(userParts) })
|
|
246
220
|
}
|
|
247
221
|
flushToolImages()
|
|
248
222
|
return out
|
|
@@ -262,7 +236,7 @@ function compactUserContent(parts: Array<Record<string, unknown>>): string | Arr
|
|
|
262
236
|
}
|
|
263
237
|
|
|
264
238
|
/**
|
|
265
|
-
*
|
|
239
|
+
* 一条 DSH 消息的内容块序列化成 OpenAI content parts(text + image_url)。
|
|
266
240
|
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
267
241
|
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
268
242
|
*/
|
|
@@ -284,10 +258,9 @@ async function contentParts(
|
|
|
284
258
|
parts.push(...(await imageParts(ref, attachments, signal, log)))
|
|
285
259
|
continue
|
|
286
260
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// 其它块(reasoning/tool-call)不属于 user content,忽略
|
|
261
|
+
// 其它块(reasoning/tool-call/tool-addition/tool-removal/file)不属于发给上游的
|
|
262
|
+
// user/tool 内容,忽略。0.1.x 的 'tool-result' 块曾在这里递归展开,随 0.2.0
|
|
263
|
+
// 单面编写一并删除(该块在 0.2.0 的 ContentBlockMap 里已不存在,见主循环注释)。
|
|
291
264
|
}
|
|
292
265
|
return parts
|
|
293
266
|
}
|