dsh-config-manager 0.1.76 → 0.1.77

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 (71) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.js +92 -12
  6. package/lib/client.d.ts +104 -9
  7. package/lib/client.js +1796 -714
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-merge.d.ts +22 -0
  13. package/lib/core/rollback-merge.js +203 -0
  14. package/lib/core/rollback-point.d.ts +114 -0
  15. package/lib/core/rollback-point.js +534 -17
  16. package/lib/core/types.d.ts +18 -0
  17. package/lib/index.d.ts +26 -3
  18. package/lib/index.js +129 -16
  19. package/lib/routes/recovery.js +11 -0
  20. package/lib/schema/section-registry.js +5 -5
  21. package/lib/ui/badge-tone.d.ts +16 -0
  22. package/lib/ui/badge-tone.js +20 -0
  23. package/lib/ui/i18n.d.ts +4 -4
  24. package/lib/ui/i18n.js +8 -8
  25. package/lib/ui/rollback-view.d.ts +95 -0
  26. package/lib/ui/rollback-view.js +55 -0
  27. package/lib/ui/selection-model.d.ts +140 -1
  28. package/lib/ui/selection-model.js +241 -9
  29. package/package.json +1 -1
  30. package/src/adapters/plugin-data-declared.ts +60 -0
  31. package/src/adapters/plugin-data.ts +7 -33
  32. package/src/adapters/plugins.ts +98 -13
  33. package/src/adapters/units.test.ts +26 -0
  34. package/src/client/chat/ChatEntryView.tsx +147 -46
  35. package/src/client/chat/chat-entry-view.test.ts +277 -1
  36. package/src/client/chat/chat-entry-view.ts +98 -4
  37. package/src/client/chat/chat-node-registration.ts +71 -0
  38. package/src/client/chat/chat-slots.ts +30 -12
  39. package/src/client/common/ContentPicker.tsx +581 -175
  40. package/src/client/common/host-primitives.ts +64 -1
  41. package/src/client/common/ui.tsx +17 -3
  42. package/src/client/config-manager.module.css +630 -397
  43. package/src/client/css-scale-guard.test.ts +41 -3
  44. package/src/client/css-token-guard.test.ts +10 -4
  45. package/src/client/export/ExportView.tsx +2 -0
  46. package/src/client/index.ts +15 -3
  47. package/src/client/locales.ts +54 -10
  48. package/src/client/recovery/RollbackCard.tsx +56 -2
  49. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  50. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  51. package/src/client/recovery/recovery-locales.ts +22 -0
  52. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  53. package/src/core/messages.ts +2 -0
  54. package/src/core/rollback-attribution.test.ts +120 -0
  55. package/src/core/rollback-attribution.ts +267 -0
  56. package/src/core/rollback-merge.test.ts +133 -0
  57. package/src/core/rollback-merge.ts +213 -0
  58. package/src/core/rollback-point.test.ts +412 -0
  59. package/src/core/rollback-point.ts +560 -20
  60. package/src/core/types.ts +19 -0
  61. package/src/index.facade.test.ts +60 -0
  62. package/src/index.ts +133 -19
  63. package/src/routes/recovery.ts +11 -0
  64. package/src/schema/section-registry.ts +5 -5
  65. package/src/ui/badge-tone.test.ts +23 -0
  66. package/src/ui/badge-tone.ts +27 -0
  67. package/src/ui/i18n.ts +8 -8
  68. package/src/ui/rollback-view.test.ts +27 -0
  69. package/src/ui/rollback-view.ts +139 -0
  70. package/src/ui/selection-model.test.ts +240 -2
  71. package/src/ui/selection-model.ts +349 -1
@@ -58,7 +58,12 @@ import {
58
58
  retryChatPointUntilHit,
59
59
  type ChatPointWindowState,
60
60
  } from './chat-point-window.ts'
61
- import { chatNodeOccupantOf, declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
61
+ import {
62
+ chatNodeOccupantOf,
63
+ declaredSlotKind,
64
+ registerChatNodeEntry,
65
+ registerChatNodeEntryWhenEnabled,
66
+ } from './chat-node-registration.ts'
62
67
  import { en as chatEn, zh as chatZh } from './chat-locales.ts'
63
68
  import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
64
69
 
@@ -236,6 +241,95 @@ test('steering is a DIFFERENT key: we never register it (host renderer unchanged
236
241
  assert.equal(chatNodeKind(nodeOf({ kind: 'user' })), 'user')
237
242
  })
238
243
 
244
+
245
+ test('gate: 关闭时一次都不注册;打开即登记;关掉立刻撤销;再打开再登记', () => {
246
+ let enabled = false
247
+ const listeners = new Set<() => void>()
248
+ const disposed: number[] = []
249
+ let registrations = 0
250
+ let live = 0
251
+ const teardown = registerChatNodeEntryWhenEnabled({
252
+ probe: { spec: () => ({ kind: 'keyed', scope: 'session' }) },
253
+ register: () => {
254
+ registrations += 1
255
+ live += 1
256
+ const id = registrations
257
+ return () => { disposed.push(id); live -= 1 }
258
+ },
259
+ isEnabled: () => enabled,
260
+ subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
261
+ warn: () => {},
262
+ })
263
+ const flip = (value: boolean): void => {
264
+ enabled = value
265
+ for (const listener of [...listeners]) listener()
266
+ }
267
+
268
+ assert.equal(registrations, 0, '缺省关闭:绝不接管 user cell(气泡留给宿主原生渲染)')
269
+ assert.equal(listeners.size, 1, '无论开关如何都要订阅,否则功能打开时补不上登记')
270
+
271
+ flip(true)
272
+ assert.equal(registrations, 1)
273
+ assert.equal(live, 1)
274
+ flip(true)
275
+ assert.equal(registrations, 1, '重复通知必须幂等(同一时间只能有一条登记)')
276
+
277
+ flip(false)
278
+ assert.equal(live, 0, '关闭 = 撤销登记 ⇒ cell 回到宿主 priority 0')
279
+ assert.deepEqual(disposed, [1])
280
+
281
+ flip(true)
282
+ assert.equal(registrations, 2, '重新打开必须当场补登记(不重载插件)')
283
+ assert.equal(live, 1)
284
+
285
+ teardown()
286
+ assert.equal(live, 0, '装配卸载必须撤销登记,不留悬挂注册')
287
+ assert.equal(listeners.size, 0, '装配卸载必须解绑订阅')
288
+ assert.deepEqual(disposed, [1, 2])
289
+ })
290
+
291
+ test('gate: 一直关闭时卸载是安全的(没有登记可撤)', () => {
292
+ let unsubscribed = 0
293
+ const teardown = registerChatNodeEntryWhenEnabled({
294
+ probe: { spec: () => ({ kind: 'keyed', scope: 'session' }) },
295
+ register: () => { throw new Error('must not register while disabled') },
296
+ isEnabled: () => false,
297
+ subscribe: () => () => { unsubscribed += 1 },
298
+ warn: () => {},
299
+ })
300
+ teardown()
301
+ assert.equal(unsubscribed, 1)
302
+ })
303
+
304
+ test('gate: 让位时登记返回的 no-op 也照样能撤销,且不重复探测', () => {
305
+ const warns: string[] = []
306
+ let enabled = true
307
+ let registrations = 0
308
+ const teardown = registerChatNodeEntryWhenEnabled({
309
+ probe: {
310
+ spec: () => ({ kind: 'keyed', scope: 'session' }),
311
+ entriesOfSlot: () => [{ options: { key: 'user', priority: -2 }, registrant: '@someone/other-rewind' }],
312
+ },
313
+ register: () => { registrations += 1; return () => {} },
314
+ isEnabled: () => enabled,
315
+ subscribe: () => () => {},
316
+ warn: (message) => { warns.push(message) },
317
+ })
318
+ assert.equal(registrations, 0, '别的插件以更低优先级占着 user cell:让位')
319
+ assert.ok(warns.some((line) => line.startsWith(CHAT_NODE_YIELD_WARN_PREFIX)))
320
+ enabled = false
321
+ teardown()
322
+ assert.equal(registrations, 0)
323
+ })
324
+
325
+ test('装配点:user cell 的登记走开关闸门(不是无条件遮蔽宿主渲染器)', () => {
326
+ const src = readFileSync(join(HERE, '..', 'index.ts'), 'utf8')
327
+ assert.match(src, /ctx\.slots\.inject\(CHAT_NODE_SLOT, \(\) => registerChatNodeEntryWhenEnabled\(\{/)
328
+ assert.match(src, /isEnabled: isRollbackEntryEnabled/)
329
+ assert.match(src, /subscribe: subscribeRollbackEntryEnabled/)
330
+ assert.doesNotMatch(src, /ctx\.slots\.inject\(CHAT_NODE_SLOT, \(\) => registerChatNodeEntry\(/)
331
+ })
332
+
239
333
  const HERE = fileURLToPath(new URL('.', import.meta.url))
240
334
 
241
335
  /** 读一个组件源码(*.tsx 不能在 node 下 type-strip 加载,故走源码级断言)。 */
@@ -317,6 +411,118 @@ test('文件卡描边与宿主同档:.5px + --cm-line-2(不是更淡的 --cm
317
411
  assert.match(scope, /--cm-line-2:\s*var\(--dsw-alias-border-l2\)/, '.chatEntryScope 必须就地重声明 --cm-line-2')
318
412
  })
319
413
 
414
+ /**
415
+ * 字号 / 行高 / 限宽必须跟随宿主的「内容字号」(2026-10-17 修 issue #93)。
416
+ *
417
+ * 背景:接管 user 渲染器后我们把气泡正文基准写死 `--cm-fs-base: 12.5px`(= §1 的面板基准),
418
+ * 而宿主的 `.bubble` 写的是 `var(--dsh-content-font-size,14px)` —— 于是 DSH 的「内容字号」
419
+ * (10–22)**在用户自己的消息上完全失效**:真机内容字号 21 时助手正文字形高 19px、
420
+ * 气泡 11px(同一张截图,比例 0.58)。宿主口径(本机 app.asar 抽出):
421
+ * · `--dsh-content-font-size` 由 ui-layout 的 ThemePresenter 写在 **body** 上;
422
+ * · `--dsh-content-font-delta` = 内容字号 − 14px,由 ui-theme 定义在 body;
423
+ * · `--dsh-chat-content-width` 定义在 ui-conversation 的聊天 body(.body / .embeddedBody)。
424
+ */
425
+ test('气泡字号/行高/限宽跟随宿主内容字号(不得再写死 12.5px 与固定 620px)', () => {
426
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
427
+ // 注释里会引用这些取值(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard.test.ts 同法
428
+ const noComment = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '')
429
+ const scope = noComment.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
430
+ assert.notEqual(scope, '', '必须能定位 .chatEntryScope 规则块(扫描失效时本用例不得静默通过)')
431
+ // ① 正文基准 = 宿主的活值 + 兜底 14px(宿主同一条气泡的兜底;不许兜回 12.5px —— 那是面板档)
432
+ assert.match(scope, /--cm-fs-base:\s*var\(--dsh-content-font-size,\s*14px\)/, '.chatEntryScope 的 --cm-fs-base 必须跟随 --dsh-content-font-size(兜底 14px)')
433
+ assert.doesNotMatch(scope, /--cm-fs-base:\s*12\.5px/, '不得再把气泡正文写死 12.5px')
434
+ // ② 行高 = 宿主同一条公式(只改字号会让 21px 下比正文松 2.5px/行)
435
+ assert.match(scope, /--cm-lh-base:\s*calc\(22px \+ var\(--dsh-content-font-delta,\s*0px\)\)/, '行高必须用宿主同一条公式')
436
+ assert.match(scope, /font-size:\s*var\(--cm-fs-base\)/, '作用域字号必须消费 --cm-fs-base')
437
+ assert.match(scope, /line-height:\s*var\(--cm-lh-base\)/, '作用域行高必须消费 --cm-lh-base')
438
+ // ③ 只改聊天这一处:§1 body 的面板基准仍是 12.5px(动它会全站字号地震)
439
+ const bodyBlock = noComment.match(/body\s*\{[\s\S]*?\n\}/)?.[0] ?? ''
440
+ assert.match(bodyBlock, /--cm-fs-base:\s*12\.5px/, '§1 body 的面板基准必须保持 12.5px')
441
+ // ④ 限宽 = 宿主 .userStack 同一条公式;--cm-fs-meta 保持 11px 档(时间戳/动作标签不跟着放大)
442
+ assert.doesNotMatch(noComment, /min\(620px,\s*82%\)/, '气泡限宽不得再用固定 620px 近似')
443
+ const stack = noComment.match(/\.chatEntryStack\s*\{[^}]*\}/)?.[0] ?? ''
444
+ const notice = noComment.match(/\.chatEntryNotice\s*\{[^}]*\}/)?.[0] ?? ''
445
+ const WIDTH_FORMULA = /max-width:\s*min\(calc\(var\(--dsh-chat-content-width,\s*748px\) \* \.702\),\s*82%\)/
446
+ for (const [name, block] of [['.chatEntryStack', stack], ['.chatEntryNotice', notice]] as [string, string][]) {
447
+ assert.notEqual(block, '', '必须能定位 ' + name + ' 规则块(扫描失效时本用例不得静默通过)')
448
+ assert.match(block, WIDTH_FORMULA, name + ' 限宽必须与宿主同一条公式')
449
+ }
450
+ assert.match(scope, /--cm-fs-meta:\s*11px/, '--cm-fs-meta 保持 11px 档(时间戳/动作标签不跟着放大)')
451
+ })
452
+
453
+ /**
454
+ * 2026-10-17(用户要求「复制 icon 和 icon 动画也使用 DSH 原生的」):动作行图标必须走宿主
455
+ * ui-primitives 的原生图标集,且**必须保留回落**(老宿主没有那份模块表 ⇒ hostIcon() 回 null)。
456
+ * 宿主口径(app.asar 抽出,ui-primitives/lib/index.js):`IconCopyOutlineRegular` / `IconCheckOutlineRegular`
457
+ * / `IconRefreshOutlineRegular`,都是普通 `(props) => svg` 组件(size 缺省 16、Regular 档 1px 描边、
458
+ * 颜色 currentColor);宿主的复制反馈是**直接换图**(`.action svg` 没有任何 transition/animation)。
459
+ */
460
+ test('动作行图标走宿主原生(复制 → 对勾 → 回退),拿不到宿主图标时必须回落本仓库图标', () => {
461
+ const entry = sourceOf('ChatEntryView.tsx')
462
+ for (const name of ['IconCopyOutlineRegular', 'IconCheckOutlineRegular', 'IconRefreshOutlineRegular']) {
463
+ assert.match(entry, new RegExp("hostIcon\\('" + name + "'\\)"), '动作行图标必须按名字取宿主的 ' + name)
464
+ }
465
+ assert.match(entry, /<CopyStateIcon done=\{copied\} size=\{15\} \/>/, '拿不到宿主图标时必须回落 morphicons 的 CopyStateIcon(不能变成空白按钮)')
466
+ assert.match(entry, /<RefreshIcon size=\{15\} \/>/, '拿不到宿主图标时必须回落本仓库的 RefreshIcon')
467
+ assert.match(entry, /copied \? <HostCheckIcon size=\{15\} \/> : <HostCopyIcon size=\{15\} \/>/, '复制反馈必须是宿主的**换图**(不是形变动画)')
468
+ // 闸门只有一处:宿主图标必须经 common/host-primitives.ts 的 hostIcon(不许在组件里裸 require)
469
+ assert.doesNotMatch(entry, /require\(['"]@deepseek-ai/, '组件里不得裸 require 宿主包(唯一出口 = host-primitives.ts)')
470
+ const gate = readFileSync(join(HERE, '..', 'common', 'host-primitives.ts'), 'utf8')
471
+ assert.match(gate, /export function hostIcon\(name: DshIconName\)/, 'hostIcon 必须按闭集名字取图标')
472
+ assert.match(gate, /type DshIconName = 'IconCopyOutlineRegular' \| 'IconCheckOutlineRegular' \| 'IconRefreshOutlineRegular'/, '闭集必须逐字列这三颗,不搬整张图标表')
473
+ })
474
+
475
+ /**
476
+ * 2026-10-17(用户要求「与 DSH 原生气泡完全一致」):§15b 的几何/字号必须与宿主
477
+ * MessageItem + MessageIconActions 的**同一条取值**逐字同式(宿主产物抽出的取值清单见
478
+ * docs/spec/known-gaps.md 的「G-39 增量(八)」)。这张用例是那条口径的源级护栏:
479
+ * 谁把圆角/内边距/动作行/文件卡改回「近似档」,这里立刻红。
480
+ */
481
+ test('气泡与动作行逐值对齐宿主:圆角 xl(20px) / 内边距 10·16 / 动作行 28+delta / 文件卡 240·64', () => {
482
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
483
+ // 注释里会引用这些取值(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard 同法
484
+ const noComment = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '')
485
+ const scope = noComment.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
486
+ assert.notEqual(scope, '', '必须能定位 .chatEntryScope 规则块(扫描失效时本用例不得静默通过)')
487
+ // ① 镜像 token:宿主 radius xl=20(气泡/文件卡)、sm=8(动作按钮)、动作行与图标跟着 delta 长
488
+ assert.match(scope, /--cm-r-chat:\s*var\(--dsw-radius-xl,\s*20px\)/, '气泡/文件卡圆角必须镜像宿主 --dsw-radius-xl(20px)')
489
+ assert.match(scope, /--cm-r-act:\s*var\(--dsw-radius-sm,\s*8px\)/, '动作按钮圆角必须镜像宿主 --dsw-radius-sm(8px)')
490
+ assert.match(scope, /--cm-chat-action:\s*calc\(28px \+ var\(--dsh-content-font-delta,\s*0px\)\)/, '动作行高度/触达区必须与宿主同式(28px + delta)')
491
+ assert.match(scope, /--cm-chat-glyph:\s*calc\(15px \+ var\(--dsh-content-font-delta,\s*0px\)\)/, '动作图标必须与宿主同式(15px + delta)')
492
+
493
+ // ② 气泡:宿主 .bubble 是**块**(尺寸 100% / 内边距 10px 16px / 圆角 xl)
494
+ const bubble = noComment.match(/\.chatBubble\s*\{[^}]*\}/)?.[0] ?? ''
495
+ assert.notEqual(bubble, '', '必须能定位 .chatBubble 规则块')
496
+ assert.match(bubble, /max-width:\s*100%/)
497
+ assert.match(bubble, /padding:\s*var\(--cm-sp-5\) var\(--cm-sp-8\)/, '内边距必须等于宿主的 10px 16px')
498
+ assert.match(bubble, /border-radius:\s*var\(--cm-r-chat\)/, '.chatBubble 必须消费 --cm-r-chat')
499
+ assert.doesNotMatch(bubble, /display:\s*flex/, '宿主 .bubble 是块级:不得再用 flex 列(技能/引用 chip 会被挤成独立一行)')
500
+
501
+ // ③ 动作行:宿主 MessageIconActions(gap 8 / 高 28+delta)+ MessageItem 的 4px / -6px 外边距
502
+ const actions = noComment.match(/\.chatEntryActions\s*\{[^}]*\}/)?.[0] ?? ''
503
+ assert.notEqual(actions, '', '必须能定位 .chatEntryActions 规则块')
504
+ assert.match(actions, /gap:\s*var\(--cm-sp-4\)/, '动作行 gap 必须等于宿主的 8px')
505
+ assert.match(actions, /height:\s*var\(--cm-chat-action\)/, '动作行高度必须跟随宿主的 28px + delta')
506
+ assert.match(actions, /margin-top:\s*var\(--cm-sp-2\)/, '动作行上方必须留宿主的 4px')
507
+ assert.match(actions, /margin-left:\s*calc\(-1 \* var\(--cm-sp-3\)\)/, '动作行必须用宿主同一条负外边距(-6px)对齐气泡右缘')
508
+
509
+ // ④ 时刻与文件卡
510
+ const time = noComment.match(/\.chatEntryTime\s*\{[^}]*\}/)?.[0] ?? ''
511
+ assert.notEqual(time, '', '必须能定位 .chatEntryTime 规则块')
512
+ assert.match(time, /font-size:\s*var\(--cm-fs-second\)/, '时刻必须用宿主的次级字号镜像(13px 档)')
513
+ assert.match(time, /padding-right:\s*var\(--cm-sp-6\)/, '时刻右侧必须留宿主的 12px')
514
+ const card = noComment.match(/\.chatFileCard\s*\{[^}]*\}/)?.[0] ?? ''
515
+ assert.notEqual(card, '', '必须能定位 .chatFileCard 规则块')
516
+ assert.match(card, /width:\s*240px/, '文件卡宽度必须等于宿主的 240px')
517
+ assert.match(card, /min-height:\s*64px/, '文件卡最小高度必须等于宿主的 64px')
518
+ assert.match(card, /gap:\s*var\(--cm-sp-5\)/, '文件卡内图标与文字间距必须等于宿主的 10px')
519
+ assert.match(card, /border-radius:\s*var\(--cm-r-chat\)/, '文件卡圆角必须与气泡同一档(宿主 xl)')
520
+
521
+ // ⑤ 动作行的显现语义:宿主靠 data-chat-flow-kind + :has(~ …) 表达「后面还有用户消息就藏起来」
522
+ assert.match(noComment, /data-chat-flow-kind='user'/, '显现语义必须复用宿主自己的 data-chat-flow-kind 属性')
523
+ assert.match(noComment, /:has\(~ :is\(\[data-chat-flow-kind='user'\]/, '必须与宿主同一条「后面还有用户/steering 消息 ⇒ 动作行默认隐藏」的规则')
524
+ })
525
+
320
526
  /**
321
527
  * 宿主正文投影(2026-10-15):气泡正文优先交给宿主自己的 projectUserText(会话引用/技能 → chip)。
322
528
  * 四条回落判据必须逐条可证 —— 任一条不成立都回落我们自己的纯文本投影(绝不空白、绝不抛)。
@@ -1161,6 +1367,76 @@ test('attachments: one ordered list in content order + compact counts ALL attach
1161
1367
  assert.deepEqual(mixed.images.map((image) => image.attachment), [{ id: 'i1' }, { id: 'i2' }])
1162
1368
  })
1163
1369
 
1370
+ test('issue #92:hostAttachments 保留宿主原始载荷、顺序与 content 一致(我们的 ChatEntryFile 拼不回来)', () => {
1371
+ const image = { id: 'i1', width: 10 }
1372
+ const fileRef = { id: 'f1', name: 'a.txt', bytes: 3, mimeType: 'text/plain' }
1373
+ const view = buildChatEntryBubble([
1374
+ { type: 'file', attachment: fileRef },
1375
+ { type: 'image', attachment: image },
1376
+ { type: 'file', attachment: { name: 'b.txt', bytes: 4 } },
1377
+ ])
1378
+ // 宿主 contentParts 的形状逐字:image 包一层 { attachment },file 是原始 FileAttachmentRef
1379
+ assert.deepEqual(view.hostAttachments, [
1380
+ { type: 'file', file: fileRef },
1381
+ { type: 'image', image: { attachment: image } },
1382
+ { type: 'file', file: { name: 'b.txt', bytes: 4 } },
1383
+ ])
1384
+ assert.equal(view.hostAttachments.length, view.attachments.length, '两份列表同趟走查、同序(不得只算一半)')
1385
+ // 没有 attachment 的块照旧只进 rest:两份列表都不许凭空多一项
1386
+ assert.deepEqual(view.hostAttachments.filter((item) => item.type === 'image').length, 1)
1387
+ const hostile = buildChatEntryBubble([{ type: 'image' }, { type: 'file' }])
1388
+ assert.equal(hostile.hostAttachments.length, 0)
1389
+ assert.equal(hostile.raw.length, 2)
1390
+ // 非数组 / 字符串 content 的安全值里也必须带着这个字段(渲染层会直接读它)
1391
+ assert.deepEqual(buildChatEntryBubble('hi').hostAttachments, [])
1392
+ assert.deepEqual(buildChatEntryBubble(undefined).hostAttachments, [])
1393
+ })
1394
+
1395
+ test('issue #92:content factory 入参 = 宿主 UserStyleBubble 的 prepared/facts(原文 + rest + 字段存在性)', () => {
1396
+ const content = [
1397
+ { type: 'text', text: 'see @[S](dsh-session:1)' },
1398
+ { type: 'image', attachment: { id: 'i1' } },
1399
+ { type: 'mystery', payload: { deep: 1 } },
1400
+ ]
1401
+ const bare = buildChatEntryView({ kind: 'user', location: { kind: 'step', turn: { turn: 1, status: 'closed' } }, data: { content } })
1402
+ // text 必须是**未投影**的原文(chip 由宿主 factory 内部投影),attachments/extraBlocks 逐字带过去
1403
+ assert.equal(bare.hostContent.content.text, 'see @[S](dsh-session:1)')
1404
+ assert.deepEqual(bare.hostContent.content.attachments, [{ type: 'image', image: { attachment: { id: 'i1' } } }])
1405
+ assert.deepEqual(bare.hostContent.content.extraBlocks, [{ type: 'mystery', payload: { deep: 1 } }])
1406
+ // 宿主用「字段是否存在」区分「没带」与「空」⇒ 没带时两个对象里都不许出现这三个键
1407
+ assert.equal('referenceLabels' in bare.hostContent.content, false)
1408
+ assert.equal('sessionLabels' in bare.hostContent.facts, false)
1409
+ assert.equal('skillNames' in bare.hostContent.facts, false)
1410
+
1411
+ const withData = buildChatEntryView({
1412
+ kind: 'user',
1413
+ location: { kind: 'step', turn: { turn: 1, status: 'closed' } },
1414
+ data: { content: [{ type: 'text', text: 'q' }], referenceLabels: ['s-a', 7], skillNames: ['changelog-writing'] },
1415
+ })
1416
+ assert.deepEqual(withData.hostContent.content.referenceLabels, ['s-a'], '非字符串项照旧被剔除')
1417
+ assert.deepEqual(withData.hostContent.facts.sessionLabels, ['s-a'])
1418
+ assert.deepEqual(withData.hostContent.facts.skillNames, ['changelog-writing'])
1419
+ // 空数组是**真的带了空**(与「没带」不同)
1420
+ const emptyLabels = buildChatEntryView({
1421
+ kind: 'user',
1422
+ location: { kind: 'step', turn: { turn: 1, status: 'closed' } },
1423
+ data: { content: [{ type: 'text', text: 'q' }], referenceLabels: [], skillNames: [] },
1424
+ })
1425
+ assert.deepEqual(emptyLabels.hostContent.content.referenceLabels, [])
1426
+ assert.deepEqual(emptyLabels.hostContent.facts.sessionLabels, [])
1427
+ })
1428
+
1429
+ test('issue #92:组件把正文委托给宿主 content factory,并把自绘气泡当 fallback(双栈,不在注册期赌形态)', () => {
1430
+ const src = sourceOf('ChatEntryView.tsx')
1431
+ assert.match(src, /CHAT_USER_CONTENT_FACTORY/, '必须按名字委托宿主的 content factory')
1432
+ assert.match(src, /typeof renderFactorySlot === 'function'/, '委托前必须探测该席位是否存在(老宿主没有)')
1433
+ assert.match(src, /fallback:\s*ownContent/, '自绘气泡必须是那次调用的 fallback(factory 缺失时不许出空白气泡)')
1434
+ assert.match(src, /interactive:\s*true/, '宿主对用户消息传 interactive: true,必须同构')
1435
+ assert.match(src, /loadImage,/, '宿主授权过的图片加载器必须原样转发')
1436
+ // 反向:图片**不许**再由我们自己 renderMessageImages 一途承载(0.2.1 起该 props 已不存在)
1437
+ assert.match(src, /const content = typeof renderFactorySlot/, '正文/附件渲染的唯一出口是这一处三元')
1438
+ })
1439
+
1164
1440
  test('bubble file cards: file name + extension + size; a missing byte count stays unknown (no fake 0 B)', () => {
1165
1441
  const view = buildChatEntryBubble([
1166
1442
  { type: 'file', attachment: { name: 'C:/tmp/report.final.PDF', bytes: 1536 } },
@@ -191,6 +191,56 @@ export type ChatEntryAttachment =
191
191
  | { kind: 'image'; image: ChatEntryImage }
192
192
  | { kind: 'file'; file: ChatEntryFile }
193
193
 
194
+ /* ------------------------------------------------------------ 宿主 content factory(0.2.1+ 委托路径) */
195
+
196
+ /**
197
+ * 宿主 `conversation.chat.user.content`(Factory 位)的名字。
198
+ *
199
+ * 0.2.1-alpha.3 起用户气泡的正文/附件/引用**整体换轨**到这条 factory:图片位
200
+ * `conversation.chat.user.images` 是它的 children(single/session),旧的 `conversation.message.images`
201
+ * 与节点 props 上的 `renderMessageImages` 同时删除(本机解包 0.2.1-rc.2 实测:后者 0 次出现)。
202
+ * 因此「接管 user 之后自己复刻气泡」这条路在 0.2.1 上拿不到图片位(issue #92)——正确做法是把正文
203
+ * **委托回宿主自己的 content factory**,图片由 ui-attachment 在该 factory 的渲染上下文里 renderSlot。
204
+ *
205
+ * 老宿主(≤0.2.0)没有这条 factory:`FactoryOutlet` 在定义缺失时回落调用方给的 `options.fallback`
206
+ * ⇒ 渲染层把**自绘气泡**当 fallback 传进去,0.1.x/0.2.0 的路径逐字不变(一个版本双栈兼容)。
207
+ */
208
+ export const CHAT_USER_CONTENT_FACTORY = 'conversation.chat.user.content'
209
+
210
+ /**
211
+ * 宿主 content factory 的附件项(逐字保留宿主 content 里的原始 attachment 载荷)。
212
+ *
213
+ * 形状逐字取自宿主 `contentParts`:`{ type:'image', image:{ attachment } }` /
214
+ * `{ type:'file', file: block.attachment }` —— `file` 那一支是我们的 ChatEntryFile **拼不回来**的
215
+ * `FileAttachmentRef`,所以必须原样留一份。
216
+ */
217
+ export type HostUserContentAttachment =
218
+ | { type: 'image'; image: { attachment: unknown } }
219
+ | { type: 'file'; file: unknown }
220
+
221
+ /** 传给宿主 content factory 的正文载荷(= 宿主 UserStyleBubble 的 `prepared`)。 */
222
+ export interface HostUserContentData {
223
+ /** 拼接后的**原文**(未投影;宿主 content factory 内部自己投影会话引用/技能 chip)。 */
224
+ text: string
225
+ attachments: HostUserContentAttachment[]
226
+ /** 未知 / rest 块(宿主交给 JsonBlock,**必须**原样带过去)。 */
227
+ extraBlocks: unknown[]
228
+ /** 仅当节点 data 真的带了该字段时出现(宿主用「字段是否存在」区分「没带」与「空」)。 */
229
+ referenceLabels?: string[]
230
+ }
231
+
232
+ /** 传给宿主 content factory 的文本投影事实(= 宿主 UserStyleBubble 的 `facts`)。 */
233
+ export interface HostUserContentFacts {
234
+ sessionLabels?: string[]
235
+ skillNames?: string[]
236
+ }
237
+
238
+ /** 一次 `renderFactorySlot(CHAT_USER_CONTENT_FACTORY, …)` 的入参(`loadImage`/`interactive` 由渲染层补)。 */
239
+ export interface HostUserContentInput {
240
+ content: HostUserContentData
241
+ facts: HostUserContentFacts
242
+ }
243
+
194
244
  export interface ChatEntryBubbleView {
195
245
  /** 文本块拼接结果(宿主 `texts.join('')`,逐字保留换行与空白;会话引用的线格式已投影成 label)。 */
196
246
  text: string
@@ -201,6 +251,12 @@ export interface ChatEntryBubbleView {
201
251
  rawText: string
202
252
  /** **有序附件列表**:渲染层必须按这个顺序输出(宿主不区分「先图片后文件」)。 */
203
253
  attachments: ChatEntryAttachment[]
254
+ /**
255
+ * **宿主形状**的有序附件列表(0.2.1+ 委托路径专用):与 `attachments` 同一趟走查、同一顺序,
256
+ * 但保留宿主 content 里的**原始 attachment 载荷**(我们的 ChatEntryFile 只留了三个展示字段,
257
+ * 拼不回宿主 `FileAttachmentRef`)。逐字对齐宿主 `contentParts` 的 `attachments.push`。
258
+ */
259
+ hostAttachments: HostUserContentAttachment[]
204
260
  /** 图片附件(= `attachments` 里的 image 项,顺序一致;用于计数与「无渲染位」提示)。 */
205
261
  images: ChatEntryImage[]
206
262
  /** 文件附件(= `attachments` 里的 file 项,顺序一致)。 */
@@ -414,14 +470,15 @@ export function fileSizeTextOf(bytes: number | null): string | null {
414
470
  export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
415
471
  if (typeof content === 'string') {
416
472
  const text = projectChatEntryText(content)
417
- return { text, rawText: content, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
473
+ return { text, rawText: content, attachments: [], hostAttachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
418
474
  }
419
475
  if (!Array.isArray(content)) {
420
- return { text: '', rawText: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
476
+ return { text: '', rawText: '', attachments: [], hostAttachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
421
477
  }
422
478
  const texts: string[] = []
423
479
  const rawTexts: string[] = []
424
480
  const attachments: ChatEntryAttachment[] = []
481
+ const hostAttachments: HostUserContentAttachment[] = []
425
482
  const raw: ChatEntryRawBlock[] = []
426
483
  content.forEach((block, index) => {
427
484
  const record = asRecord(block)
@@ -435,6 +492,8 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
435
492
  }
436
493
  if (type === 'image' && record?.['attachment'] !== undefined) {
437
494
  attachments.push({ kind: 'image', image: { attachment: record['attachment'] } })
495
+ // 同一趟走查里并出宿主形状的那一份(同一顺序、原始载荷)—— 见 HostUserContentAttachment
496
+ hostAttachments.push({ type: 'image', image: { attachment: record['attachment'] } })
438
497
  return
439
498
  }
440
499
  if (type === 'file' && record?.['attachment'] !== undefined) {
@@ -449,6 +508,7 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
449
508
  bytes: typeof bytes === 'number' && Number.isFinite(bytes) ? bytes : null,
450
509
  },
451
510
  })
511
+ hostAttachments.push({ type: 'file', file: record['attachment'] })
452
512
  return
453
513
  }
454
514
  raw.push({ id: String(index), payload: block })
@@ -458,7 +518,34 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
458
518
  // images / files 是 attachments 的**派生视图**(不独立积累,避免两处真相与顺序漂移)
459
519
  const images = attachments.flatMap((item) => (item.kind === 'image' ? [item.image] : []))
460
520
  const files = attachments.flatMap((item) => (item.kind === 'file' ? [item.file] : []))
461
- return { text, rawText, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
521
+ return { text, rawText, attachments, hostAttachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
522
+ }
523
+
524
+ /**
525
+ * 节点 data + 气泡视图 → 宿主 content factory 的入参(0.2.1+ 委托路径的**唯一**组装点)。
526
+ *
527
+ * 逐字对齐宿主 `UserStyleBubble` 的 `prepared` / `facts` 两段 useMemo:
528
+ * · `content.text` = 未投影的文本拼接(宿主 factory 自己把它交给 `conversation.user.text` 投影成 chip);
529
+ * · `content.attachments` = 宿主形状的有序附件(同一趟走查产出);
530
+ * · `content.extraBlocks` = rest 块原样;
531
+ * · `referenceLabels` / `sessionLabels` / `skillNames` **只在节点真的带了该字段时出现** ——
532
+ * 宿主用「字段是否存在」区分「没带」与「空数组」,我们传 `[]` 会改变它的分支。
533
+ *
534
+ * 本函数不抛(读不到就按「没带」处理),与 buildChatEntryBubble 同一条纪律。
535
+ */
536
+ export function buildHostUserContentInput(data: ChatEntryNodeDataView, bubble: ChatEntryBubbleView): HostUserContentInput {
537
+ const content: HostUserContentData = {
538
+ text: bubble.rawText,
539
+ attachments: bubble.hostAttachments,
540
+ extraBlocks: bubble.raw.map((block) => block.payload),
541
+ }
542
+ const facts: HostUserContentFacts = {}
543
+ if (data.referenceLabels !== null) {
544
+ content.referenceLabels = data.referenceLabels
545
+ facts.sessionLabels = data.referenceLabels
546
+ }
547
+ if (data.skillNames !== null) facts.skillNames = data.skillNames
548
+ return { content, facts }
462
549
  }
463
550
 
464
551
  /** 渲染计划的一项:图片带 owner 载荷,文件带名称/扩展名/体积文本。 */
@@ -631,19 +718,26 @@ export interface ChatEntryView {
631
718
  */
632
719
  referenceLabels: string[]
633
720
  skillNames: string[]
721
+ /**
722
+ * 宿主 content factory 的入参(0.2.1+ 委托路径)。**始终**算好:新宿主用它委托,老宿主把它
723
+ * 闲置(那条 factory 不存在 ⇒ 渲染层走自绘气泡 fallback),两条路径都不需要再读一次节点。
724
+ */
725
+ hostContent: HostUserContentInput
634
726
  }
635
727
 
636
728
  /** 节点 → 整视图模型(读不到一律回安全值;本函数不抛)。 */
637
729
  export function buildChatEntryView(node: unknown): ChatEntryView {
638
730
  const data = readChatEntryNodeData(node)
639
731
  const turn = chatNodeTurn(node)
732
+ const bubble = buildChatEntryBubble(data.content)
640
733
  return {
641
734
  turnNumber: turn?.turn ?? null,
642
735
  turnStatus: turn?.status ?? null,
643
- bubble: buildChatEntryBubble(data.content),
736
+ bubble,
644
737
  clock: formatChatEntryClock(data.time),
645
738
  referenceLabels: data.referenceLabels ?? [],
646
739
  skillNames: data.skillNames ?? [],
740
+ hostContent: buildHostUserContentInput(data, bubble),
647
741
  }
648
742
  }
649
743
 
@@ -8,6 +8,11 @@
8
8
  * 2026-10-12(用户要求「聊天内回退入口只有每条用户消息」):旧的「整回合」档连同它的偏好模块
9
9
  * (`chat-entry-pref` / `chat-entry-mount`)与回合尾入口(`conversation.chat.turnTail`)一并删除 ——
10
10
  * 入口恒为每条用户消息气泡里的那一颗,于是这里不再有偏好门,也不再需要「切档时重新评估」的订阅。
11
+ *
12
+ * 2026-10-17(用户要求):开关**关闭时不得再接管用户消息气泡**。原实现是「登记常驻、只收起按钮」——
13
+ * 功能关掉之后那条气泡仍由我们代画(即使正文已委托宿主 factory,外层壳与动作行仍是我们的)。
14
+ * 现在登记本身随开关出现 / 撤销(`registerChatNodeEntryWhenEnabled`):关掉即让出 user cell,
15
+ * 宿主 `UserMessageNodeView` 原样接管回来。
11
16
  */
12
17
  import {
13
18
  CHAT_NODE_SLOT,
@@ -96,3 +101,69 @@ export function registerChatNodeEntry(options: {
96
101
  return () => {}
97
102
  }
98
103
  }
104
+
105
+ /**
106
+ * 开关驱动的登记闸门:**只在功能开启时才接管 user cell**。
107
+ *
108
+ * 为什么不是「常驻登记 + 组件里隐藏按钮」:接管 = priority -1 遮蔽宿主 `UserMessageNodeView`,
109
+ * 只要登记还在,那条气泡(含外层壳与动作行)就由我们画 —— 用户 2026-10-17 明确要求关闭后
110
+ * **完全不要接管**,气泡交回宿主原生渲染。反过来,开关打开时**必须**有登记,否则入口无处可放
111
+ * (该 key 没有 children 表,见 `chat-slots.ts`)。
112
+ *
113
+ * 纯逻辑(开关读写与订阅都从外面注进来),便于在 node:test 下逐条断言:
114
+ * · 关闭 → 一次注册都不发生;
115
+ * · 开关转开 → 登记一次(重复通知幂等);
116
+ * · 开关转关 → **立刻撤销登记**(cell 回到宿主 priority 0);
117
+ * · 装配卸载 → 解绑订阅 + 撤销登记,不留悬挂注册。
118
+ *
119
+ * @returns 卸载函数(调用点 = `ctx.slots.inject` 的回调返回值)。
120
+ */
121
+ export interface ChatNodeEntryGateOptions {
122
+ /** 槽位探测面(同 {@link registerChatNodeEntry};探测只在真正注册那一次发生)。 */
123
+ probe: ChatNodeSlotProbe | undefined | null
124
+ /** 真正的注册调用(调用点包 `ctx.slots.register`,保持编译期校验)。 */
125
+ register: () => () => void
126
+ /** 功能此刻是否开启(`rollbackEnabled` 的进程内信号)。 */
127
+ isEnabled: () => boolean
128
+ /** 订阅开关变化(返回解绑函数)。 */
129
+ subscribe: (listener: () => void) => () => void
130
+ /** 诊断回调(缺省 `console.warn`;单测注入以免污染输出)。 */
131
+ warn?: (message: string) => void
132
+ }
133
+
134
+ export function registerChatNodeEntryWhenEnabled(options: ChatNodeEntryGateOptions): () => void {
135
+ /** 当前登记的解绑函数;null = 现在没有登记(= 宿主渲染器在渲染那条 cell)。 */
136
+ let disposeEntry: (() => void) | null = null
137
+
138
+ const sync = (): void => {
139
+ if (options.isEnabled() === true) {
140
+ if (disposeEntry === null) {
141
+ disposeEntry = registerChatNodeEntry({
142
+ probe: options.probe,
143
+ register: options.register,
144
+ ...(options.warn === undefined ? {} : { warn: options.warn }),
145
+ })
146
+ }
147
+ return
148
+ }
149
+ if (disposeEntry !== null) {
150
+ const dispose = disposeEntry
151
+ disposeEntry = null
152
+ dispose()
153
+ }
154
+ }
155
+
156
+ const unsubscribe = options.subscribe(sync)
157
+ // 订阅**先建后读**:读到与订阅之间没有窗口,开关恰在此刻翻转也不会漏(多跑一次 sync 是幂等的)。
158
+ sync()
159
+
160
+ return () => {
161
+ unsubscribe()
162
+ if (disposeEntry !== null) {
163
+ const dispose = disposeEntry
164
+ disposeEntry = null
165
+ dispose()
166
+ }
167
+ }
168
+ }
169
+
@@ -9,19 +9,25 @@
9
9
  *
10
10
  * ## 镜像来源(**逐条核过真机 app.asar,不是猜的**)
11
11
  *
12
- * 槽位声明 = `packages/client/ui-chat/src/client/contract/slots.ts`(整包只声明 8 个槽位:
13
- * conversation.chat.node / conversation.message.images / conversation.chat.commandview /
14
- * conversation.chat.turnTail / conversation.chat.assistant-actions / conversation.view /
15
- * conversation.composer.dock / conversation.approval.detail)。
12
+ * 槽位声明 = `packages/client/ui-chat/src/client/contract/slots.ts`。
16
13
  *
17
14
  * ### `conversation.chat.node`(本插件**接管**其 `user` key)
18
15
  *
19
16
  * kind: 'keyed' · scope: 'session'
20
- * ownerProps: { groupPart?, cwd, openFile, openSkill, inspectCall, forkAt, loadImage,
21
- * renderMessageImages, fileMentions, turnProcess, node }
22
17
  * registerOptions: key(必填) / priority(可选,= cell 遮蔽秩,升序、最低者渲染)
23
18
  * replaceRisk: **有**(这是 keyed 遮蔽位:priority -1 会顶掉 shipped 的 UserMessageNodeView)
24
19
  *
20
+ * ownerProps **在 0.2.1-alpha.3 换过一代**(本机解包 `@deepseek-ai/dsh-client-ui-chat` 的
21
+ * `lib/client.js` + `lib/types/.../slots.d.ts` 实测,2026-10-17,issue #92):
22
+ *
23
+ * ≤0.2.0 :{ groupPart?, cwd, openFile, openSkill, inspectCall, forkAt, loadImage,
24
+ * renderMessageImages, fileMentions, turnProcess, node }
25
+ * ≥0.2.1 :{ groupPart?, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, node }
26
+ * —— **删掉 `renderMessageImages` 与 `openSkill`**;图片位不再由 ui-chat 直接声明,
27
+ * 而是 `conversation.chat.user.content` factory 的 children
28
+ * (`conversation.chat.user.images`, single/session)⇒ 想拿到图片**必须**委托那条 factory
29
+ * (实现与判据见 `ChatEntryView.tsx` / `CHAT_USER_CONTENT_FACTORY`)。
30
+ *
25
31
  * 关键事实(决定了本功能只能走这条路,见 T1 output 的证据段):
26
32
  * · 宿主对 `{ name:'conversation.chat.node', key:'user' }` **没有声明 children 表**
27
33
  * (对比 turn-tail 节点声明了 turnTail + assistant-actions 两个 list 子位)
@@ -84,14 +90,26 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
84
90
  */
85
91
  node: unknown
86
92
  /**
87
- * 图片渲染位(宿主 `conversation.message.images` 的 renderSlot 绑定)——
88
- * **必须**用它渲染图片(ui-chat 的私有实现不可导入)。
93
+ * 图片渲染位(**≤0.2.0** 宿主:`conversation.message.images` 的 renderSlot 绑定)——
94
+ * 必须用它渲染图片(ui-chat 的私有实现不可导入)。
95
+ *
96
+ * **0.2.1-alpha.3 起宿主整行删掉了它**(issue #92:新契约改由
97
+ * `conversation.chat.user.content` factory 的 `conversation.chat.user.images` 子位承载)
98
+ * ⇒ 镜像里跟着改成**可选**:渲染层据「它在不在」决定走旧的自绘图片位,还是委托 content factory。
99
+ */
100
+ renderMessageImages?: (owner: unknown) => unknown
101
+ /**
102
+ * 宿主授权过的图片加载器(0.2.1 起节点渲染器 props 上的稳定席位;转发给 content factory)。
103
+ * 老宿主上拿不到它(那时 loadImage 被封在 renderMessageImages 的闭包里)。
89
104
  */
90
- renderMessageImages: (owner: unknown) => unknown
105
+ loadImage?: unknown
91
106
  /** 宿主提供的文件打开器(本插件不消费,保持契约完整)。 */
92
- openFile: (path: string) => void
93
- /** 宿主提供的 skill 打开器(同上)。 */
94
- openSkill: (name: string) => void
107
+ openFile?: (path: string) => void
108
+ /**
109
+ * 宿主提供的 skill 打开器(同上)。0.2.1 起该席位从 ChatNodeOwnerProps 消失
110
+ * (与 renderMessageImages 同一批)⇒ 同样改成可选。
111
+ */
112
+ openSkill?: (name: string) => void
95
113
  }
96
114
  }
97
115
  owner: {