dsh-config-manager 0.1.72 → 0.1.76
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 +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +103 -10
- package/lib/client.js +10748 -10119
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +5 -3
- package/lib/ui/i18n.js +10 -6
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +10 -6
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
|
@@ -9,56 +9,18 @@
|
|
|
9
9
|
* ② **DSH 自带**:`@deepseek-ai/dsh-client-ui-primitives` 的 `FileTypeIcon` —— 这正是 DSH 左侧文件树
|
|
10
10
|
* (`dsh-client-ui-sidebar-files` 的 FilesBody)逐字使用的那颗组件(同一份按类型着色的画集)。
|
|
11
11
|
*
|
|
12
|
-
* ##
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* 顶层 `import` 会被打包器改写成**模块体顶层**的 `require`,那一刻抛错会让**整个浏览器半加载失败**
|
|
17
|
-
* (设置页一起没)—— 为了「一颗图标」赔掉整插件,绝不。所以这里:
|
|
18
|
-
* · 只在**真要画 icon 时**才 require(帧内),
|
|
19
|
-
* · 拿不到 / 形态不符 / 抛错一律回落一颗**极简的本地 SVG 文件图标**(`fallbackFileGlyph`),
|
|
20
|
-
* 既不打挂任何东西,也不空着。
|
|
21
|
-
* 该包因此不进 bundle:源码里只有 `require` 调用(bundle 白名单在 src/utils/bundle-scan.ts 登记)。
|
|
12
|
+
* ## 惰取门在 `common/host-primitives.ts`(2026-10-15 抽出)
|
|
13
|
+
* 那颗「帧内惰取 + try/catch + 三态缓存」的门改为全仓唯一出口(同一份门现在也供聊天气泡的
|
|
14
|
+
* 正文投影使用)——本文件只保留**两级回落**:外部来源(better-sidebar)→ 宿主 FileTypeIcon
|
|
15
|
+
* → 本地极简 SVG。为什么不能顶层 import、为什么必须帧内取,见那个文件的头注释。
|
|
22
16
|
*/
|
|
23
|
-
import type {
|
|
17
|
+
import type { ReactNode } from 'react'
|
|
24
18
|
import { Icon } from '../common/Icon.tsx'
|
|
19
|
+
import { hostFileTypeIcon } from '../common/host-primitives.ts'
|
|
25
20
|
|
|
26
21
|
/** 装配点注入的「外部图标来源」(dsh-better-sidebar 的 `fileIcon`);返回 null = 它不认这一条。 */
|
|
27
22
|
export type RollbackFileIconRenderer = (path: string) => ReactNode | null
|
|
28
23
|
|
|
29
|
-
/** `FileTypeIcon` 我们真的用到的入参面(只声明消费的字段,不复制第三方类型)。 */
|
|
30
|
-
interface DshFileTypeIconProps {
|
|
31
|
-
path: string
|
|
32
|
-
size?: number
|
|
33
|
-
className?: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 惰取结果的三态缓存(undefined = 还没试过;null = 试过了,这台宿主没有)。 */
|
|
37
|
-
let cachedDshFileTypeIcon: ComponentType<DshFileTypeIconProps> | null | undefined
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 惰取 DSH 自带的 `FileTypeIcon`(**每次进程只试一次**,失败就记 null 不再重试)。
|
|
41
|
-
*
|
|
42
|
-
* 为什么不用 `import()`:动态 import 在 cjs 产物里同样要走模块表,且会把整个包拖进 chunk;
|
|
43
|
-
* 这里的 `require` 是 DSH loader 交给插件工厂的那一个(banner 里 `factory: (require) => {...}`)。
|
|
44
|
-
*/
|
|
45
|
-
function dshFileTypeIcon(): ComponentType<DshFileTypeIconProps> | null {
|
|
46
|
-
if (cachedDshFileTypeIcon !== undefined) return cachedDshFileTypeIcon
|
|
47
|
-
cachedDshFileTypeIcon = null
|
|
48
|
-
try {
|
|
49
|
-
const loaded = (typeof require === 'function' ? require('@deepseek-ai/dsh-client-ui-primitives') : null) as
|
|
50
|
-
| { FileTypeIcon?: unknown }
|
|
51
|
-
| null
|
|
52
|
-
const icon = loaded?.FileTypeIcon
|
|
53
|
-
if (typeof icon === 'function' || typeof icon === 'object') {
|
|
54
|
-
cachedDshFileTypeIcon = icon as ComponentType<DshFileTypeIconProps>
|
|
55
|
-
}
|
|
56
|
-
} catch {
|
|
57
|
-
cachedDshFileTypeIcon = null
|
|
58
|
-
}
|
|
59
|
-
return cachedDshFileTypeIcon
|
|
60
|
-
}
|
|
61
|
-
|
|
62
24
|
/**
|
|
63
25
|
* 最后的兜底:共享图标原语的通用「文件」图标(`common/Icon.tsx` 的 `name="file"`)。
|
|
64
26
|
* 只有「没有 better-sidebar + 宿主模块表里没有 ui-primitives」两条同时成立时才会出现 ——
|
|
@@ -90,7 +52,7 @@ export function RollbackFileIcon({ path, size = 14, render }: RollbackFileIconPr
|
|
|
90
52
|
// null / undefined / false = 这一条外部来源接不住 ⇒ 回落 DSH 自带画法(不猜、不空着)
|
|
91
53
|
if (node !== null && node !== undefined && node !== false) return <>{node}</>
|
|
92
54
|
}
|
|
93
|
-
const Dsh =
|
|
55
|
+
const Dsh = hostFileTypeIcon()
|
|
94
56
|
if (Dsh !== null) return <Dsh path={path} size={size} />
|
|
95
57
|
return fallbackFileGlyph(size)
|
|
96
58
|
}
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import { useEffect, useRef, useState } from 'react'
|
|
35
35
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
36
36
|
import type { TranslateNS } from '../client-types.ts'
|
|
37
|
-
import { rollbackPreviewScope } from '../../ui/rollback-view.ts'
|
|
37
|
+
import { rollbackPreviewScope, rollbackSkippedSegments } from '../../ui/rollback-view.ts'
|
|
38
38
|
import type {
|
|
39
39
|
RollbackPointPort,
|
|
40
40
|
RollbackPreviewResult,
|
|
@@ -321,6 +321,12 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
|
|
|
321
321
|
/** 选择框(第 1 条)当前是否「连文件一起退」以及它能不能被点。 */
|
|
322
322
|
const revertFiles = revertFilesOn(draft)
|
|
323
323
|
const revertFilesEnabled = canRevertFiles(preview)
|
|
324
|
+
/**
|
|
325
|
+
* 被跳过的段(C3):工作区段不可用时引擎会跳过它、结果为 partial —— 同一份事实在设置页会
|
|
326
|
+
* 渲染(RollbackCard 的 `rollback.rewind.result.skipped`),聊天侧此前只说「部分恢复 / 恢复 0 项」,
|
|
327
|
+
* 没人告诉用户**文件一个都没退、以及为什么**。判据与设置页同源(rollbackSkippedSegments)。
|
|
328
|
+
*/
|
|
329
|
+
const skippedSegments = rollbackSkippedSegments(result)
|
|
324
330
|
/**
|
|
325
331
|
* t8:这条对话此刻正在运行(引擎 preview 的 `session.active`)。运行中时:
|
|
326
332
|
* · 默认档 = 只回溯消息(`defaultDraft`);· 文件开关禁用(`canRevertFiles`);
|
|
@@ -393,7 +399,9 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
|
|
|
393
399
|
工作区态没有可用记录时**禁用**(那时没有文件可退,开关点了也没有区别)。 */}
|
|
394
400
|
<div className={css.previewChoiceRow}>
|
|
395
401
|
<Checkbox
|
|
396
|
-
|
|
402
|
+
// 工作区段不可用时显示为**未勾选**(C4):勾着却禁用会让人以为「文件会退、只是改不了」,
|
|
403
|
+
// 而实际提交的 workspace 段会被引擎跳过 —— 勾选态必须与真的会发生的事一致。
|
|
404
|
+
checked={revertFiles && revertFilesEnabled}
|
|
397
405
|
disabled={busy || !revertFilesEnabled}
|
|
398
406
|
onChange={(next) => { setDraft((previous) => setRevertFiles(previous, next) ?? previous) }}
|
|
399
407
|
label={t('chat.preview.revertFiles')}
|
|
@@ -455,8 +463,8 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
|
|
|
455
463
|
{inlineDiff !== null && (
|
|
456
464
|
<div className={css.inlineDiffBox}>
|
|
457
465
|
{inlineNotice !== null && <div className={css.hint}>{inlineNotice}</div>}
|
|
458
|
-
|
|
459
|
-
<RollbackDiffPane state={inlineDiff.state} rt={rt} />
|
|
466
|
+
{/* 路径由差异表的文件头承载(.diffFileHead):这里不再画第二遍 */}
|
|
467
|
+
<RollbackDiffPane state={inlineDiff.state} rt={rt} title={inlineDiff.path} />
|
|
460
468
|
</div>
|
|
461
469
|
)}
|
|
462
470
|
</>
|
|
@@ -516,6 +524,14 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
|
|
|
516
524
|
</div>
|
|
517
525
|
)}
|
|
518
526
|
|
|
527
|
+
{/* ⑤b 被跳过的段(C3):**部分恢复时最该说的一句** —— 哪一段没做。
|
|
528
|
+
键与设置页共用(同一条事实只有一处文案)。 */}
|
|
529
|
+
{skippedSegments.length > 0 && (
|
|
530
|
+
<div className={css.hint}>
|
|
531
|
+
{rt('rollback.rewind.result.skipped', { list: skippedSegments.map((row) => rt(rk(row.key))).join(' / ') })}
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
|
|
519
535
|
{/* ⑥ fork 出来的子会话(有才显示;自动打开失败如实降级) */}
|
|
520
536
|
{childId !== null && (
|
|
521
537
|
<div className={css.statRow}>
|
|
@@ -42,6 +42,9 @@ import {
|
|
|
42
42
|
planChatEntryAttachments,
|
|
43
43
|
planChatNodeRegistration,
|
|
44
44
|
projectChatEntryText,
|
|
45
|
+
projectChatEntryTextWithHost,
|
|
46
|
+
chatNodeUserOccupant,
|
|
47
|
+
CHAT_NODE_YIELD_WARN_PREFIX,
|
|
45
48
|
resetChatPointCache,
|
|
46
49
|
shouldRetryChatPoint,
|
|
47
50
|
CHAT_POINT_LOOKUP_WINDOW_MS,
|
|
@@ -55,7 +58,7 @@ import {
|
|
|
55
58
|
retryChatPointUntilHit,
|
|
56
59
|
type ChatPointWindowState,
|
|
57
60
|
} from './chat-point-window.ts'
|
|
58
|
-
import { declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
|
|
61
|
+
import { chatNodeOccupantOf, declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
|
|
59
62
|
import { en as chatEn, zh as chatZh } from './chat-locales.ts'
|
|
60
63
|
import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
|
|
61
64
|
|
|
@@ -110,6 +113,52 @@ test('registration plan: a keyed declaration registers; anything else is skipped
|
|
|
110
113
|
assert.deepEqual(planChatNodeRegistration('list'), { kind: 'skip', reason: 'slot-kind-mismatch', declared: 'list' })
|
|
111
114
|
})
|
|
112
115
|
|
|
116
|
+
/**
|
|
117
|
+
* 让位判据(2026-10-15,跨插件冲突):keyed cell「最低的渲染」——别人 priority 更低时我们必定轮不到渲染,
|
|
118
|
+
* 所以**先读占用再决定注册**:更低 → 让位并告警(不抢格、不静默);相同(只可能是残留的自己)→ 照常注册
|
|
119
|
+
* (真撞上由 try/catch 兜住);宿主那条 priority 0 → 我们 -1 本来就要遮蔽它,当然注册。
|
|
120
|
+
*/
|
|
121
|
+
test('registration plan: yields when another renderer already owns the user cell at a LOWER priority', () => {
|
|
122
|
+
const host = { priority: 0, registrant: null }
|
|
123
|
+
const oursOwn = { priority: -1, registrant: 'dsh-config-manager' }
|
|
124
|
+
const theirs = { priority: -2, registrant: '@someone/other-rewind' }
|
|
125
|
+
assert.deepEqual(planChatNodeRegistration('keyed', host), { kind: 'register' })
|
|
126
|
+
assert.deepEqual(planChatNodeRegistration('keyed', oursOwn), { kind: 'register' })
|
|
127
|
+
assert.deepEqual(planChatNodeRegistration('keyed', theirs), {
|
|
128
|
+
kind: 'skip',
|
|
129
|
+
reason: 'shadowed',
|
|
130
|
+
declared: 'keyed',
|
|
131
|
+
occupant: theirs,
|
|
132
|
+
})
|
|
133
|
+
// 形态不符的判据优先于占用(按 keyed 参数注册本来就会抛)
|
|
134
|
+
assert.equal(planChatNodeRegistration('list', theirs).kind, 'skip')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('occupant reader: reads the winning entry per cell from entriesOfSlot shape; anything else is null', () => {
|
|
138
|
+
const entry = (key: string, priority?: number, registrant?: string) => ({
|
|
139
|
+
options: { key, ...(priority === undefined ? {} : { priority }) },
|
|
140
|
+
...(registrant === undefined ? {} : { registrant }),
|
|
141
|
+
})
|
|
142
|
+
assert.equal(chatNodeUserOccupant(null), null)
|
|
143
|
+
assert.equal(chatNodeUserOccupant({}), null)
|
|
144
|
+
assert.equal(chatNodeUserOccupant([]), null)
|
|
145
|
+
assert.deepEqual(chatNodeUserOccupant([entry('agent-teams', -5)]), null, 'other keys are not our cell')
|
|
146
|
+
// 缺省 priority = 0(宿主缺省),registrant 读不到如实回 null(绝不猜)
|
|
147
|
+
assert.deepEqual(chatNodeUserOccupant([entry('user')]), { priority: 0, registrant: null })
|
|
148
|
+
// 多条同 key:取最低的那条(= 会被渲染的那条)
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
chatNodeUserOccupant([entry('user', 0, 'host'), entry('user', -2, 'other')]),
|
|
151
|
+
{ priority: -2, registrant: 'other' },
|
|
152
|
+
)
|
|
153
|
+
// 非数值 priority 当成缺省 0;空 registrant 归 null
|
|
154
|
+
assert.deepEqual(chatNodeUserOccupant([entry('user', Number.NaN, '')]), { priority: 0, registrant: null })
|
|
155
|
+
// registrant 也可能挂在 options 上(宿主两个位置都读过)
|
|
156
|
+
assert.deepEqual(chatNodeUserOccupant([{ options: { key: 'user', priority: -3, registrant: 'x' } }]), {
|
|
157
|
+
priority: -3,
|
|
158
|
+
registrant: 'x',
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
113
162
|
test('register shell: keyed declaration registers exactly once; non-keyed declaration is skipped, not thrown', () => {
|
|
114
163
|
const warn: string[] = []
|
|
115
164
|
let called = 0
|
|
@@ -136,6 +185,31 @@ test('register shell: keyed declaration registers exactly once; non-keyed declar
|
|
|
136
185
|
assert.equal(declaredSlotKind({ spec: () => { throw new Error('boom') } }), null)
|
|
137
186
|
})
|
|
138
187
|
|
|
188
|
+
test('register shell: yields (does NOT register) when a lower-priority renderer owns the user cell', () => {
|
|
189
|
+
const warn: string[] = []
|
|
190
|
+
let called = 0
|
|
191
|
+
const disposer = registerChatNodeEntry({
|
|
192
|
+
probe: {
|
|
193
|
+
spec: () => ({ kind: 'keyed', scope: 'session' }),
|
|
194
|
+
entriesOfSlot: () => [{ options: { key: 'user', priority: -2 }, registrant: '@someone/other-rewind' }],
|
|
195
|
+
},
|
|
196
|
+
register: () => { called += 1; return () => {} },
|
|
197
|
+
warn: (message) => { warn.push(message) },
|
|
198
|
+
})
|
|
199
|
+
assert.equal(called, 0, 'we must not fight for a cell another renderer already owns at a lower priority')
|
|
200
|
+
assert.equal(typeof disposer, 'function')
|
|
201
|
+
assert.ok(warn.some((line) => line.startsWith(CHAT_NODE_YIELD_WARN_PREFIX)), 'the yield must be logged, never silent')
|
|
202
|
+
assert.ok(warn.some((line) => line.includes('-2') && line.includes('@someone/other-rewind')))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('occupant probe: missing entriesOfSlot / a throwing probe / a foreign shape all mean “nobody occupies it”', () => {
|
|
206
|
+
assert.equal(chatNodeOccupantOf(undefined), null)
|
|
207
|
+
assert.equal(chatNodeOccupantOf({ spec: () => ({ kind: 'keyed' }) }), null, 'old hosts expose no entriesOfSlot')
|
|
208
|
+
assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => { throw new Error('boom') } }), null)
|
|
209
|
+
assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => ({ not: 'an array' }) }), null)
|
|
210
|
+
assert.deepEqual(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => [{ options: { key: 'user', priority: 0 } }] }), { priority: 0, registrant: null })
|
|
211
|
+
})
|
|
212
|
+
|
|
139
213
|
test('register shell: a throwing register() is contained (never takes the plugin down)', () => {
|
|
140
214
|
const warn: string[] = []
|
|
141
215
|
const disposer = registerChatNodeEntry({
|
|
@@ -204,6 +278,95 @@ test('样式纪律:icon 入口与预览文件行只用既有 token/类,未
|
|
|
204
278
|
}
|
|
205
279
|
})
|
|
206
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 底色必须跟随宿主(2026-10-15 用户反馈「浅色主题下用户消息气泡看不见」)。
|
|
283
|
+
*
|
|
284
|
+
* 背景:接管 user 渲染器后我们自复刻那条气泡,底色曾写 --cm-surface-2(= --dsw-alias-bg-layer-2)——
|
|
285
|
+
* 浅色主题下它 = #fff,而页面底 --dsw-alias-bg-base 也是 #fff ⇒ 与页面同色(对比度 0);
|
|
286
|
+
* 宿主真机用的是 --dsw-specific-bubble(浅色 = --dsw-static-deepseek-50 = #edf3fe)。
|
|
287
|
+
* 深色下两者恰好同值,所以这条差距当时被误判成「可接受的近似档」。
|
|
288
|
+
*/
|
|
289
|
+
test('气泡底色跟随宿主的 --dsw-specific-bubble(不得再用 --cm-surface-2 近似)', () => {
|
|
290
|
+
const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
|
|
291
|
+
// ① 定义行:宿主 token + **可见**兜底(无兜底的 var() 在宿主删档时会整条失效 → transparent;
|
|
292
|
+
// 兜成 bg-layer-2 也没用 —— 浅色下它同样是 #fff,气泡照样消失)
|
|
293
|
+
const defs = cssSheet.match(/--cm-chat-bubble:\s*var\(--dsw-specific-bubble,\s*color-mix\(in srgb, var\(--cm-text-1\) 12%, transparent\)\)/g) ?? []
|
|
294
|
+
assert.ok(defs.length >= 2, '--cm-chat-bubble 必须在 §1 body 与 .chatEntryScope 各定义一次(且兜底是可见色),实际 ' + String(defs.length) + ' 处')
|
|
295
|
+
assert.equal(
|
|
296
|
+
(cssSheet.match(/--cm-chat-bubble:[^;]*--dsw-alias-bg-layer-2/g) ?? []).length,
|
|
297
|
+
0,
|
|
298
|
+
'兜底不得用 --dsw-alias-bg-layer-2(浅色下 = #fff ⇒ 等于没兜底)',
|
|
299
|
+
)
|
|
300
|
+
// ② 消费点:.chatBubble 只写 --cm-*(三层单向依赖),且不得再回到 --cm-surface-2
|
|
301
|
+
const bubble = cssSheet.match(/\.chatBubble\s*\{[^}]*\}/)?.[0] ?? ''
|
|
302
|
+
assert.match(bubble, /background:\s*var\(--cm-chat-bubble\)/, '.chatBubble 的底色必须消费 --cm-chat-bubble')
|
|
303
|
+
assert.doesNotMatch(bubble, /--cm-surface-2|--dsw-/, '.chatBubble 不得再用 --cm-surface-2 近似、也不得裸写 --dsw-*')
|
|
304
|
+
// ③ 作用域重声明:聊天流不在插件设置页子树内
|
|
305
|
+
const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
|
|
306
|
+
assert.match(scope, /--cm-chat-bubble:\s*var\(--dsw-specific-bubble/, '.chatEntryScope 必须就地重声明 --cm-chat-bubble')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
/** 文件卡描边同轮对齐(2026-10-15):宿主 = `.5px solid --dsw-alias-border-l2`,我们此前用更淡的 border-l1。 */
|
|
310
|
+
test('文件卡描边与宿主同档:.5px + --cm-line-2(不是更淡的 --cm-line-1)', () => {
|
|
311
|
+
const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
|
|
312
|
+
// 注释里会出现 token 名(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard.test.ts 同法
|
|
313
|
+
const card = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '').match(/\.chatFileCard\s*\{[^}]*\}/)?.[0] ?? ''
|
|
314
|
+
assert.match(card, /border:\s*0\.5px solid var\(--cm-line-2\)/, '文件卡描边必须与宿主一致:.5px + --cm-line-2')
|
|
315
|
+
assert.doesNotMatch(card, /--cm-line-1/, '文件卡描边不得再退回更淡的 --cm-line-1')
|
|
316
|
+
const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
|
|
317
|
+
assert.match(scope, /--cm-line-2:\s*var\(--dsw-alias-border-l2\)/, '.chatEntryScope 必须就地重声明 --cm-line-2')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 宿主正文投影(2026-10-15):气泡正文优先交给宿主自己的 projectUserText(会话引用/技能 → chip)。
|
|
322
|
+
* 四条回落判据必须逐条可证 —— 任一条不成立都回落我们自己的纯文本投影(绝不空白、绝不抛)。
|
|
323
|
+
*/
|
|
324
|
+
test('host text projection: four fallback criteria, and every argument goes through verbatim', () => {
|
|
325
|
+
const calls: unknown[][] = []
|
|
326
|
+
// 返回值用字符串(合法的 ReactNode)—— 纯层只要求「非空节点」,chip 的真实形状由宿主决定
|
|
327
|
+
const projector = (text: string, labels: readonly string[], skills: readonly string[], kind: string, references?: { openFile?: unknown; openSkill?: unknown }): string => {
|
|
328
|
+
calls.push([text, labels, skills, kind, references])
|
|
329
|
+
return '[chip]'
|
|
330
|
+
}
|
|
331
|
+
const options = { sessionLabels: ['会话甲'], skillNames: ['i-have-adhd'], openFile: 'of', openSkill: 'os' }
|
|
332
|
+
assert.deepEqual(projectChatEntryTextWithHost(projector, 'raw', options), '[chip]')
|
|
333
|
+
assert.deepEqual(calls[0], ['raw', ['会话甲'], ['i-have-adhd'], 'skill', { openFile: 'of', openSkill: 'os' }])
|
|
334
|
+
// ① 没有投影器(老宿主模块表里没有 ui-primitives)
|
|
335
|
+
assert.equal(projectChatEntryTextWithHost(null, 'raw', options), null)
|
|
336
|
+
assert.equal(projectChatEntryTextWithHost(undefined, 'raw', options), null)
|
|
337
|
+
// ② 原文为空
|
|
338
|
+
assert.equal(projectChatEntryTextWithHost(projector, '', options), null)
|
|
339
|
+
// ③ 投影自己抛错
|
|
340
|
+
assert.equal(projectChatEntryTextWithHost(() => { throw new Error('boom') }, 'raw', options), null)
|
|
341
|
+
// ④ 投影拒绝了这段文本(null / undefined / false)
|
|
342
|
+
assert.equal(projectChatEntryTextWithHost(() => null, 'raw', options), null)
|
|
343
|
+
assert.equal(projectChatEntryTextWithHost(() => undefined, 'raw', options), null)
|
|
344
|
+
assert.equal(projectChatEntryTextWithHost(() => false, 'raw', options), null)
|
|
345
|
+
// 缺省选项也要把完整参数交给投影器(空数组 + 空打开器),绝不缺参
|
|
346
|
+
calls.length = 0
|
|
347
|
+
assert.deepEqual(projectChatEntryTextWithHost(projector, 'x'), '[chip]')
|
|
348
|
+
assert.deepEqual(calls[0], ['x', [], [], 'skill', { openFile: undefined, openSkill: undefined }])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('bubble keeps the UNprojected text for the host projector and the projected text as the fallback', () => {
|
|
352
|
+
const view = buildChatEntryBubble([{ type: 'text', text: '看这条 @[会话甲](dsh-session:s9) 引用' }])
|
|
353
|
+
assert.equal(view.text, '看这条 会话甲 引用')
|
|
354
|
+
assert.equal(view.rawText, '看这条 @[会话甲](dsh-session:s9) 引用')
|
|
355
|
+
// 多个文本块:两边都按 content 顺序拼接(非文本块不参与拼接)
|
|
356
|
+
const multi = buildChatEntryBubble([
|
|
357
|
+
{ type: 'text', text: 'A' },
|
|
358
|
+
{ type: 'image', attachment: { id: 'i' } },
|
|
359
|
+
{ type: 'text', text: 'B' },
|
|
360
|
+
])
|
|
361
|
+
assert.equal(multi.text, 'AB')
|
|
362
|
+
assert.equal(multi.rawText, 'AB')
|
|
363
|
+
// 怪形状:字符串 content 两边都有值;空 content 两边都是空串(不编造)
|
|
364
|
+
assert.deepEqual([buildChatEntryBubble('hi').text, buildChatEntryBubble('hi').rawText], ['hi', 'hi'])
|
|
365
|
+
const empty = buildChatEntryBubble(null)
|
|
366
|
+
assert.equal(empty.text, '')
|
|
367
|
+
assert.equal(empty.rawText, '')
|
|
368
|
+
})
|
|
369
|
+
|
|
207
370
|
/* ------------------------------------------------------------ ⑤ 回合号只认可核实字段 */
|
|
208
371
|
|
|
209
372
|
test('turn number: only node.location.kind turn|step counts (same read as the host turnTail)', () => {
|
|
@@ -45,20 +45,63 @@ export const CHAT_NODE_USER_KEY = 'user'
|
|
|
45
45
|
*/
|
|
46
46
|
export const CHAT_NODE_PRIORITY = -1
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* 同一个 cell(key=user)当前**会被渲染的那一条**(= priority 最小的那一条)。
|
|
50
|
+
*
|
|
51
|
+
* 为什么要读它(2026-10-15,跨插件冲突排查的落地):`conversation.chat.node` 是 keyed 遮蔽位,
|
|
52
|
+
* 「最低的渲染」——别的插件若也用 key=user 且 priority 比我们更低(≤ -2),它会赢而我们**静默失效**
|
|
53
|
+
* (框架不报错)。所以我们在注册**之前**读一次占用:赢家比我们更低就让位并告警,不去抢。
|
|
54
|
+
*/
|
|
55
|
+
export interface ChatNodeOccupant {
|
|
56
|
+
/** 该条的 priority(宿主缺省 0)。 */
|
|
57
|
+
priority: number
|
|
58
|
+
/** 诊断用标识(宿主用 fiber 名盖的戳);读不到回 null,**绝不猜**。 */
|
|
59
|
+
registrant: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 从 `entriesOfSlot('conversation.chat.node')` 的返回值里读 user cell 的赢家(形状不符一律 null)。 */
|
|
63
|
+
export function chatNodeUserOccupant(entries: unknown): ChatNodeOccupant | null {
|
|
64
|
+
if (!Array.isArray(entries)) return null
|
|
65
|
+
let winner: ChatNodeOccupant | null = null
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const record = asRecord(entry)
|
|
68
|
+
const options = asRecord(record?.['options'])
|
|
69
|
+
if (options === null || options['key'] !== CHAT_NODE_USER_KEY) continue
|
|
70
|
+
const raw = options['priority']
|
|
71
|
+
const priority = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
|
72
|
+
const label = record?.['registrant'] ?? options['registrant']
|
|
73
|
+
const registrant = typeof label === 'string' && label !== '' ? label : null
|
|
74
|
+
if (winner === null || priority < winner.priority) winner = { priority, registrant }
|
|
75
|
+
}
|
|
76
|
+
return winner
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
/**
|
|
49
80
|
* 槽位登记计划:**只在 0.2 的 keyed 形态时接管**(2026-10-12:入口只剩「每条用户消息」一种形态,
|
|
50
81
|
* 旧的「整回合」档与偏好门已随 chat-entry-pref / chat-entry-mount 一起删除)。
|
|
51
82
|
*
|
|
52
83
|
* 跳过必须是**失败软化**(只丢这一个入口,不动本插件其余槽位、不动宿主启动):
|
|
53
84
|
* · `slot-kind-mismatch`:0.1.x 的 ui-chat 把同一 key 声明成别的形态(对照 turnTail 的
|
|
54
|
-
* list/chain 双形态实测),按 keyed 参数(key/priority
|
|
85
|
+
* list/chain 双形态实测),按 keyed 参数(key/priority)注册会抛;
|
|
86
|
+
* · `shadowed`(2026-10-15):user cell 已被**更低优先级**的条目占住 —— 我们注册也永远轮不到渲染
|
|
87
|
+
* (「最低的渲染」),不如让位并如实告警(不静默、也不去抢别人的格子)。
|
|
88
|
+
*
|
|
89
|
+
* 注意 `occupant.priority === CHAT_NODE_PRIORITY` **不算遮蔽**:同一 cell 同 priority 只可能有一条
|
|
90
|
+
* (再注册会抛,由登记处的 try/catch 兜住),所以那时该试的是注册而不是让位。
|
|
55
91
|
*/
|
|
56
92
|
export type ChatNodeRegistrationPlan =
|
|
57
93
|
| { kind: 'register' }
|
|
58
94
|
| { kind: 'skip'; reason: 'slot-kind-mismatch'; declared: string | null }
|
|
95
|
+
| { kind: 'skip'; reason: 'shadowed'; declared: string | null; occupant: ChatNodeOccupant }
|
|
59
96
|
|
|
60
|
-
export function planChatNodeRegistration(
|
|
97
|
+
export function planChatNodeRegistration(
|
|
98
|
+
declaredKind: string | null,
|
|
99
|
+
occupant: ChatNodeOccupant | null = null,
|
|
100
|
+
): ChatNodeRegistrationPlan {
|
|
61
101
|
if (declaredKind !== 'keyed') return { kind: 'skip', reason: 'slot-kind-mismatch', declared: declaredKind }
|
|
102
|
+
if (occupant !== null && occupant.priority < CHAT_NODE_PRIORITY) {
|
|
103
|
+
return { kind: 'skip', reason: 'shadowed', declared: declaredKind, occupant }
|
|
104
|
+
}
|
|
62
105
|
return { kind: 'register' }
|
|
63
106
|
}
|
|
64
107
|
|
|
@@ -151,6 +194,11 @@ export type ChatEntryAttachment =
|
|
|
151
194
|
export interface ChatEntryBubbleView {
|
|
152
195
|
/** 文本块拼接结果(宿主 `texts.join('')`,逐字保留换行与空白;会话引用的线格式已投影成 label)。 */
|
|
153
196
|
text: string
|
|
197
|
+
/**
|
|
198
|
+
* **未投影**的文本拼接(会话线格式原样保留)—— 只给宿主 `projectUserText` 用:
|
|
199
|
+
* 它自己认 `@[label](dsh-session:…)` 并把引用/技能渲染成 chip;我们自己的 `text` 是它的兜底。
|
|
200
|
+
*/
|
|
201
|
+
rawText: string
|
|
154
202
|
/** **有序附件列表**:渲染层必须按这个顺序输出(宿主不区分「先图片后文件」)。 */
|
|
155
203
|
attachments: ChatEntryAttachment[]
|
|
156
204
|
/** 图片附件(= `attachments` 里的 image 项,顺序一致;用于计数与「无渲染位」提示)。 */
|
|
@@ -244,11 +292,12 @@ export function formatChatEntryClock(time: number | null): string | null {
|
|
|
244
292
|
export const CHAT_SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
|
|
245
293
|
|
|
246
294
|
/**
|
|
247
|
-
*
|
|
295
|
+
* 正文的**最小文本投影**(宿主投影不可用时的兜底):把会话引用线格式换成它的人类可读 label。
|
|
248
296
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
297
|
+
* 2026-10-15 起:气泡正文**优先走宿主自己的** `projectUserText`(见 `projectChatEntryTextWithHost`
|
|
298
|
+
* —— 它能把会话引用与技能渲染成 chip,并在给了打开器时可点开);本函数是**兜底与纯文本路径**
|
|
299
|
+
* (复制按钮、错误边界降级、老宿主没有原语包时)的唯一实现:任何情况下都**不把线格式原样打给用户看**
|
|
300
|
+
* (那是机器的编码,正常人读不了 —— t4 复核 D5,medium)。
|
|
252
301
|
* 与宿主一致的边界:只认 `dsh-session:` 的括号形式;`g` flag 下逐处替换,正则自身 lastIndex 置零。
|
|
253
302
|
*/
|
|
254
303
|
export function projectChatEntryText(text: string): string {
|
|
@@ -257,6 +306,61 @@ export function projectChatEntryText(text: string): string {
|
|
|
257
306
|
return text.replace(CHAT_SESSION_WIRE_RE, '$1')
|
|
258
307
|
}
|
|
259
308
|
|
|
309
|
+
/**
|
|
310
|
+
* 宿主 `projectUserText` 的**可注入面**(宽类型:本仓库不引用 ui-primitives 的类型,也不 import 它)。
|
|
311
|
+
* 返回值是不透明 ReactNode(chip 节点等);缺省 null = 这台宿主没有该能力。
|
|
312
|
+
*/
|
|
313
|
+
export type ChatEntryTextProjector = (
|
|
314
|
+
text: string,
|
|
315
|
+
sessionLabels: readonly string[],
|
|
316
|
+
slashNames: readonly string[],
|
|
317
|
+
slashKind: string,
|
|
318
|
+
references?: { openFile?: unknown; openSkill?: unknown },
|
|
319
|
+
) => ReactNode
|
|
320
|
+
|
|
321
|
+
/** 宿主投影的入参(全部来自节点 data 与 owner props;缺省即空)。 */
|
|
322
|
+
export interface ChatEntryHostTextOptions {
|
|
323
|
+
/** `data.referenceLabels`(引用会话的 label 列表)。 */
|
|
324
|
+
sessionLabels?: readonly string[]
|
|
325
|
+
/** `data.skillNames`(宿主把它们渲染成 chip —— 我们此前一个元素都不产出)。 */
|
|
326
|
+
skillNames?: readonly string[]
|
|
327
|
+
/** 宿主给的文件 / 技能打开器(给了才能点开 chip;没有就只显示 chip 外观)。 */
|
|
328
|
+
openFile?: unknown
|
|
329
|
+
openSkill?: unknown
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 用**宿主自己**的 `projectUserText` 投影正文(2026-10-15:按生态既有做法补齐 G-39 差距①)。
|
|
334
|
+
*
|
|
335
|
+
* 实证(为什么这条现在成立):ui-primitives 由 DSH shell 的模块表作为 static module seed 预置,
|
|
336
|
+
* 插件 bundle 的 `require` 能命中 —— 第三方插件 dsh-reedit 就是这么拿 `projectUserText` 的,
|
|
337
|
+
* 本仓的 `RollbackFileIcon` 也早就这样拿 `FileTypeIcon`。**只能帧内惰取**(顶层 import 在老宿主
|
|
338
|
+
* 会打挂整个浏览器半加载),入口在 `common/host-primitives.ts`。
|
|
339
|
+
*
|
|
340
|
+
* 四条判据缺一不可(本函数**绝不抛**,任一条不满足就回 null ⇒ 调用方回落 `projectChatEntryText`):
|
|
341
|
+
* ① 有投影器(老宿主没有原语包 → null);② 原文非空;
|
|
342
|
+
* ③ 投影不抛错;④ 投影返回**非空节点**(null/undefined/false = 宿主拒绝了这段文本)。
|
|
343
|
+
*
|
|
344
|
+
* 为什么投影要当参数传进来:纯层与单测都不认识模块表 —— 抽成参数后,上面四条判据能逐条覆盖。
|
|
345
|
+
*/
|
|
346
|
+
export function projectChatEntryTextWithHost(
|
|
347
|
+
projector: ChatEntryTextProjector | null | undefined,
|
|
348
|
+
rawText: string,
|
|
349
|
+
options: ChatEntryHostTextOptions = {},
|
|
350
|
+
): ReactNode | null {
|
|
351
|
+
if (typeof projector !== 'function') return null
|
|
352
|
+
if (rawText === '') return null
|
|
353
|
+
try {
|
|
354
|
+
const node = projector(rawText, options.sessionLabels ?? [], options.skillNames ?? [], 'skill', {
|
|
355
|
+
openFile: options.openFile,
|
|
356
|
+
openSkill: options.openSkill,
|
|
357
|
+
})
|
|
358
|
+
return node === null || node === undefined || node === false ? null : node
|
|
359
|
+
} catch {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
260
364
|
/**
|
|
261
365
|
* 扩展名 → 文件卡上的短徽标:宿主 `fileExtension` + 宿主那行 meta 的同一截断
|
|
262
366
|
* (`fileExtension(name).toUpperCase().slice(0, 8)`);后缀为空(无点 / 点在末尾)回 null。
|
|
@@ -310,12 +414,13 @@ export function fileSizeTextOf(bytes: number | null): string | null {
|
|
|
310
414
|
export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
|
|
311
415
|
if (typeof content === 'string') {
|
|
312
416
|
const text = projectChatEntryText(content)
|
|
313
|
-
return { text, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
|
|
417
|
+
return { text, rawText: content, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
|
|
314
418
|
}
|
|
315
419
|
if (!Array.isArray(content)) {
|
|
316
|
-
return { text: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
|
|
420
|
+
return { text: '', rawText: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
|
|
317
421
|
}
|
|
318
422
|
const texts: string[] = []
|
|
423
|
+
const rawTexts: string[] = []
|
|
319
424
|
const attachments: ChatEntryAttachment[] = []
|
|
320
425
|
const raw: ChatEntryRawBlock[] = []
|
|
321
426
|
content.forEach((block, index) => {
|
|
@@ -323,6 +428,8 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
|
|
|
323
428
|
const type = record?.['type']
|
|
324
429
|
if (type === 'text' && typeof record?.['text'] === 'string') {
|
|
325
430
|
// 宿主把这条文本交给 projectUserText(会话引用渲染成 chip);我们做**最小文本投影**:线格式 → label
|
|
431
|
+
// (rawTexts 同时留一份未投影原文,宿主投影可用时优先用它 —— 见 ChatEntryBubbleView.rawText)
|
|
432
|
+
rawTexts.push(record['text'] as string)
|
|
326
433
|
texts.push(projectChatEntryText(record['text'] as string))
|
|
327
434
|
return
|
|
328
435
|
}
|
|
@@ -347,10 +454,11 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
|
|
|
347
454
|
raw.push({ id: String(index), payload: block })
|
|
348
455
|
})
|
|
349
456
|
const text = texts.join('')
|
|
457
|
+
const rawText = rawTexts.join('')
|
|
350
458
|
// images / files 是 attachments 的**派生视图**(不独立积累,避免两处真相与顺序漂移)
|
|
351
459
|
const images = attachments.flatMap((item) => (item.kind === 'image' ? [item.image] : []))
|
|
352
460
|
const files = attachments.flatMap((item) => (item.kind === 'file' ? [item.file] : []))
|
|
353
|
-
return { text, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
|
|
461
|
+
return { text, rawText, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
|
|
354
462
|
}
|
|
355
463
|
|
|
356
464
|
/** 渲染计划的一项:图片带 owner 载荷,文件带名称/扩展名/体积文本。 */
|
|
@@ -517,7 +625,10 @@ export interface ChatEntryView {
|
|
|
517
625
|
bubble: ChatEntryBubbleView
|
|
518
626
|
/** 动作行的时间文本(null = 节点没给可用的时间戳)。 */
|
|
519
627
|
clock: string | null
|
|
520
|
-
/**
|
|
628
|
+
/**
|
|
629
|
+
* 宿主 data 的另外两个字段:`referenceLabels` 进引用汇总行;两者都作为 `projectUserText`
|
|
630
|
+
* 的入参传给**宿主的**正文投影(2026-10-15 起 —— 此前它们只是「如实记下的差距」)。
|
|
631
|
+
*/
|
|
521
632
|
referenceLabels: string[]
|
|
522
633
|
skillNames: string[]
|
|
523
634
|
}
|
|
@@ -600,6 +711,12 @@ export function rawBlockText(payload: unknown): string {
|
|
|
600
711
|
/** 错误边界的 ASCII 诊断前缀(console.* 实参不触发 i18n 守卫;界面文案一律在字典里)。 */
|
|
601
712
|
export const CHAT_ENTRY_WARN_PREFIX = '[config-manager] chat user node renderer degraded to plain text: '
|
|
602
713
|
|
|
714
|
+
/**
|
|
715
|
+
* 让位告警的 ASCII 前缀(诊断用,绝不当作界面文案)—— 见 `planChatNodeRegistration` 的 `shadowed`:
|
|
716
|
+
* 别的插件用更低优先级占住了 key=user 时,我们**不注册**(注册也永远轮不到渲染),只留这条日志。
|
|
717
|
+
*/
|
|
718
|
+
export const CHAT_NODE_YIELD_WARN_PREFIX = '[config-manager] chat user node entry yielded: another renderer owns key "user" at a lower priority: '
|
|
719
|
+
|
|
603
720
|
/* ------------------------------------------------------------ 回滚点惰取(按会话缓存 + 在途去重) */
|
|
604
721
|
|
|
605
722
|
/** 缓存 TTL:同一会话里的所有按钮共用一次列表读取;跨分钟自然过期(列表变化由宿主写,非热点)。 */
|
|
@@ -9,11 +9,23 @@
|
|
|
9
9
|
* (`chat-entry-pref` / `chat-entry-mount`)与回合尾入口(`conversation.chat.turnTail`)一并删除 ——
|
|
10
10
|
* 入口恒为每条用户消息气泡里的那一颗,于是这里不再有偏好门,也不再需要「切档时重新评估」的订阅。
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
CHAT_NODE_SLOT,
|
|
14
|
+
CHAT_NODE_YIELD_WARN_PREFIX,
|
|
15
|
+
chatNodeUserOccupant,
|
|
16
|
+
planChatNodeRegistration,
|
|
17
|
+
type ChatNodeOccupant,
|
|
18
|
+
} from './chat-entry-view.ts'
|
|
13
19
|
|
|
14
|
-
/**
|
|
20
|
+
/** 槽位探测面(`ctx.slots`):读声明的 kind,以及该 cell 当前会被渲染的那一条。 */
|
|
15
21
|
export interface ChatNodeSlotProbe {
|
|
22
|
+
/** 只读 spec(宽类型读回 kind)。 */
|
|
16
23
|
spec: (key: string) => unknown
|
|
24
|
+
/**
|
|
25
|
+
* 每个 cell 当前会被渲染的那一条(SlotRegistry.entriesOfSlot)。
|
|
26
|
+
* **可选**:老宿主没有这个方法(那时不做占用探测,照常注册)。
|
|
27
|
+
*/
|
|
28
|
+
entriesOfSlot?: (key: string) => unknown
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
/** 诊断(ASCII,与界面文案无关;console.* 实参不触发 i18n 守卫)。 */
|
|
@@ -33,12 +45,24 @@ export function declaredSlotKind(probe: ChatNodeSlotProbe | undefined | null): s
|
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
/** user cell 的当前赢家(没有探测面 / 形状不符 / 探测抛错都回 null —— 探测失败不当成「有人占」)。 */
|
|
49
|
+
export function chatNodeOccupantOf(probe: ChatNodeSlotProbe | undefined | null): ChatNodeOccupant | null {
|
|
50
|
+
if (probe === undefined || probe === null || typeof probe.entriesOfSlot !== 'function') return null
|
|
51
|
+
try {
|
|
52
|
+
return chatNodeUserOccupant(probe.entriesOfSlot(CHAT_NODE_SLOT))
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/**
|
|
37
59
|
* 登记「接管 conversation.chat.node / user」的渲染器。
|
|
38
60
|
*
|
|
39
|
-
*
|
|
61
|
+
* 三道门(顺序即优先级):
|
|
40
62
|
* ① 声明形态不是 `keyed`(0.1.x 的 ui-chat 契约不同)→ 跳过(按 keyed 参数注册会抛);
|
|
41
|
-
* ②
|
|
63
|
+
* ② **user cell 已被更低优先级占住**(别的插件也在接管用户消息,2026-10-15)→ 让位并如实告警:
|
|
64
|
+
* 注册也永远轮不到渲染(keyed cell「最低的渲染」),不抢别人的格子、也不静默;
|
|
65
|
+
* ③ 注册本身抛错 → 吞掉并告警(只丢这一个入口)。
|
|
42
66
|
*
|
|
43
67
|
* @returns disposer;跳过 / 失败时是 no-op。
|
|
44
68
|
*/
|
|
@@ -51,8 +75,18 @@ export function registerChatNodeEntry(options: {
|
|
|
51
75
|
}): () => void {
|
|
52
76
|
const warn = options.warn ?? warnChatNode
|
|
53
77
|
const declared = declaredSlotKind(options.probe)
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
const plan = planChatNodeRegistration(declared, chatNodeOccupantOf(options.probe))
|
|
79
|
+
if (plan.kind === 'skip') {
|
|
80
|
+
if (plan.reason === 'shadowed') {
|
|
81
|
+
warn(
|
|
82
|
+
CHAT_NODE_YIELD_WARN_PREFIX +
|
|
83
|
+
'priority ' + String(plan.occupant.priority) +
|
|
84
|
+
(plan.occupant.registrant === null ? '' : ' (registered by ' + plan.occupant.registrant + ')') +
|
|
85
|
+
' — entry not registered',
|
|
86
|
+
)
|
|
87
|
+
} else {
|
|
88
|
+
warn('[config-manager] chat user node entry skipped: slot declaration is ' + (declared === null ? 'missing' : '"' + declared + '"') + ' (expected "keyed")')
|
|
89
|
+
}
|
|
56
90
|
return () => {}
|
|
57
91
|
}
|
|
58
92
|
try {
|
|
@@ -119,11 +119,16 @@ export class ChatPointWindowController {
|
|
|
119
119
|
private current: ChatPointWindowState = { lookup: null, open: false }
|
|
120
120
|
private started = false
|
|
121
121
|
private cancelled = false
|
|
122
|
+
/** 默认排程器留下的待触发定时器(≤12 s);dispose 清掉,避免悬空 timer 与晚到的回调 */
|
|
123
|
+
private pendingTimer: ReturnType<typeof setTimeout> | null = null
|
|
122
124
|
|
|
123
125
|
constructor(options: ChatPointWindowOptions) {
|
|
124
126
|
// 参数属性(`constructor(private readonly x)`)会产出运行期语法,erasableSyntaxOnly 下不允许 —— 显式赋值
|
|
125
127
|
this.options = options
|
|
126
|
-
|
|
128
|
+
// 默认排程器把句柄留下来,dispose 时清掉(注入的 retry 由调用方自理)
|
|
129
|
+
this.schedule = options.retry ?? ((fn: () => void, delayMs: number) => {
|
|
130
|
+
this.pendingTimer = setTimeout(() => { this.pendingTimer = null; fn() }, delayMs)
|
|
131
|
+
})
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
/** 当前状态(与最后一次 onState 回调同形)。 */
|
|
@@ -141,6 +146,11 @@ export class ChatPointWindowController {
|
|
|
141
146
|
*/
|
|
142
147
|
dispose(): void {
|
|
143
148
|
this.cancelled = true
|
|
149
|
+
// 取消后**不再需要任何晚到的回调**:默认排程器的定时器一并清掉(注入的 retry 由调用方负责)
|
|
150
|
+
if (this.pendingTimer !== null) {
|
|
151
|
+
clearTimeout(this.pendingTimer)
|
|
152
|
+
this.pendingTimer = null
|
|
153
|
+
}
|
|
144
154
|
this.current = { lookup: this.current.lookup, open: false }
|
|
145
155
|
}
|
|
146
156
|
|