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.
Files changed (53) hide show
  1. package/README.md +18 -15
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +65 -3
  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 +298 -119
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +57 -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-base-url.d.ts +31 -0
  21. package/lib/types/host-version.d.ts +7 -8
  22. package/lib/types/index.d.ts +1 -1
  23. package/lib/types/llm/adapter.d.ts +6 -1
  24. package/lib/types/shared.d.ts +33 -0
  25. package/package.json +17 -7
  26. package/src/client/EndpointTab.tsx +15 -18
  27. package/src/client/ExtDetail.tsx +552 -9
  28. package/src/client/index.tsx +7 -28
  29. package/src/client/router.css +132 -62
  30. package/src/ext/contract.ts +111 -0
  31. package/src/ext-prompt/assembly.test.ts +79 -0
  32. package/src/ext-prompt/content.ts +174 -0
  33. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  34. package/src/ext-prompt/index.ts +104 -0
  35. package/src/ext-prompt/locale/en.json +6 -0
  36. package/src/ext-prompt/locale/zh.json +6 -0
  37. package/src/ext-prompt/logo.svg +14 -0
  38. package/src/ext-prompt/logo.ts +18 -0
  39. package/src/ext-prompt/mount.test.ts +95 -0
  40. package/src/ext-prompt/mount.ts +79 -0
  41. package/src/ext-prompt/plugin.ts +223 -0
  42. package/src/ext-prompt/render.test.ts +256 -0
  43. package/src/ext-prompt/render.ts +216 -0
  44. package/src/ext-prompt/row.json +5 -0
  45. package/src/ext-route.test.ts +78 -0
  46. package/src/host-base-url.test.ts +129 -0
  47. package/src/host-base-url.ts +43 -0
  48. package/src/host-version.test.ts +21 -28
  49. package/src/host-version.ts +39 -39
  50. package/src/index.ts +230 -96
  51. package/src/llm/adapter.test.ts +19 -42
  52. package/src/llm/adapter.ts +32 -50
  53. package/src/shared.ts +34 -0
@@ -123,11 +123,10 @@ export interface RouterAdapterSource {
123
123
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
124
124
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
125
125
  *
126
- * 工具结果双模型兼容:0.1.5/0.1.6 里 tool result 是 user 消息里的 'tool-result' 块
127
- * (旧路径);0.1.7+ 改成了独立 role:'tool' 消息、toolCallId 挂消息级(新分支)。
128
- * 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
126
+ * 工具结果只有一种形态:独立 `role:'tool'` 消息、toolCallId 挂消息级,wire 上原样
127
+ * 发 `role:'tool'`。见主循环里的 `role === 'tool'` 分支。
129
128
  */
130
- /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
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-result 里带出来的图片」,等这一串 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
- // 新形态:独立 role:'tool' 消息、toolCallId 挂消息级(0.1.7 loop-built);
193
- // 旧形态:user 消息里的 'tool-result' 块(0.1.5/0.1.6,以及 0.1.7 下经
194
- // session-format 迁移/重放的老会话历史——实测 0.1.7 仍会出现)。
195
- // 所以**不能**用宿主版本号判断形态对错(0.1.7-alpha.2 也是 0.1.7,同样会出现
196
- // 旧形态)——形状才是事实,按形状分派这一份 adapter 就同时吃两版。
197
- const isToolRoleMessage = (message.role as string) === 'tool'
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 / tool-result
218
- const toolResults = message.content.filter((b) => b.type === 'tool-result')
219
- // 这条消息自己的(非 tool-result 的)user 部分:文本 + 图片。
220
- const ownParts = await contentParts(
221
- message.content.filter((b) => b.type !== 'tool-result'),
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
- * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
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
- if (block.type === 'tool-result') {
288
- parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal, log)))
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
  }
@@ -634,7 +607,12 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
634
607
  ]
635
608
 
636
609
  export class RouterAdapter extends LlmAdapter {
637
- private readonly baseURL: string
610
+ /**
611
+ * 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
612
+ * 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
613
+ * issue #9),见 `host-base-url.ts`。
614
+ */
615
+ private readonly baseURL: string | (() => string)
638
616
  private readonly source: RouterAdapterSource
639
617
  private readonly resolveAttachments: () => RouterAttachmentStore | undefined
640
618
  /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
@@ -649,7 +627,7 @@ export class RouterAdapter extends LlmAdapter {
649
627
  * 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
650
628
  */
651
629
  constructor(
652
- baseURL: string,
630
+ baseURL: string | (() => string),
653
631
  source: RouterAdapterSource,
654
632
  resolveAttachments?: () => RouterAttachmentStore | undefined,
655
633
  idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
@@ -713,7 +691,11 @@ export class RouterAdapter extends LlmAdapter {
713
691
  options.signal?.addEventListener('abort', onAbort, { once: true })
714
692
  let resp: Response
715
693
  try {
716
- resp = await fetch(`${this.baseURL}/chat/completions`, {
694
+ // 端点现算:宿主监听端口在 listen 之后才有值、也会随宿主重启变(issue #9)。
695
+ // 解析器自己抛错时也落进下面这个 catch,于是「端口拿不到」是以
696
+ // LlmError 消息报出来的,而不是一句无从查证的 `fetch failed`。
697
+ const base = typeof this.baseURL === 'function' ? this.baseURL() : this.baseURL
698
+ resp = await fetch(`${base}/chat/completions`, {
717
699
  method: 'POST',
718
700
  // attributionHeaders() 是 dsh-llm 对 adapter 的硬契约:每个 provider
719
701
  // 请求都必须带(LlmAdapter 类注释原话)。它给出
package/src/shared.ts CHANGED
@@ -164,6 +164,40 @@ export interface RouterExtItem {
164
164
  * 「包含的组件」里占一行了,两处都列就是同一个东西显示两遍(与内置供应商同理)。
165
165
  */
166
166
  source?: 'builtin'
167
+ /**
168
+ * 扩展自带的子开关(如准则的每一条)。**由扩展自报**,核心只搬运。
169
+ *
170
+ * ⚠️ 与 `enabled`(总开关)不同:子开关**不改变扩展是否被加载**,只改它
171
+ * 自己的行为。总开关关时这些子开关一并失效(面板上禁用,不隐藏)。
172
+ */
173
+ controls?: ExtControlItem[]
174
+ }
175
+
176
+ /** `/router/api/ext` 里的一条子开关(与 `router.ext` 契约的 `ExtControl` 对应)。 */
177
+ export interface ExtControlItem {
178
+ /** 稳定 id(持久化键;改名会丢用户已保存的选择)。 */
179
+ id: string
180
+ title: string
181
+ /** 当前是否开启(扩展自算:默认值 + 用户已存选择)。 */
182
+ on: boolean
183
+ /** 一句话说明(面板副行,可选)。 */
184
+ detail?: string
185
+ /** 原文(面板点开即可读到;不给出就是在盲切,见 `ExtControl.body`)。 */
186
+ body?: string
187
+ /** 文本可编辑(缺省 = 只读;见 `ExtControl.editable`)。 */
188
+ editable?: boolean
189
+ /**
190
+ * 自建条目(可删)还是内置(不可删、只有开关与还原)。
191
+ *
192
+ * ⚠️ **必填,不给可选**(2026-09-30 实测踩过):曾声明成 `custom?: boolean`,
193
+ * 而服务端只在 `custom === true` 时才带这个字段 ⇒ 内置条目拿到的是
194
+ * `undefined`,面板判 `c.custom === true` 为假,**恰好是对的** ——
195
+ * 但那条正确性建立在"缺省恰好等于 false"上,改任何一边的默认值都会翻转。
196
+ * ⇒ 让服务端**总是**给显式布尔,两边不必猜。
197
+ */
198
+ custom: boolean
199
+ /** 文本被改过(决定「还原」是否可点)。必填,理由同 `custom`。 */
200
+ overridden: boolean
167
201
  }
168
202
 
169
203
  /** `/router/api/ext` response(扩展插件列表 + 开关)。 */