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.
Files changed (49) hide show
  1. package/README.md +9 -10
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +64 -2
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +249 -117
  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-version.d.ts +7 -8
  21. package/lib/types/index.d.ts +1 -1
  22. package/lib/types/shared.d.ts +33 -0
  23. package/package.json +17 -7
  24. package/src/client/EndpointTab.tsx +15 -18
  25. package/src/client/ExtDetail.tsx +552 -9
  26. package/src/client/index.tsx +7 -28
  27. package/src/client/router.css +132 -62
  28. package/src/ext/contract.ts +111 -0
  29. package/src/ext-prompt/assembly.test.ts +79 -0
  30. package/src/ext-prompt/content.ts +174 -0
  31. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  32. package/src/ext-prompt/index.ts +104 -0
  33. package/src/ext-prompt/locale/en.json +6 -0
  34. package/src/ext-prompt/locale/zh.json +6 -0
  35. package/src/ext-prompt/logo.svg +14 -0
  36. package/src/ext-prompt/logo.ts +18 -0
  37. package/src/ext-prompt/mount.test.ts +95 -0
  38. package/src/ext-prompt/mount.ts +79 -0
  39. package/src/ext-prompt/plugin.ts +223 -0
  40. package/src/ext-prompt/render.test.ts +256 -0
  41. package/src/ext-prompt/render.ts +216 -0
  42. package/src/ext-prompt/row.json +5 -0
  43. package/src/ext-route.test.ts +78 -0
  44. package/src/host-version.test.ts +21 -28
  45. package/src/host-version.ts +39 -39
  46. package/src/index.ts +216 -94
  47. package/src/llm/adapter.test.ts +19 -42
  48. package/src/llm/adapter.ts +20 -47
  49. package/src/shared.ts +34 -0
@@ -1,18 +1,52 @@
1
1
  /**
2
2
  * 扩展详情页 —— 从设置 → 路由 → 扩展 的卡片点进来。
3
3
  *
4
- * **只读:开关不在这一层。** 启停的唯一入口是官方插件页(设置 → 插件 →
4
+ * **总开关不在这一层。** 启停的唯一入口是官方插件页(设置 → 插件 →
5
5
  * dsh-router-core 详情页的「路由组件」一节),那里直接调核心的
6
6
  * `PATCH /router/api/ext`,自检与持久化都由核心裁决。同一件事只留一个开关,
7
7
  * 否则两处状态各说各话。
8
8
  *
9
+ * **但扩展自带的子开关在这一层**(`item.controls`,如准则的每一条):它管的是
10
+ * 扩展自己的**行为细节**,没有第二处地方可放 —— 插件页那一行只有宿主管的
11
+ * 总开关。走**同一个** PATCH 端点、同一个 `data` 落盘,不存在"两处状态各说
12
+ * 各话"的问题(那正是总开关要单点的原因)。
13
+ *
9
14
  * 列表页只列**已启用**的扩展(关闭的连卡片都不出现),所以这里也不必再显示
10
15
  * 「已启用/未启用」——那行字只能是常量。布局同供应商详情:返回链接 + 图标 +
11
16
  * 名称/副行,下面跟扩展说明卡与未就绪横幅。有 `api()` 支持的自定义扩展可在未来
12
17
  * 按需加独立面板,此处不内置任何具体扩展的专属视图。
13
18
  */
14
- import type { RouterExtItem } from '../shared.ts'
19
+ import { useEffect, useState } from 'react'
20
+ import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
21
+ import { ROUTER_API_BASE, type ExtControlItem, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
15
22
  import { extPanel } from './ext-panels.ts'
23
+ import { Modal } from './Modal.tsx'
24
+
25
+ /**
26
+ * 服务端的 `error` 是**给调试看的**(`'controlId must be a non-empty string'`、
27
+ * `'reorder failed'`…),直接摆到页面上等于把 API 内部话漏给用户。
28
+ *
29
+ * 做法:**先认自己那几个已知中文错误**(核心对"为什么拒"给的是中文),其余一律
30
+ * 回落到调用方给的兜底文案。宁可少说一句,也不要给一句看不懂的。
31
+ */
32
+ function friendly(serverError: string | undefined, fallback: string): string {
33
+ if (serverError === undefined || serverError === '') return fallback
34
+ // 服务端已经写了中文(面向用户的拒绝理由)⇒ 原样透出。
35
+ if (/[\u4e00-\u9fa5]/.test(serverError)) return serverError
36
+ return fallback
37
+ }
38
+
39
+ /** 行内图标按钮里的图标(照 `CombosTab` 的同款路径与线宽,1.8px 描边)。 */
40
+ function RowIcon({ d, size = 15 }: { d: string; size?: number }): JSX.Element {
41
+ return (
42
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
43
+ <path d={d} />
44
+ </svg>
45
+ )
46
+ }
47
+
48
+ /** 铅笔图标 —— 与 `CombosTab.tsx` / `SupplierDetail.tsx` 的 `I.edit` **逐字相同**。 */
49
+ const I_EDIT = 'M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3zM13.5 6.5l3 3'
16
50
 
17
51
  function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
18
52
  return (
@@ -27,6 +61,518 @@ const I = {
27
61
  bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
28
62
  }
29
63
 
64
+ /** 「+ 添加准则」弹窗 —— 标题 + 正文,必填。 */
65
+ function AddControlModal({
66
+ onClose,
67
+ onSave,
68
+ busy,
69
+ }: {
70
+ onClose: () => void
71
+ onSave: (title: string, body: string) => void
72
+ busy: boolean
73
+ }): JSX.Element {
74
+ const [title, setTitle] = useState('')
75
+ const [body, setBody] = useState('')
76
+ const valid = title.trim() !== '' && body.trim() !== ''
77
+ return (
78
+ <Modal title="添加准则" onClose={onClose}>
79
+ <div className="dshr-modalForm">
80
+ <label className="dshr-requireTitle" htmlFor="dshr-add-title">标题</label>
81
+ <input
82
+ id="dshr-add-title"
83
+ className="dshr-input"
84
+ value={title}
85
+ onChange={(e) => { setTitle(e.target.value) }}
86
+ autoFocus
87
+ />
88
+ <label className="dshr-requireTitle" htmlFor="dshr-add-body">内容</label>
89
+ <textarea
90
+ id="dshr-add-body"
91
+ className="dshr-input"
92
+ style={{ minHeight: 140, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.6 }}
93
+ value={body}
94
+ onChange={(e) => { setBody(e.target.value) }}
95
+ />
96
+ <div className="dshr-modalActions">
97
+ <button type="button" className="dshr-miniButton" onClick={onClose} disabled={busy}>取消</button>
98
+ <button
99
+ type="button"
100
+ className="dshr-primaryButton"
101
+ disabled={busy || !valid}
102
+ title={valid ? '添加' : '标题与内容都不能为空'}
103
+ onClick={() => { onSave(title.trim(), body.trim()) }}
104
+ >
105
+ {busy ? '添加中…' : '添加'}
106
+ </button>
107
+ </div>
108
+ </div>
109
+ </Modal>
110
+ )
111
+ }
112
+
113
+ /**
114
+ * 「修改」弹窗 —— 改一个子开关的标题与正文(2026-09-29)。
115
+ *
116
+ * ⚠️ **改完必须真的进 prompt**,否则这就是"只改了显示"的假编辑
117
+ * (服务端 `setControlText` + 渲染层的 `effectiveText` 是这件事的另一半)。
118
+ *
119
+ * ⚠️ **空标题 / 空正文不允许提交**(按钮禁用 + 服务端也拒):空标题让这一行没
120
+ * 名字,空正文让这条规则凭空消失 —— 两者都像"被关了"而不是"被改坏了"。
121
+ */
122
+ function EditControlModal({
123
+ control,
124
+ onClose,
125
+ onSave,
126
+ onReset,
127
+ onRemove,
128
+ busy,
129
+ }: {
130
+ control: ExtControlItem
131
+ onClose: () => void
132
+ onSave: (patch: { title?: string; body?: string }) => void
133
+ /** 还原成内置内容(仅内置且被改过时可调)。 */
134
+ onReset: () => void
135
+ /** 删除这条自定义准则(仅自建条目可调)。 */
136
+ onRemove: () => void
137
+ busy: boolean
138
+ }): JSX.Element {
139
+ const [title, setTitle] = useState(control.title)
140
+ const [body, setBody] = useState(control.body ?? '')
141
+ const trimmedTitle = title.trim()
142
+ const trimmedBody = body.trim()
143
+ const valid = trimmedTitle !== '' && trimmedBody !== ''
144
+
145
+ return (
146
+ <Modal title={`修改:${control.title}`} onClose={onClose}>
147
+ <div className="dshr-modalForm">
148
+ <label className="dshr-requireTitle" htmlFor={`dshr-ctl-title-${control.id}`}>标题</label>
149
+ <input
150
+ id={`dshr-ctl-title-${control.id}`}
151
+ className="dshr-input"
152
+ value={title}
153
+ onChange={(e) => { setTitle(e.target.value) }}
154
+ autoFocus
155
+ />
156
+ <label className="dshr-requireTitle" htmlFor={`dshr-ctl-body-${control.id}`}>内容</label>
157
+ {/* 正文用 textarea:`input` 单行,长准则(收口/底线)没法编辑。 */}
158
+ <textarea
159
+ id={`dshr-ctl-body-${control.id}`}
160
+ className="dshr-input"
161
+ style={{ minHeight: 160, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.6 }}
162
+ value={body}
163
+ onChange={(e) => { setBody(e.target.value) }}
164
+ />
165
+ <p className="dshr-compHint">
166
+ 改完立刻生效于 system prompt。此处只存**改过的部分**;未改的分类仍随内置版本更新。
167
+ </p>
168
+ {/* ⚠️ `dshr-modalActions` + `dshr-miniButton` —— 与 `CombosTab` 的
169
+ 创建/删除弹窗**同一套**(2026-09-29 用户指出)。原先是内联
170
+ `display:flex` 手搓的按钮排布。 */}
171
+ {/* ⚠️ 左:这一条**特有**的动作(自建⇒删除 / 内置被改过⇒还原);
172
+ 右:通用的取消与保存。2026-09-29 把删/还原从行内挪到这里 ——
173
+ 它们是"次要且有破坏性"的动作,不该和「修改」挤在标题旁边
174
+ (14 行 × 3 个常驻按钮 = 42 个按钮压过内容)。 */}
175
+ <div className="dshr-modalActions">
176
+ {control.custom === true
177
+ ? (
178
+ <button
179
+ type="button"
180
+ className="dshr-dangerButton"
181
+ disabled={busy}
182
+ onClick={onRemove}
183
+ >
184
+ 删除这条
185
+ </button>
186
+ )
187
+ : (
188
+ <button
189
+ type="button"
190
+ className="dshr-miniButton"
191
+ disabled={busy || control.overridden !== true}
192
+ title={control.overridden === true ? '还原成内置内容' : '这条没被改过,无需还原'}
193
+ onClick={onReset}
194
+ >
195
+ 还原成内置
196
+ </button>
197
+ )}
198
+ <span style={{ flex: 1 }} />
199
+ <button type="button" className="dshr-miniButton" onClick={onClose} disabled={busy}>取消</button>
200
+ <button
201
+ type="button"
202
+ className="dshr-primaryButton"
203
+ disabled={busy || !valid}
204
+ title={valid ? '保存' : '标题与内容都不能为空'}
205
+ onClick={() => {
206
+ // 只提交**真的变了**的字段:省一次写盘,也让"部分更新"这条契约在
207
+ // UI 侧也成立(服务端是 `patch` 合并,不是整条替换)。
208
+ const patch: { title?: string; body?: string } = {}
209
+ if (trimmedTitle !== control.title) patch.title = trimmedTitle
210
+ if (trimmedBody !== control.body) patch.body = trimmedBody
211
+ onSave(patch)
212
+ }}
213
+ >
214
+ {busy ? '保存中…' : '保存'}
215
+ </button>
216
+ </div>
217
+ </div>
218
+ </Modal>
219
+ )
220
+ }
221
+
222
+ /**
223
+ * 扩展自带的子开关(如准则的每一条)。
224
+ *
225
+ * ⚠️ **失败时回读真值,不留乐观假象**(与 `RouterComponentsSection.toggle` 同款
226
+ * 纪律):乐观更新看着顺,但核心拒了(扩展未就绪 / 未知 controlId / 网络失败)
227
+ * 时,开关会停在一个「看起来成功了」的状态,而 prompt 里其实没变。
228
+ *
229
+ * ⚠️ **总开关关时显示为禁用而不是隐藏**:隐藏会让用户以为这些分类不存在。
230
+ * 它们的值也没被清掉(总开关只叠加失效),所以重新打开总开关就恢复原样。
231
+ */
232
+ function ExtControls({ item }: { item: RouterExtItem }): JSX.Element {
233
+ // ⚠️ `rows` 存**当前的显示列表**(含顺序):拖动与新增/删除要立刻反映在界面上,
234
+ // 而它们只改服务端、只回一张新的 `controls`。用 props 直接渲染的话,
235
+ // 拖动后要等下一次 GET 才会重排 —— 手上还抓着就跳回去了。
236
+ const [rows, setRows] = useState<ExtControlItem[]>(() => [...(item.controls ?? [])])
237
+ const controls = rows
238
+ const [state, setState] = useState<Record<string, boolean>>(() => Object.fromEntries(controls.map((c) => [c.id, c.on])))
239
+ const [busy, setBusy] = useState('')
240
+ // ⚠️ 错误走**浮动提示**(`dshr-toast`,与 `SupplierDetail` 同款),不再是列表
241
+ // 上方常驻的一行 —— 失败是**瞬时**事件,常驻行会一直占着位置。
242
+ const [error, setError] = useState('')
243
+
244
+ // 扩展重算 controls 后(例如用户在别处改了总开关)同步过来。
245
+ useEffect(() => {
246
+ setRows([...(item.controls ?? [])])
247
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
+ }, [item.id, item.enabled, (item.controls ?? []).length])
249
+
250
+ useEffect(() => {
251
+ setState(Object.fromEntries(controls.map((c) => [c.id, c.on])))
252
+ // eslint-disable-next-line react-hooks/exhaustive-deps
253
+ }, [item.id, item.enabled, controls.length])
254
+
255
+ const toggle = async (id: string, next: boolean): Promise<void> => {
256
+ setBusy(id)
257
+ setError('')
258
+ setState((s) => ({ ...s, [id]: next }))
259
+ try {
260
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
261
+ method: 'PATCH',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ id: item.id, controlId: id, on: next }),
264
+ cache: 'no-store',
265
+ })
266
+ const data = await response.json() as RouterExtResponse
267
+ if (data.ok === true) {
268
+ // 核心 PATCH 后会回发整张表 —— 直接用它对齐,省一次往返。
269
+ const self = data.enhancers?.find((e) => e.id === item.id)
270
+ if (self?.controls) setState(Object.fromEntries(self.controls.map((c) => [c.id, c.on])))
271
+ return
272
+ }
273
+ setError(data.error ?? '切换失败')
274
+ } catch {
275
+ setError('切换失败(网络)')
276
+ } finally {
277
+ // ⚠️ 无论成败都**回读真值**:上面那条乐观更新必须被覆盖,否则失败时
278
+ // 开关停在一个假的成功状态(这正是本仓反复栽过的「假绿」)。
279
+ try {
280
+ const res = await fetch(`${ROUTER_API_BASE}/ext`, { cache: 'no-store' })
281
+ const data = await res.json() as RouterExtResponse
282
+ const self = data.enhancers?.find((e) => e.id === item.id)
283
+ if (self?.controls) setState(Object.fromEntries(self.controls.map((c) => [c.id, c.on])))
284
+ } catch { /* 回读也失败就保留乐观值,错误已经显示 */ }
285
+ setBusy('')
286
+ }
287
+ }
288
+
289
+ const disabled = busy !== ''
290
+ const withBody = controls.filter((c) => typeof c.body === 'string' && c.body !== '')
291
+ // 正在编辑的分类 id(null = 没开弹窗)。
292
+ const [editing, setEditing] = useState<string | null>(null)
293
+ // 正在删除确认的分类 id(null = 没开弹窗)。
294
+ const [removing, setRemoving] = useState<string | null>(null)
295
+ // 「+ 添加准则」弹窗开关。
296
+ const [adding, setAdding] = useState(false)
297
+ // 拖动排序:正在被拖的那一项的下标。
298
+ const [dragIndex, setDragIndex] = useState<number | null>(null)
299
+
300
+ // 浮动提示 2.5s 后自动消失(同 `SupplierDetail.showToast` 的时长)。
301
+ // ⚠️ 不自动消失就会**一直浮在页面上**遮内容 —— 失败是瞬时事件,提示也是。
302
+ useEffect(() => {
303
+ if (error === '') return undefined
304
+ const t = window.setTimeout(() => { setError('') }, 2500)
305
+ return () => { window.clearTimeout(t) }
306
+ }, [error])
307
+ // 标题单独存一份:开关的 `state` 只记 on/off,改标题后列表要立刻跟着变。
308
+ const [titles, setTitles] = useState<Record<string, string>>(() =>
309
+ Object.fromEntries((item.controls ?? []).map((c) => [c.id, c.title])))
310
+ /**
311
+ * 发一个 `op` 动作并**回读真值**。
312
+ *
313
+ * ⚠️ 成功与失败都回读:乐观更新看着顺,但服务端拒了(删内置条目 / 还原没改过的)
314
+ * 时,面板会停在「以为改了却没生效」的状态。
315
+ */
316
+ const act = async (payload: Record<string, unknown>, onDone?: () => void): Promise<void> => {
317
+ setBusy('op')
318
+ setError('')
319
+ try {
320
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
321
+ method: 'PATCH',
322
+ headers: { 'Content-Type': 'application/json' },
323
+ body: JSON.stringify({ id: item.id, ...payload }),
324
+ cache: 'no-store',
325
+ })
326
+ const data = await response.json() as RouterExtResponse
327
+ if (data.ok !== true) {
328
+ setError(friendly(data.error, '操作失败'))
329
+ return
330
+ }
331
+ const self = data.enhancers?.find((e) => e.id === item.id)
332
+ if (self?.controls) {
333
+ setState(Object.fromEntries(self.controls.map((c) => [c.id, c.on])))
334
+ setTitles(Object.fromEntries(self.controls.map((c) => [c.id, c.title])))
335
+ setRows(self.controls)
336
+ }
337
+ onDone?.()
338
+ } catch {
339
+ setError('操作失败(网络)')
340
+ } finally {
341
+ setBusy('')
342
+ }
343
+ }
344
+
345
+ /** 拖动后落盘顺序;失败回读真值。 */
346
+ const persist = async (ids: string[]): Promise<void> => {
347
+ setError('')
348
+ try {
349
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
350
+ method: 'PATCH',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({ id: item.id, op: 'reorder', ids }),
353
+ cache: 'no-store',
354
+ })
355
+ const data = await response.json() as RouterExtResponse
356
+ if (data.ok !== true) {
357
+ setError(friendly(data.error, '排序保存失败'))
358
+ }
359
+ const self = data.enhancers?.find((e) => e.id === item.id)
360
+ if (self?.controls) setRows(self.controls)
361
+ } catch {
362
+ setError('排序保存失败(网络)')
363
+ // ⚠️ 失败**必须回读真值**:否则行还停在拖出来的顺序上,而落盘是旧的
364
+ // ⇒ 刷新一下就跳回去,用户以为没存上。
365
+ try {
366
+ const res = await fetch(`${ROUTER_API_BASE}/ext`, { cache: 'no-store' })
367
+ const data = await res.json() as RouterExtResponse
368
+ const self = data.enhancers?.find((e) => e.id === item.id)
369
+ if (self?.controls) setRows(self.controls)
370
+ } catch { /* 回读也失败就保留当前,错误已显示 */ }
371
+ }
372
+ }
373
+
374
+ // 保存编辑。**成功与失败都回读真值**(同 `toggle` 的纪律):乐观更新看着顺,
375
+ // 但服务端拒了(未知 id / 空串)时面板会停在"改了却没生效"的状态。
376
+ const save = async (controlId: string, patch: { title?: string; body?: string }): Promise<void> => {
377
+ setBusy(controlId)
378
+ setError('')
379
+ try {
380
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
381
+ method: 'PATCH',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ body: JSON.stringify({ id: item.id, controlId, ...patch }),
384
+ cache: 'no-store',
385
+ })
386
+ const data = await response.json() as RouterExtResponse
387
+ if (data.ok !== true) {
388
+ setError(friendly(data.error, '保存失败'))
389
+ return
390
+ }
391
+ // 核心 PATCH 后会回发整张表 —— 直接用它对齐,省一次往返。
392
+ const self = data.enhancers?.find((e) => e.id === item.id)
393
+ if (self?.controls) setState(Object.fromEntries(self.controls.map((c) => [c.id, c.on])))
394
+ if (self?.controls) setTitles(Object.fromEntries(self.controls.map((c) => [c.id, c.title])))
395
+ setEditing(null)
396
+ } catch {
397
+ setError('保存失败(网络)')
398
+ } finally {
399
+ setBusy('')
400
+ }
401
+ }
402
+
403
+ return (
404
+ // ⚠️ **类名全用 DSH 原生的**(`dshr-comp*`,见 `router.css` 与
405
+ // `RouterComponentsSection` 的同款用法):内联 style 会跟着主题走丢
406
+ // (颜色/间距/字号在本仓有 CSS 变量,深浅色两套值),而这一页是设置里
407
+ // 唯一的详情页,不该长成另一个样子。
408
+ <section className="dshr-card">
409
+ {/* `Tight`:本页不要 `.dshr-compGroup` 的 12px 间距(页头已有分割线,
410
+ 再加留白会让分割线悬在空白上);那 12px 是 `RouterComponentsSection`
411
+ 隔「组标题/列表」用的,动全局会打坏那一页。 */}
412
+ <div className="dshr-compGroup dshr-compGroupTight">
413
+ {/* ⚠️ 页头 = `description`([准则 v5])+ 右侧「+ 添加准则」(2026-09-29)。
414
+ **不再单独占一块区域** —— 之前标题自己一个卡、列表另一个卡,
415
+ 进来第一眼是"这块是什么"而不是准则本身。
416
+ 「添加」放在这里而不是页面外层:它与这列表是同一件事
417
+ (增删改条目),而外层拿不到 `adding` 这个状态。 */}
418
+ {/* ⚠️ 页头直接用 `dshr-compRow`(2026-09-30 用户指出):我原先自造了一个
419
+ `dshr-compHeadPad`,只为了"同样的 padding + 分割线"—— 结果是**新旧两份**,
420
+ 连 border 的渲染都和原类对不上。⇒ 复用既有的那一套。
421
+ (注释必须在 `{cond && (` **外面** —— 那个位置只接受一个表达式。) */}
422
+ {item.description !== undefined && item.description !== '' && (
423
+ <div className="dshr-compRow">
424
+ <h4 className="dshr-compTitle">{item.description}</h4>
425
+ <button
426
+ type="button"
427
+ className="dshr-primaryButton"
428
+ onClick={() => { setAdding(true) }}
429
+ style={{ marginLeft: 'auto' }}
430
+ >
431
+ + 添加准则
432
+ </button>
433
+ </div>
434
+ )}
435
+ {/* ⚠️ **连「子开关」这个标题一起去掉了**(2026-09-29)。上一轮去掉计数后
436
+ 它只剩两个字,却仍占一行 —— 页面进来第一眼是「子开关」这三个字而不是
437
+ 那些规则本身。列表自带每条的标题与开关,不需要再套一层说明。 */}
438
+ <ul className="dshr-compRows">
439
+ {controls.map((c, index) => (
440
+ <li
441
+ key={c.id}
442
+ className={`dshr-compRow${dragIndex === index ? ' dshr-linkRowDragging' : ''}`}
443
+ draggable
444
+ onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
445
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
446
+ onDrop={(e) => {
447
+ e.preventDefault()
448
+ if (dragIndex === null || dragIndex === index) { setDragIndex(null); return }
449
+ const next = [...controls]
450
+ const [moved] = next.splice(dragIndex, 1)
451
+ if (moved !== undefined) next.splice(index, 0, moved)
452
+ setDragIndex(null)
453
+ void persist(next.map((x) => x.id))
454
+ }}
455
+ onDragEnd={() => { setDragIndex(null) }}
456
+ >
457
+ {/* 拖把 —— 与 `SupplierDetail` 连接池那一套**同一套**(2026-09-29)。 */}
458
+ <span className="dshr-linkGrip" title="拖动排序" aria-hidden="true">
459
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
460
+ <circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
461
+ <circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
462
+ <circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
463
+ </svg>
464
+ </span>
465
+ <div className="dshr-compRowMain">
466
+ {/* ⚠️ 标题与「修改」按钮**必须包在同一个 flex 行里**(2026-09-29)。
467
+ `.dshr-compRowMain` 是 `flex-direction: column`,而
468
+ `.dshr-compRowName` 是**行内**元素、标题一长就换行 ⇒ 直接把
469
+ 按钮放在 span 后面会掉到**下一行**,看着像"标题下面挂了个东西"。
470
+ ⇒ 用一个 `display:flex` 的行把两者并在一起,且 `min-width:0`
471
+ 让长标题在**这一行内**省略号,而不是把按钮顶下去。 */}
472
+ <div className="dshr-compRowTitleLine">
473
+ <span className="dshr-compRowName">{titles[c.id] ?? c.title}</span>
474
+ {/* ⚠️ 行内**只留「修改」**,且**hover 这一行才出现**(2026-09-29)。
475
+ 14 行都常驻三个图标按钮 = 42 个按钮压过内容;它们绝大多数时候
476
+ 不该被看见。`opacity: 0` + `:focus-within`(见 CSS)保证
477
+ **键盘聚焦时也会显形** —— 纯 `:hover` 会让键盘用户永远够不到。
478
+ 删/还原已挪进修改弹窗(2026-09-29),它们是"次要且有破坏性"的动作,
479
+ 不该和「修改」挤在标题旁边。 */}
480
+ {c.editable === true && (
481
+ <button
482
+ type="button"
483
+ className="dshr-iconBtn dshr-iconBtn-sm dshr-compRowAct"
484
+ aria-label={`修改「${titles[c.id] ?? c.title}」`}
485
+ title={`修改「${titles[c.id] ?? c.title}」的标题与内容`}
486
+ onClick={() => { setEditing(c.id) }}
487
+ >
488
+ <RowIcon d={I_EDIT} />
489
+ </button>
490
+ )}
491
+ </div>
492
+ {/* ⚠️ **原文直接全文展示,不折叠也不限高**(2026-09-29)。
493
+ 中间那版是 `<details>` + `line-clamp: 2`(默认两行、点击展开),
494
+ 理由是"14 条全展开太长" —— 但那是在**替用户决定什么算长**。
495
+ 折叠 + 限高叠在一起还带来两个真问题:
496
+ ① 要看全文得**多点一次**,而"照着原文决定开不开"这个动作
497
+ 被这一步打断;② 同一个 `body` 要渲染**两份**(summary 里
498
+ 一份、展开区一份),两份可能不同步。
499
+ ⇒ 直接一个 `<span>`,内容即所见。 */}
500
+ {typeof c.body === 'string' && c.body !== '' && (
501
+ <span className="dshr-compRowState">{c.body}</span>
502
+ )}
503
+ </div>
504
+ <Switch
505
+ checked={state[c.id] === true}
506
+ disabled={disabled || item.enabled !== true}
507
+ label={`${c.title} 开关`}
508
+ title={item.enabled !== true
509
+ ? '扩展总开关已关闭,子开关暂不生效'
510
+ : (state[c.id] === true ? `关闭 ${c.title}` : `开启 ${c.title}`)}
511
+ onChange={(next) => { void toggle(c.id, next) }}
512
+ />
513
+ </li>
514
+ ))}
515
+ </ul>
516
+ {withBody.length === 0 && (
517
+ <p className="dshr-compHint">本扩展没有提供原文。</p>
518
+ )}
519
+ {/* 添加自定义准则 */}
520
+ {adding && (
521
+ <AddControlModal
522
+ busy={busy === 'add'}
523
+ onClose={() => { setAdding(false) }}
524
+ onSave={(t, b) => {
525
+ void act({ op: 'add', title: t, body: b }, () => { setAdding(false) })
526
+ }}
527
+ />
528
+ )}
529
+
530
+ {/* 删除确认 —— 删除不可撤销,且清掉开关/覆盖/顺序里的关联数据 */}
531
+ {removing !== null && (() => {
532
+ const target = controls.find((c) => c.id === removing)
533
+ if (target === undefined) return null
534
+ return (
535
+ <Modal title="删除自定义准则" onClose={() => { setRemoving(null) }}>
536
+ <div className="dshr-modalForm">
537
+ <p className="dshr-muted">确定删除「{titles[target.id] ?? target.title}」吗?此操作不可撤销。</p>
538
+ <div className="dshr-modalActions">
539
+ <button type="button" className="dshr-miniButton" onClick={() => { setRemoving(null) }}>取消</button>
540
+ <button
541
+ type="button"
542
+ className="dshr-dangerButton"
543
+ disabled={busy !== ''}
544
+ onClick={() => { void act({ op: 'remove', controlId: target.id }, () => { setRemoving(null) }) }}
545
+ >
546
+ 删除
547
+ </button>
548
+ </div>
549
+ </div>
550
+ </Modal>
551
+ )
552
+ })()}
553
+
554
+ {/* 编辑弹窗(在列表之外,避免嵌在 <li> 里影响排版) */}
555
+ {editing !== null && (() => {
556
+ const target = controls.find((c) => c.id === editing)
557
+ if (target === undefined) return null
558
+ return (
559
+ <EditControlModal
560
+ control={{ ...target, title: titles[target.id] ?? target.title }}
561
+ busy={busy === editing}
562
+ onClose={() => { setEditing(null) }}
563
+ onSave={(patch) => { void save(editing, patch) }}
564
+ // 还原/删除后**关掉弹窗**:状态已经变了,再开着会让人以为还能改。
565
+ onReset={() => { void act({ op: 'reset', controlId: editing }, () => { setEditing(null) }) }}
566
+ onRemove={() => { setEditing(null); setRemoving(editing) }}
567
+ />
568
+ )
569
+ })()}
570
+ </div>
571
+ {error !== '' && <div className="dshr-toastWrap" role="status">{error}</div>}
572
+ </section>
573
+ )
574
+ }
575
+
30
576
  interface ExtDetailProps {
31
577
  item: RouterExtItem
32
578
  onBack: () => void
@@ -69,16 +615,13 @@ export function ExtDetail({ item, onBack }: ExtDetailProps): JSX.Element {
69
615
  </div>
70
616
  </div>
71
617
 
72
- {/* 扩展自带的面板(注册表里没有就落回下面这两块通用内容) */}
618
+ {/* 扩展自带的面板优先(注册表命中时下面几块都不渲染) */}
73
619
  {Custom !== undefined ? <Custom /> : (
74
620
  <>
75
- {/* 扩展说明 */}
76
- {item.description !== undefined && item.description !== '' && (
77
- <section className="dshr-card">
78
- <div className="dshr-muted" style={{ padding: '12px 14px' }}>{item.description}</div>
79
- </section>
80
- )}
621
+ {/* 子开关(扩展自己的行为细节;没有 controls 就不渲染这一块) */}
622
+ {item.controls !== undefined && item.controls.length > 0 && <ExtControls item={item} />}
81
623
 
624
+ {/* 实际生效的提示词(只读预览;回答「关掉的真的没进去吗」) */}
82
625
  {/* 未就绪警示(同供应商详情的出错横幅) */}
83
626
  {item.ready === false && (
84
627
  <div className="dshr-alert">
@@ -48,15 +48,11 @@ interface Ctx {
48
48
  * (kind: list, scope: session —— 即宿主 composer 卡片底部操作行
49
49
  * `.uV2eYG_dock` 里、原生上下文环旁边的位置)。
50
50
  * scope 为 session 时宿主给组件注入 `sessionId`,据此按**当前会话**取命中。
51
- *
52
- * **仅宿主 >= 0.1.7 才挂**:composer.dock 这个座位在 0.1.5 也在,但渲染在
53
- * InputBar 之后的独立块(位置不对),只有 0.1.7 才放进底部操作行。座位名相同
54
- * 没法靠契约区分,故先问核心 `/router/api/health` 的 `lastHitDock` 支持位。
55
- * @returns 清理函数;不支持或宿主无 slots 时为空操作
51
+ * @returns 清理函数;宿主没给 slots 时为空操作
56
52
  */
57
- function mountLastHitDock(ctx: Ctx, supported: boolean): () => void {
53
+ function mountLastHitDock(ctx: Ctx): () => void {
58
54
  const slots = ctx.slots
59
- if (slots === undefined || !supported) return () => {}
55
+ if (slots === undefined) return () => {}
60
56
  return slots.inject('conversation.composer.dock', () => slots.register({
61
57
  name: 'conversation.composer.dock',
62
58
  id: 'dsh-router-last-hit',
@@ -64,21 +60,6 @@ function mountLastHitDock(ctx: Ctx, supported: boolean): () => void {
64
60
  }, LastHitDock))
65
61
  }
66
62
 
67
- /**
68
- * 问核心是否支持「最近命中」徽章(宿主 >= 0.1.7)。
69
- * 网络/解析失败一律当**不支持**——宁可不显示,也不在 0.1.5 上摆错位置。
70
- */
71
- async function fetchLastHitDockSupport(): Promise<boolean> {
72
- try {
73
- const res = await fetch(`${ROUTER_API_BASE}/health`)
74
- if (!res.ok) return false
75
- const body = (await res.json()) as { lastHitDock?: unknown }
76
- return body.lastHitDock === true
77
- } catch {
78
- return false
79
- }
80
- }
81
-
82
63
  /** 挂载「设置 → 路由」页;拿不到 slots 时返回 undefined 表示跳过。 */
83
64
  function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
84
65
  const slots = ctx.slots
@@ -131,12 +112,10 @@ function registerExtTestPanel(ctx: Ctx): void {
131
112
 
132
113
  export function apply(ctx: Ctx): void {
133
114
  // 「最近命中」徽章:走官方 conversation.composer.dock(scope: session → 组件
134
- // props 注入 sessionId,按当前会话取命中)。**仅宿主 >= 0.1.7 才挂**——
135
- // composer.dock 在 0.1.5 渲染位置不对,先问核心拿支持位,避免摆错位置。
136
- // 查询是异步的,拿到结果(无论成败)再决定注册与否。
137
- void fetchLastHitDockSupport().then((supported) => {
138
- ctx.effect(() => mountLastHitDock(ctx, supported), 'dsh-router: last-hit dock')
139
- })
115
+ // props 注入 sessionId,按当前会话取命中)。0.2.0 起这个座位无条件可用 ——
116
+ // 0.1.5 那个「渲染位置不对」的老宿主已不支持,所以不再问核心拿支持位,
117
+ // 启动期也就不用多一次 /health 往返。
118
+ ctx.effect(() => mountLastHitDock(ctx), 'dsh-router: last-hit dock')
140
119
  const disposeSettings = mountSettingsSection(ctx)
141
120
  if (disposeSettings === undefined) {
142
121
  // 老宿主(无 slots):回退到侧边栏入口 + 中心栏面板