dsh-rich-msg 0.1.0 → 0.1.1

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 CHANGED
@@ -1,255 +1,24 @@
1
1
  # dsh-rich-msg
2
2
 
3
- 一个独立、无模型行为倾向的 **DSH 富内容消息渲染引擎**(Rich Message Renderer)。
3
+ DeepSeek Harness(DSH)的富内容消息渲染插件:把声明式 JSON 渲染成跟随宿主主题、可交互的消息内容。
4
4
 
5
- 它把结构化 JSON 协议渲染成美观、可交互、跟随宿主主题的消息内容,并提供两条互不耦合的输出通道:
5
+ ## 功能
6
6
 
7
- | 通道 | 数据来源 | 最终展示位置 |
8
- |---|---|---|
9
- | **A. Markdown Fence** | 最终 Assistant 回答正文中的 ```rich-msg 代码围栏 | 原位替换该代码块 |
10
- | **B. Tool Bridge** | 工具调用 `rich_msg_publish` 暂存的结构化内容 + 最终回答中的 `ref` 引用 | 最终回答中引用所在的位置 |
7
+ - **双输出通道**:Markdown fence(```rich-msg,兼容上游 `rich-ui`)与 Tool Bridge(`rich_msg_publish` 暂存 + 最终回答 `ref` 引用);两条通道共用同一渲染器。
8
+ - **只渲染最终回答**:推理过程、工具调用阶段、中间步骤中的内容不渲染;工具暂存内容只有被最终回答明确引用后才显示。
9
+ - **组件**:布局(stack/row/grid/card/carousel/divider/meta)、文本与 callout/code、实体列表与详情、图库与图片灯箱、卡片指标/进度/状态/表格(排序筛选)/图表(bar/line/area/donut/scatter)、流程 diagram、分层 layers、图片/视频/音频、popover/tooltip、输入/选择/滑块/开关/复选/单选/分段/tabs、条件渲染与安全表达式。
10
+ - **交互**:本地 set/toggle/reset、复制、打开链接、发消息回传;控件状态按会话持久化。
11
+ - **主题**:颜色/字体/圆角/阴影取自 DSH 主题变量,支持浅色、深色与 390px 移动端。
12
+ - **安全**:严格 Schema 校验、无 HTML/JS 执行、无 eval、URL 白名单、大小/深度/数量上限、会话隔离;交互只由用户触发。
13
+ - **模型中立**:不注入 system prompt、persona 或 skill,不规定模型何时/如何使用富内容。
14
+ - **工具**:`rich_msg_validate`(Schema 校验)、`rich_msg_publish`(校验并存入会话 Artifact,返回引用 id)。
11
15
 
12
- 两条通道共用同一套 Schema、校验器、React Renderer 与主题适配层。
16
+ ## 许可证
13
17
 
14
- 本项目是**纯技术渲染层**:它不注入 system prompt / developer prompt / persona / skill,不规定模型何时使用富内容,也不改变模型回答风格。是否使用、何时使用、如何组织内容,完全由使用者自己的 Persona、Prompt 或上层应用决定。
18
+ MIT。
15
19
 
16
- ---
20
+ 本项目基于 [kyoukisu/dsh-rich-ui](https://github.com/kyoukisu/dsh-rich-ui)(MIT,Copyright (c) 2026 kyoukisu)的源码进行独立开发与重构,上游版权声明保留在 [LICENSE](LICENSE);与上游作者无隶属、合作或背书关系。
17
21
 
18
- ## 设计目标
22
+ 图标来自 [lucide](https://lucide.dev)(ISC)。
19
23
 
20
- 1. **协议先行**:先定义并校验数据结构,再渲染;非法数据永不执行、永不修补。
21
- 2. **双通道、单渲染器**:Fence 与 Tool 两种入口最终都落到同一个 `RichResponse`。
22
- 3. **只渲染最终回答**:推理过程、工具调用阶段、中间步骤中的内容不会被当成最终 UI 展示。
23
- 4. **宿主优先的视觉**:组件保留自己的布局语言,颜色、字体、背景、边框、圆角、阴影全部取自 DSH 主题变量;明暗主题与移动端开箱即用。
24
- 5. **安全默认**:声明式 JSON、无 HTML/JS 执行、无 eval、URL 白名单、Action 白名单、大小/深度/数量限制、Session 隔离。
25
- 6. **可卸载、可重挂载**:所有 React root、样式、监听器、路由、缓存都有明确的生命周期。
26
-
27
- ## 它不负责什么
28
-
29
- - 不决定模型应该如何思考、回答、教学或组织内容。
30
- - 不注册任何 prompt 段落、Persona、Skill 或 Agent 预设。
31
- - 不通过工具描述夹带 UI 使用策略。
32
- - 不替使用者选择模型、语气或教学策略。
33
-
34
- ---
35
-
36
- ## 架构
37
-
38
- ```
39
- dsh-rich-msg
40
- ├── Host Integration(Node / Cordis 插件)
41
- │ ├── lib/index.js 插件入口:注册工具、装配 Artifact Store、可选 HTTP 读取路由
42
- │ ├── Artifact Store 会话作用域、带 TTL / 容量上限 / 淘汰策略的内存存储
43
- │ └── Final Message Bridge 由「工具暂存 + 最终回答引用」构成的两段式桥接
44
- │
45
- ├── Protocol(框架无关)
46
- │ ├── core/schema.ts RichSpec v1 定义与严格校验
47
- │ ├── core/evaluator.ts 安全表达式求值(无 eval)
48
- │ ├── core/partial.ts 流式前缀解析(只呈现已闭合且合法的部分)
49
- │ └── shared/protocol.ts Fence 语言、ref 协议、Action 信封、DOM 标记、上限
50
- │
51
- ├── Renderer(React)
52
- │ ├── ui/RichResponse.tsx 节点调度、交互状态、持久化
53
- │ ├── ui/blocks/* Chart / Table / Metrics / Media / Layers / Code / Field
54
- │ ├── ui/EntityList.tsx 实体列表与详情
55
- │ └── ui/primitives/* Popover / Tooltip / Lightbox / Gallery 等基础件
56
- │
57
- ├── Client Integration(浏览器)
58
- │ ├── client/dom-renderer.tsx Fence 发现、最终消息判定、挂载/回滚
59
- │ ├── client/chat-view.ts 读取 Chat Snapshot:最终性、推理排除、Artifact 索引
60
- │ ├── client/artifact-resolver.ts ref 解析(会话日志优先,HTTP 路由兜底)
61
- │ ├── client/actions.ts 交互事件 → 用户消息
62
- │ └── client/entity-tab.tsx 可选:右侧栏实体详情页
63
- │
64
- ├── Theme
65
- │ └── client/host-theme.css DSH 主题变量 → 组件 token(明/暗/移动端)
66
- │
67
- └── Tests / scripts
68
- ```
69
-
70
- 详细设计见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
71
-
72
- ---
73
-
74
- ## 两条输出通道
75
-
76
- ### A. Markdown Fence
77
-
78
- 最终 Assistant 回答中:
79
-
80
- ````markdown
81
- ```rich-msg
82
- {"version":1,"nodes":[{"type":"text","content":"Hello"},{"type":"stat","label":"Score","value":42}]}
83
- ```
84
- ````
85
-
86
- 渲染器会:
87
-
88
- 1. 只处理 **最终回答** 的 `assistant-step` 行,并用 Chat Snapshot 校验:该步骤是整轮对话的收尾回答,且其后没有工具调用;
89
- 2. 通过 Snapshot 中该步骤的内容块区分 **正文** 与 **推理**:推理块中的围栏永不渲染;
90
- 3. 校验 JSON;非法内容保持原生代码块(流式中)或显示诊断卡片(已稳定);
91
- 4. 在原位置挂载独立的 React root 并隐藏源码块,卸载时完整还原。
92
-
93
- `rich-ui` 作为上游兼容别名同样被接受。普通代码块、其他语言、工具行内容完全不受影响。
94
-
95
- ### B. Tool Bridge
96
-
97
- 工具调用负责**准备**内容,最终回答负责**决定是否呈现**:
98
-
99
- ```
100
- 模型调用 rich_msg_publish({document})
101
- → Host 校验、存入会话作用域 Artifact Store
102
- → 返回 {ok:true, id:"rm_…", kind, summary}
103
- 模型在最终回答中写:
104
- ```rich-msg
105
- {"version":1,"ref":"rm_…"}
106
- ```
107
- → 浏览器从会话日志中的工具调用还原文档(主路径)
108
- → 或读取 Host 的只读路由(兜底)
109
- → 在最终回答的引用位置渲染
110
- ```
111
-
112
- 如果最终回答没有引用该 id,内容不会被显示;工具阶段本身不渲染任何富内容。
113
-
114
- 详细数据流与验证方法见 [docs/TOOL-BRIDGE.md](docs/TOOL-BRIDGE.md)。
115
-
116
- ---
117
-
118
- ## 支持的能力
119
-
120
- 组件与字段的完整清单见 [docs/COMPONENTS.md](docs/COMPONENTS.md),数据协议见 [docs/PROTOCOL.md](docs/PROTOCOL.md)。
121
-
122
- - **布局**:`stack` / `row` / `grid` / `card` / `carousel` / `divider` / `meta`
123
- - **文本**:`text`(行内 code/bold/链接/实体 chip)、`callout`、`code`
124
- - **对象**:`entity-list` / `entity-ref` / 实体详情面板 / 图库 / 图片灯箱 / `image` / `image-group` / `gallery`
125
- - **数据**:`stat` / `progress` / `status` / `table`(排序、筛选、状态色、进度列)/ `chart`(bar/line/area/donut/scatter)/ `computed`
126
- - **流程**:`diagram` / `layers`
127
- - **媒体**:`image` / `video` / `audio` / `popover` / `tooltip`
128
- - **控件**:`input` / `select` / `slider` / `switch` / `checkbox` / `radio` / `segmented` / `tabs`
129
- - **逻辑**:`expression` / `conditional` / 本地 Action(set/toggle/reset/open-entity/close-entity/copy/open-url/send-message)
130
-
131
- ## 工具接口
132
-
133
- | 工具 | 输入 | 输出 | 说明 |
134
- |---|---|---|---|
135
- | `rich_msg_validate` | `{document: string}` | `{valid, error?, title?, nodes?, entities?}` | 只做 Schema 校验,无副作用 |
136
- | `rich_msg_publish` | `{document: string}` | `{ok, id?, kind?, summary?, error?}` | 校验并存入当前会话的 Artifact Store,返回稳定引用 id |
137
-
138
- 工具描述只说明输入、输出与副作用,不包含任何"何时应该使用"的行为指导。
139
-
140
- ## 安装
141
-
142
- ### 方式一:本地 link(开发)
143
-
144
- ```powershell
145
- # 1) 构建
146
- node scripts/build.mjs
147
-
148
- # 2) 在 %USERPROFILE%.dshprofiles<name>package.json 中声明(示例)
149
- # {
150
- # "name": "dsh-profile-richmsg",
151
- # "private": true,
152
- # "dependencies": { "dsh-rich-msg": "link:E:/path/to/dsh-rich-msg" },
153
- # "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-rich-msg"] } }
154
- # }
155
- # 并在该 profile 的 node_modules 下建立指向本目录的 junction/symlink。
156
-
157
- # 3) 启动该 profile
158
- node <dsh>/lib/bin.js --profile <name> --host 127.0.0.1 --port 58470 --no-open
159
- ```
160
-
161
- ### 方式二:作为依赖安装
162
-
163
- 把本包发布/复制到可解析的位置后,在 profile 的 `package.json` 中:
164
-
165
- ```json
166
- {
167
- "dependencies": { "dsh-rich-msg": "link:E:/path/to/dsh-rich-msg" },
168
- "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-rich-msg"] } }
169
- }
170
- ```
171
-
172
- ```powershell
173
- dsh plugin --profile <profile> add link:E:/path/to/dsh-rich-msg
174
- # 并把 "dsh-rich-msg" 加入 dsh.profile.bundles
175
- ```
176
-
177
- > 新增/替换包后需要重启宿主进程;客户端资源会按 boot revision 重新生成。
178
-
179
- ## 主题
180
-
181
- - 全部颜色/字体/圆角/阴影来自 DSH 的 `--dsw-*` 令牌;
182
- - 同时支持浅色与深色主题(跟随 `data-ds-dark-theme` 与上游 `data-theme="dark"`);
183
- - 组件不覆盖宿主消息气泡、侧边栏或其它插件的布局;
184
- - 移动端 390px 宽度下保持可读。
185
-
186
- 详见 [docs/THEME.md](docs/THEME.md)。
187
-
188
- ## 安全
189
-
190
- - 只接受声明式 JSON;
191
- - 拒绝 `__proto__` / `constructor` / `prototype` 键,防原型污染;
192
- - 文档大小、嵌套深度、数组长度、组件数量、字符串长度全部有上限;
193
- - URL 仅允许 http(s),禁止内嵌凭据与危险协议;
194
- - Action 事件白名单校验;交互只由用户触发;
195
- - 富内容不能直接调用任意 DSH 工具;
196
- - Artifact 按 Session 隔离,跨 Session 查询与不存在等价;
197
- - 日志不写入文档内容或表单值。
198
-
199
- 详见 [docs/SECURITY.md](docs/SECURITY.md)。
200
-
201
- ## 兼容性与已验证范围
202
-
203
- - 目标宿主:`@deepseek-ai/dsh@0.2.2-alpha.2`(本机 `E:\tools\dsh-cli`)。
204
- - Fence 发现依赖宿主 DOM 标记(`data-chat-flow-kind`、`data-code-block-content` 等)。宿主升级后需重新验证;标记变化时渲染器**失败关闭**(保留原生代码块)。
205
- - 最终消息判定依赖 Chat Snapshot(`uiConversation`)。该服务不可用时退化为保守的 DOM 规则(仅已稳定的 assistant 行,且不预览流式内容),并在文档中明确记录这一降级。
206
- - **已验证**:13 个测试文件 / 210 项单元与集成测试通过;`tsc --noEmit` 0 错误;隔离 profile 下真实 DSH 宿主加载与 DOM 冒烟验证通过(插件加载、模块分发、Fence 渲染、普通代码/工具行/流式行不接管、卸载还原、零控制台错误、浅色/深色/390px 截图)。
207
- - **未验证(待人工)**:真实模型对话下的 Fence 通道与 Tool Bridge 引用渲染。自动化 CDP 发送消息未打通,已按交付要求停止;请按 [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) 第 5.3 节清单在真实 DSH 中人工验证。
208
-
209
- ## 上游项目与许可证
210
-
211
- - 本项目基于 **kyoukisu/dsh-rich-ui**(MIT)的源码进行独立开发与重构:保留了其成熟的 RichSpec Schema、表达式求值器、React 组件与视觉实现,重写了宿主集成、消息判定、工具桥接、主题桥接与文档,并移除了运行时 Prompt 注入。
212
- - 上游版权声明保留在 [LICENSE](LICENSE)(MIT, Copyright (c) 2026 kyoukisu)。
213
- - 图标来自 [lucide-react](https://lucide.dev)(ISC),详见 [docs/THIRD-PARTY.md](docs/THIRD-PARTY.md)。
214
- - 本项目与上游作者无隶属、合作或背书关系;名称 `dsh-rich-msg` 用于区分衍生项目。
215
-
216
- ## 变更报告
217
-
218
- | 模块 | 上游原有能力 | 本次修改 | 修改原因 |
219
- |---|---|---|---|
220
- | Prompt | 通过 `inject: ["systemPrompt"]` 注册 `rich-ui:fence` 段落,构建行为指令并注入每次请求 | **完全移除**运行时注入;删除 `prompt-rich-ui/` 运行时文件;技术内容改为离线文档 | 保持模型行为中立 |
221
- | Validator | `rich_ui_validate` 工具,描述含"默认使用/优先"等行为指导 | 保留校验能力,改名为 `rich_msg_validate`,描述仅含参数、返回与副作用 | 工具 API 保持纯技术性 |
222
- | Tool | 仅校验,无状态 | 新增 `rich_msg_publish` + 会话作用域 Artifact Store + 只读读取路由 | 支持 Tool 输出通道 |
223
- | Fence | DOM MutationObserver 扫描 `rich-ui` 围栏,仅以 assistant-step 祖先判定 | 保留扫描技术;新增 Chat Snapshot 最终性判定、推理排除、流式回撤、引用解析、稳定重挂载 | 只渲染最终回答,不误渲染推理/工具阶段 |
224
- | Renderer | RichSpec React 组件体系 | 复用全部组件;命名空间改为 `dsh-rich-msg`;保留既有可访问性修复 | 降低改造成本 |
225
- | Theme | 自带配色 + 部分 DSH token 桥接 | 新增完整桥接层(明/暗/移动端),统一映射 `--dsw-*` | Hybrid 视觉要求 |
226
- | Actions | `[rich-ui-action]` 用户消息 | 改为 `[rich-msg-action]`,保留白名单、大小上限、会话捕获 | 品牌统一与安全 |
227
- | Build | Vite + tsc + 独立 demo | esbuild 双产物(Host ESM / Client ModuleLoader bundle),无需 Vite 运行时 | 简化构建、与宿主加载协议对齐 |
228
- | Tests | vitest 单测 + Playwright 截图 | 保留并扩充为 13 文件 / 210 项单测与集成测试;新增真实宿主 DOM 冒烟脚本;真实模型 E2E 改为人工验证 | 可复现、不虚构验证 |
229
- | Docs | 上游内部文档 + 运行时 prompt 文件 | 重写为 7 份技术文档,且明确不得成为运行时 prompt | 交付要求 |
230
-
231
- 完整说明见 [docs/CHANGES.md](docs/CHANGES.md)。
232
-
233
- ## 开发
234
-
235
- ```powershell
236
- node scripts/build.mjs # 构建 lib/index.js 与 lib/client.js
237
- npx tsc --noEmit # 类型检查
238
- npx vitest run # 单元 + 集成测试
239
- node scripts/package.mjs # 打包 dist/*.zip + sha256
240
- ```
241
-
242
- 详见 [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)。
243
-
244
- ## 目录
245
-
246
- ```
247
- src/core 协议与求值
248
- src/host Node 插件:工具、Artifact Store、读取路由
249
- src/client 浏览器插件:Fence 渲染、消息判定、引用解析、交互回传
250
- src/ui React Renderer 与样式
251
- src/shared 双端共享协议常量
252
- tests 单元与集成测试
253
- scripts 构建与真实宿主验证脚本
254
- docs 技术文档
255
- ```
24
+ 技术文档见 `docs/`。
@@ -88,15 +88,23 @@
88
88
  └─ 通过 → 校验/挂载;任一环节失败 → 保留宿主原生代码块
89
89
  ```
90
90
 
91
- **关键点**:`assistant-step` 在 DSH 中既包含推理过程,也包含工具调用前后的中间叙述,所以"祖先匹配"不能证明它是最终回答。Snapshot 判定解决了这一点,并且当模型在后续步骤调用工具时,已挂载的流式预览会被**自动回撤**(恢复到原生代码块)。
91
+ **关键点**:`assistant-step` 在 DSH 中既包含推理过程,也包含工具调用前后的中间叙述,所以"祖先匹配"不能证明它是最终回答。Snapshot 判定解决了这一点。
92
+
93
+ **调度策略(0.1.1 起)**:完全事件驱动。
94
+
95
+ - 触发器只有三个:宿主 DOM 的 `MutationObserver` 批次、Chat Snapshot 订阅、`visibilitychange`;
96
+ - 同一事件循环内的多次触发合并到一个微任务,**没有定时器、没有轮询、没有 rAF 循环**;
97
+ - 每次只评估事件命中的行;行签名未变时零解析、零重绘;
98
+ - **默认只在行已稳定后渲染**(不渲染流式预览)。回答完成前的围栏保持宿主原生代码块,避免每个 token 重建 React 树;
99
+ - 渲染异常由 `RenderBoundary` 捕获:隐藏容器、恢复源码块,不重试同一失败树。
92
100
 
93
101
  ### 4.3 挂载与重挂载
94
102
 
95
103
  - 每个源码块对应一个独立 `createRoot` 容器,容器插在源码块之后并设置 `hidden` 隐藏源块;
96
- - 复用条件:raw 文本、会话、streaming 状态、行 owner、模式(spec/ref/diagnostic)全部一致,避免每个 token 重建整棵树;
97
- - 宿主 reconcillation 可能移动节点:每次扫描校验 `source.nextSibling === container`,需要时重新插入;
98
- - 渲染异常由 `RenderBoundary` 捕获:隐藏容器、恢复源码块,不产生无限重试;
99
- - 卸载:逐个 unmount、移除容器、恢复 `source.hidden`。
104
+ - 同一围栏内容变化时**原地更新**(复用 root、按渲染 key 去重),不销毁重建;
105
+ - 宿主 reconciler 可能移除外来节点:校验 `source.nextSibling === container` 并按需重插,**最多 3 次**;超限则放弃该行并恢复原生代码块,避免插入/删除拉锯;
106
+ - **每个 document scope 只允许一个渲染器实例**(HMR/重复安装会替换旧实例,而不是双挂载);
107
+ - 卸载:逐个 unmount、移除容器、恢复 `source.hidden`、断开观察者与订阅。
100
108
 
101
109
  ### 4.4 持久化
102
110
 
package/docs/CHANGES.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  相对上游 `kyoukisu/dsh-rich-ui@0.6.2` 的完整变更报告。
4
4
 
5
+ ## 0.1.1(稳定性修复)
6
+
7
+ 0.1.0 在长会话中会出现页面无响应("此页面没有响应")。根因是渲染调度而非数据协议:
8
+
9
+ 1. 流式期间每来一个 token 就销毁并重建一个 React root;
10
+ 2. 每次 DOM 变更全量扫描整个页面,长会话 / 长历史下开销失控;
11
+ 3. 复用分支把已渲染容器反向设为 `hidden`,与宿主 reconciler 反复拉扯。
12
+
13
+ 0.1.1 修复:
14
+
15
+ - 渲染调度改为**纯事件驱动**(DOM MutationObserver 批次 / Chat Snapshot 订阅 / `visibilitychange`,微任务合并;无定时器、无轮询、无 rAF 循环);
16
+ - **默认只在稳定的最终回答渲染**(流式预览改为可选且默认关闭),消除 per-token React 重建;
17
+ - 按事件命中的行**局部评估**,行签名缓存使重复扫描零解析、零重绘;
18
+ - 同一围栏**原地更新**,不重建 root;
19
+ - **每个 document scope 只允许一个渲染器实例**;
20
+ - 宿主移除外来容器时最多重插 3 次,超限放弃并恢复原生代码块;
21
+ - 修复复用分支的 `hidden` 反转 bug;
22
+ - 删除对已移除的 `prompt-rich-ui` 目录的残留测试引用。
23
+
24
+ 验证:`tsc --noEmit` 0 错误;15 个测试文件 / 226 项测试通过;隔离宿主压力验证(201 行模拟历史 + 150 次流式形状 DOM 变更)0 long task、最大帧间隔 19.5ms、单 root、卸载归零、无控制台错误。
25
+
5
26
  ## 1. 总体
6
27
 
7
28
  | 维度 | 上游 | dsh-rich-msg |
@@ -12,7 +33,7 @@
12
33
  | 最终消息判定 | 仅 `assistant-step` 祖先 | Snapshot 最终性 + 推理排除 + 流式回撤 |
13
34
  | 主题 | 自有配色 + 部分桥接 | 全量 DSH token 桥接(明/暗/移动端) |
14
35
  | 构建 | Vite + tsc + 独立 demo | esbuild 双产物、无 Vite 依赖 |
15
- | 测试 | vitest + Playwright 快照 | 13 文件 / 210 项单测与集成测试通过 + 真实宿主 DOM 冒烟;真实模型 E2E 改为人工 |
36
+ | 测试 | vitest + Playwright 快照 | 15 文件 / 226 项单测与集成测试通过 + 真实宿主 DOM 冒烟;真实模型 E2E 改为人工 |
16
37
 
17
38
  ## 2. 模块级明细
18
39
 
@@ -44,7 +65,7 @@
44
65
 
45
66
  ## 3.1 验证状态
46
67
 
47
- - 已通过:`tsc --noEmit`(0 错误)、`vitest run`(210/210)、隔离 profile 真实宿主加载与 DOM 冒烟。
68
+ - 已通过:`tsc --noEmit`(0 错误)、`vitest run`(15 文件 / 226 项)、隔离 profile 真实宿主加载与 DOM 冒烟、0.1.1 事件驱动压力验证。
48
69
  - 未完成(按用户要求停止自动化,改人工):真实模型对话的 Fence 通道与 Tool Bridge 引用端到端验证。
49
70
  - 未验证:Android WebView、多标签页并发、超长历史压测、宿主升级后的 DOM 契约。
50
71
 
@@ -46,7 +46,7 @@ node scripts/build.mjs
46
46
 
47
47
  ```
48
48
  lib/index.js 48,876 bytes
49
- lib/client.js 341,008 bytes
49
+ lib/client.js 346,449 bytes (0.1.1)
50
50
  host esbuild warnings: 0
51
51
  tsc --noEmit: 0 errors
52
52
  ```
@@ -59,17 +59,18 @@ npx vitest run
59
59
  ```
60
60
 
61
61
  ```
62
- Test Files 13 passed (13)
63
- Tests 210 passed (210)
62
+ Test Files 15 passed (15)
63
+ Tests 226 passed (226)
64
64
  ```
65
65
 
66
+ > 0.1.1 修复记录:0.1.0 在长会话中会出现页面无响应——根因是流式期间每 token 重建 React root、每次 DOM 变更全量扫描整页、宿主 reconcider 与容器隐藏互相拉扯。0.1.1 改为纯事件驱动、默认只在稳定回答渲染、按行局部评估、原地更新、单 scope 单实例、重插上限。上述 226 项测试与隔离宿主压力验证(201 行模拟历史 + 150 次流式形状 DOM 变更:0 long task、最大帧间隔 19.5ms、单 root、卸载归零)均在 0.1.1 上通过。
67
+
66
68
  覆盖范围:
67
69
 
68
70
  | 测试文件 | 覆盖 |
69
71
  |---|---|
70
72
  | `tests/core/schema.test.ts` | RichSpec 全字段校验、非法输入、上限、实体/引用/图表/表格/控件 |
71
73
  | `tests/core/evaluator.test.ts` | 表达式求值、算子树、状态、边界 |
72
- | `tests/core/references.test.ts` | 引用目标收集 |
73
74
  | `tests/shared/protocol.test.ts` | ref 协议、Action 信封、id 语法、大小上限 |
74
75
  | `tests/host/artifacts.test.ts` | 会话隔离、TTL、容量、淘汰、清理 |
75
76
  | `tests/host/tools.test.ts` | 工具 Schema、中性描述、校验/发布行为、取消信号 |
package/lib/client.js CHANGED
@@ -9783,7 +9783,9 @@ function looksLikeDocument(raw) {
9783
9783
  }
9784
9784
  function claimFence(source, body, raw) {
9785
9785
  if (raw.length > MAX_SOURCE_LENGTH) {
9786
- if (isOurs(labelToken(fenceLabel(source, body)))) return { kind: "invalid", error: "Document is longer than " + MAX_SOURCE_LENGTH + " characters" };
9786
+ if (isOurs(labelToken(fenceLabel(source, body)))) {
9787
+ return { kind: "invalid", error: "Document is longer than " + MAX_SOURCE_LENGTH + " characters" };
9788
+ }
9787
9789
  return null;
9788
9790
  }
9789
9791
  const label = fenceLabel(source, body);
@@ -9819,14 +9821,39 @@ function rowOf(source) {
9819
9821
  function isStreamingDom(source) {
9820
9822
  return source.closest("[data-streaming]") !== null;
9821
9823
  }
9822
- function factsTag(facts) {
9823
- if (facts === void 0) return "no-facts";
9824
- return facts.finality + "|" + facts.fenceLocation;
9824
+ function surfacesOf(row) {
9825
+ return [...row.querySelectorAll(SURFACES)].filter(
9826
+ (surface) => !surface.closest(OWN) && !surface.parentElement?.closest(SURFACES)
9827
+ );
9828
+ }
9829
+ function rowSignature(row) {
9830
+ const key2 = row.getAttribute("data-chat-node-key") ?? row.getAttribute("data-chat-anchor-key") ?? "";
9831
+ const streaming = isStreamingDom(row) ? "1" : "0";
9832
+ const surfaces = surfacesOf(row);
9833
+ const lengths = surfaces.map((surface) => {
9834
+ const body = surface.querySelector("pre") ?? surface.querySelector("[data-code-block-content]");
9835
+ return body?.textContent?.length ?? 0;
9836
+ });
9837
+ return key2 + "|" + streaming + "|" + surfaces.length + "|" + lengths.join(",");
9825
9838
  }
9826
- function installFenceRenderer(deps, scope = document.body) {
9839
+ function installFenceRenderer(deps, scope = document.body, options = {}) {
9840
+ if (activeScopeRenderer !== void 0 && activeScopeRenderer.scope === scope) {
9841
+ try {
9842
+ activeScopeRenderer.dispose();
9843
+ } catch {
9844
+ }
9845
+ }
9846
+ const previewAllowed = options.previewDuringStreaming === true;
9847
+ const maxMounts = typeof options.maxMounts === "number" && options.maxMounts > 0 ? options.maxMounts : 40;
9827
9848
  const mounts = /* @__PURE__ */ new Map();
9849
+ const knownRows = /* @__PURE__ */ new Set();
9850
+ const unstableRows = /* @__PURE__ */ new WeakSet();
9851
+ const rowSignatures = /* @__PURE__ */ new Map();
9828
9852
  let disposed = false;
9829
- let scheduled = false;
9853
+ let flushPending = false;
9854
+ let pendingRows = /* @__PURE__ */ new Set();
9855
+ let fullRescan = true;
9856
+ let sessionId;
9830
9857
  const remove = (source, mount) => {
9831
9858
  try {
9832
9859
  mount.root.unmount();
@@ -9837,9 +9864,9 @@ function installFenceRenderer(deps, scope = document.body) {
9837
9864
  mounts.delete(source);
9838
9865
  };
9839
9866
  const paint = (mount, facts) => {
9840
- const sessionId = mount.sessionId;
9867
+ const session = mount.sessionId;
9841
9868
  const onAction = (action2, payload) => {
9842
- deps.sendAction(sessionId, action2, payload);
9869
+ deps.sendAction(session, action2, payload);
9843
9870
  };
9844
9871
  const onOpenEntity = (entity, entities) => {
9845
9872
  if (deps.openEntity === void 0) return false;
@@ -9872,63 +9899,87 @@ function installFenceRenderer(deps, scope = document.body) {
9872
9899
  const error = mount.mode === "resolving" ? "Resolving reference..." : mount.error ?? "Invalid document";
9873
9900
  mount.root.render((0, import_react16.createElement)(Diagnostic, { key: error + ":" + mount.raw, error, source: mount.raw }));
9874
9901
  };
9875
- const claimFor = (source, body, raw, row) => {
9876
- const facts = deps.chat.factsForRow(row, raw);
9877
- const claim = claimFence(source, body, raw);
9878
- if (claim === null) return { claim: null, facts, streaming: false };
9879
- if (facts !== void 0) {
9880
- if (facts.fenceLocation === "reasoning") return { claim: null, facts, streaming: false };
9881
- if (facts.finality === "not-final") return { claim: null, facts, streaming: false };
9882
- if (facts.finality === "streaming-final" && facts.fenceLocation !== "text") return { claim: null, facts, streaming: false };
9883
- return { claim, facts, streaming: facts.finality === "streaming-final" };
9884
- }
9885
- if (isStreamingDom(source)) return { claim: null, facts, streaming: false };
9886
- return { claim, facts, streaming: false };
9902
+ const paintIfNeeded = (mount, facts) => {
9903
+ const key2 = mount.mode + "|" + mount.raw + "|" + (mount.error ?? "") + "|" + (mount.ref ?? "") + "|" + (facts?.finality ?? "");
9904
+ if (mount.renderedKey === key2) return;
9905
+ mount.renderedKey = key2;
9906
+ paint(mount, facts);
9887
9907
  };
9888
- const scan = () => {
9889
- scheduled = false;
9890
- if (disposed) return;
9891
- for (const [source, mount] of mounts) {
9892
- if (!scope.contains(source)) remove(source, mount);
9908
+ const ensurePlacement = (source, mount) => {
9909
+ if (source.nextSibling !== mount.container) {
9910
+ if (!source.isConnected) return false;
9911
+ mount.reinserts += 1;
9912
+ if (mount.reinserts > 3) {
9913
+ const row = rowOf(source);
9914
+ if (row !== null) unstableRows.add(row);
9915
+ return false;
9916
+ }
9917
+ source.after(mount.container);
9893
9918
  }
9894
- const candidates = new Set(scope.querySelectorAll(SURFACES));
9895
- for (const body of scope.querySelectorAll("[data-code-block-content]")) {
9896
- if (body.parentElement !== null) candidates.add(body.parentElement);
9919
+ if (!source.hidden) source.hidden = true;
9920
+ if (mount.container.hidden) mount.container.hidden = false;
9921
+ return true;
9922
+ };
9923
+ const evaluateRow = (row, force = false) => {
9924
+ if (disposed || !row.isConnected) return;
9925
+ if (unstableRows.has(row)) return;
9926
+ if (row.getAttribute("data-chat-flow-kind") !== ANSWER_ROW) {
9927
+ for (const source of surfacesOf(row)) {
9928
+ const mount = mounts.get(source);
9929
+ if (mount !== void 0) remove(source, mount);
9930
+ }
9931
+ knownRows.delete(row);
9932
+ return;
9933
+ }
9934
+ const surfaces = surfacesOf(row);
9935
+ if (surfaces.length === 0) {
9936
+ knownRows.delete(row);
9937
+ return;
9938
+ }
9939
+ knownRows.add(row);
9940
+ const signature = rowSignature(row);
9941
+ if (!force && rowSignatures.get(row) === signature) {
9942
+ for (const source of surfaces) {
9943
+ const mount = mounts.get(source);
9944
+ if (mount !== void 0 && !ensurePlacement(source, mount)) remove(source, mount);
9945
+ }
9946
+ return;
9897
9947
  }
9898
- const sessionId = deps.chat.sessionId();
9899
- for (const source of candidates) {
9900
- if (source.closest(OWN)) continue;
9901
- const row = rowOf(source);
9948
+ rowSignatures.set(row, signature);
9949
+ for (const source of surfaces) {
9902
9950
  const body = source.querySelector("pre") ?? source.querySelector("[data-code-block-content]");
9903
- if (row === null || body === null || row.getAttribute("data-chat-flow-kind") !== ANSWER_ROW) {
9904
- const mount2 = mounts.get(source);
9905
- if (mount2 !== void 0) remove(source, mount2);
9951
+ if (body === null) {
9952
+ const stale = mounts.get(source);
9953
+ if (stale !== void 0) remove(source, stale);
9906
9954
  continue;
9907
9955
  }
9908
9956
  const raw = body.textContent ?? "";
9909
- const evaluated = claimFor(source, body, raw, row);
9957
+ const facts = deps.chat.factsForRow(row, raw);
9958
+ const claim = claimFence(source, body, raw);
9910
9959
  const mount = mounts.get(source);
9911
- if (evaluated.claim === null) {
9960
+ const streaming = facts !== void 0 ? facts.finality === "streaming-final" : isStreamingDom(source);
9961
+ const claimable = claim !== null && !(facts !== void 0 && facts.fenceLocation === "reasoning") && !(facts !== void 0 && facts.finality === "not-final") && !(facts !== void 0 && facts.finality === "streaming-final" && facts.fenceLocation !== "text") && !(streaming && !previewAllowed);
9962
+ if (!claimable || claim === null) {
9912
9963
  if (mount !== void 0) remove(source, mount);
9913
9964
  continue;
9914
9965
  }
9915
- if (mount !== void 0 && mount.sessionId !== sessionId) {
9916
- remove(source, mount);
9917
- }
9918
- const existing = mounts.get(source);
9919
9966
  const owner = fenceOwnerKey(source, row);
9920
- const mode = evaluated.claim.kind === "spec" ? "rich" : evaluated.claim.kind === "ref" ? "resolving" : "diagnostic";
9921
- const ref = evaluated.claim.kind === "ref" ? evaluated.claim.ref : void 0;
9922
- if (existing !== void 0 && !existing.failed && existing.raw === raw && existing.sessionId === sessionId && existing.streaming === evaluated.streaming && existing.owner === owner && existing.ref === ref && existing.mode === mode) {
9923
- if (source.nextSibling !== existing.container) source.after(existing.container);
9924
- if (!source.hidden) {
9925
- existing.container.hidden = true;
9926
- source.hidden = true;
9967
+ const nextMode = claim.kind === "spec" ? "rich" : claim.kind === "ref" ? "resolving" : "diagnostic";
9968
+ const nextRef = claim.kind === "ref" ? claim.ref : void 0;
9969
+ if (mount !== void 0 && !mount.failed && mount.sessionId === sessionId && mount.owner === owner && mount.ref === nextRef && mount.mode !== "resolving") {
9970
+ mount.raw = raw;
9971
+ mount.streaming = streaming;
9972
+ mount.error = claim.kind === "invalid" ? claim.error : void 0;
9973
+ mount.spec = claim.kind === "spec" ? claim.spec : mount.spec;
9974
+ if (!ensurePlacement(source, mount)) {
9975
+ remove(source, mount);
9976
+ continue;
9927
9977
  }
9978
+ paintIfNeeded(mount, facts);
9928
9979
  continue;
9929
9980
  }
9930
- if (existing !== void 0) remove(source, existing);
9931
- if (evaluated.streaming && evaluated.claim.kind === "invalid") continue;
9981
+ if (mount !== void 0) remove(source, mount);
9982
+ if (mounts.size >= maxMounts) continue;
9932
9983
  const container = document.createElement("div");
9933
9984
  container.setAttribute(DOM_MARKERS.root, "");
9934
9985
  const root = (0, import_client.createRoot)(container);
@@ -9938,23 +9989,20 @@ function installFenceRenderer(deps, scope = document.body) {
9938
9989
  root,
9939
9990
  raw,
9940
9991
  sourceHidden: source.hidden,
9941
- streaming: evaluated.streaming,
9942
- mode,
9943
- ref,
9944
- spec: evaluated.claim.kind === "spec" ? evaluated.claim.spec : void 0,
9945
- error: evaluated.claim.kind === "invalid" ? evaluated.claim.error : void 0,
9992
+ streaming,
9993
+ mode: nextMode,
9994
+ ref: nextRef,
9995
+ spec: claim.kind === "spec" ? claim.spec : void 0,
9996
+ error: claim.kind === "invalid" ? claim.error : void 0,
9946
9997
  sessionId,
9947
- owner
9998
+ owner,
9999
+ reinserts: 0
9948
10000
  };
9949
10001
  mounts.set(source, next);
9950
10002
  source.after(container);
9951
10003
  source.hidden = true;
9952
- if (mode === "rich") {
9953
- paint(next, evaluated.facts);
9954
- continue;
9955
- }
9956
- if (mode === "diagnostic") {
9957
- paint(next, evaluated.facts);
10004
+ if (nextMode === "rich" || nextMode === "diagnostic") {
10005
+ paintIfNeeded(next, facts);
9958
10006
  continue;
9959
10007
  }
9960
10008
  const referenceId = next.ref ?? "";
@@ -9963,7 +10011,7 @@ function installFenceRenderer(deps, scope = document.body) {
9963
10011
  applyResolution(next, immediate);
9964
10012
  continue;
9965
10013
  }
9966
- paint(next, evaluated.facts);
10014
+ paintIfNeeded(next, facts);
9967
10015
  void deps.artifacts.resolve(sessionId, referenceId).then((resolved) => {
9968
10016
  if (disposed || mounts.get(source) !== next) return;
9969
10017
  applyResolution(next, resolved);
@@ -9975,52 +10023,156 @@ function installFenceRenderer(deps, scope = document.body) {
9975
10023
  mount.mode = "rich";
9976
10024
  mount.spec = resolved.spec;
9977
10025
  mount.container.hidden = false;
9978
- paint(mount, void 0);
10026
+ mount.renderedKey = void 0;
10027
+ paintIfNeeded(mount, void 0);
9979
10028
  return;
9980
10029
  }
9981
10030
  mount.mode = "diagnostic";
9982
10031
  mount.error = "Unresolved rich-msg reference: " + (resolved.id || "missing id");
9983
10032
  mount.container.hidden = false;
9984
- paint(mount, void 0);
10033
+ mount.renderedKey = void 0;
10034
+ paintIfNeeded(mount, void 0);
10035
+ };
10036
+ const rowsFromRecords = (records) => {
10037
+ const rows = /* @__PURE__ */ new Set();
10038
+ let full = false;
10039
+ for (const record of records) {
10040
+ const target = record.target instanceof Element ? record.target : record.target.parentElement;
10041
+ if (target === null) {
10042
+ full = true;
10043
+ continue;
10044
+ }
10045
+ if (target.closest(OWN) !== null) continue;
10046
+ const row = target.closest("[data-chat-flow-kind]");
10047
+ if (row !== null) rows.add(row);
10048
+ for (const node of [...record.addedNodes, ...record.removedNodes]) {
10049
+ if (!(node instanceof Element)) continue;
10050
+ if (node.matches(OWN) || node.closest(OWN) !== null) continue;
10051
+ const addedRow = node.closest?.("[data-chat-flow-kind]");
10052
+ if (addedRow !== null && addedRow !== void 0) rows.add(addedRow);
10053
+ for (const nested of node.querySelectorAll?.("[data-chat-flow-kind]") ?? []) {
10054
+ if (nested instanceof HTMLElement) rows.add(nested);
10055
+ }
10056
+ }
10057
+ if (record.type === "childList" || record.type === "characterData") {
10058
+ const owner = target.closest("[data-chat-flow-kind]");
10059
+ if (owner !== null) rows.add(owner);
10060
+ }
10061
+ }
10062
+ return { rows, full };
10063
+ };
10064
+ const schedule = (rows, full = false) => {
10065
+ if (disposed) return;
10066
+ if (full) fullRescan = true;
10067
+ if (rows !== void 0) for (const row of rows) pendingRows.add(row);
10068
+ if (flushPending) return;
10069
+ flushPending = true;
10070
+ queueMicrotask(() => {
10071
+ flushPending = false;
10072
+ if (disposed) return;
10073
+ flush();
10074
+ });
9985
10075
  };
9986
- const schedule = () => {
9987
- if (scheduled || disposed) return;
9988
- scheduled = true;
9989
- if (scope.querySelector("[data-streaming]") !== null && typeof requestAnimationFrame === "function") {
9990
- requestAnimationFrame(scan);
9991
- } else {
9992
- queueMicrotask(scan);
10076
+ const flush = () => {
10077
+ if (disposed) return;
10078
+ const nextSession = deps.chat.sessionId();
10079
+ if (nextSession !== sessionId) {
10080
+ sessionId = nextSession;
10081
+ fullRescan = true;
10082
+ }
10083
+ if (fullRescan) {
10084
+ fullRescan = false;
10085
+ for (const [source, mount] of mounts) remove(source, mount);
10086
+ knownRows.clear();
10087
+ rowSignatures.clear();
10088
+ const rows = /* @__PURE__ */ new Set();
10089
+ for (const surface of scope.querySelectorAll(SURFACES)) {
10090
+ if (surface.closest(OWN) !== null) continue;
10091
+ const row = rowOf(surface);
10092
+ if (row !== null) rows.add(row);
10093
+ }
10094
+ for (const row of rows) evaluateRow(row, true);
10095
+ return;
10096
+ }
10097
+ for (const row of pendingRows) {
10098
+ if (!row.isConnected) {
10099
+ knownRows.delete(row);
10100
+ continue;
10101
+ }
10102
+ evaluateRow(row);
10103
+ }
10104
+ pendingRows = /* @__PURE__ */ new Set();
10105
+ for (const [source, mount] of mounts) {
10106
+ if (!source.isConnected || !mount.container.isConnected) remove(source, mount);
9993
10107
  }
10108
+ void knownRows;
9994
10109
  };
9995
- const observer = new MutationObserver((records) => {
9996
- if (records.every((record) => {
9997
- const target = record.target instanceof Element ? record.target : record.target.parentElement;
9998
- return target?.closest(OWN) !== null && target?.closest(OWN) !== void 0;
9999
- })) {
10110
+ const onChatChange = () => {
10111
+ if (disposed) return;
10112
+ const nextSession = deps.chat.sessionId();
10113
+ const rows = /* @__PURE__ */ new Set();
10114
+ for (const row of knownRows) {
10115
+ if (row.isConnected) rows.add(row);
10116
+ }
10117
+ if (nextSession !== sessionId) {
10118
+ schedule(rows, true);
10000
10119
  return;
10001
10120
  }
10002
- schedule();
10121
+ if (rows.size === 0) return;
10122
+ if (flushPending) return;
10123
+ flushPending = true;
10124
+ queueMicrotask(() => {
10125
+ flushPending = false;
10126
+ if (disposed) return;
10127
+ for (const row of rows) if (row.isConnected) evaluateRow(row, true);
10128
+ for (const [source, mount] of mounts) {
10129
+ if (!source.isConnected || !mount.container.isConnected) remove(source, mount);
10130
+ }
10131
+ });
10132
+ };
10133
+ const observer = new MutationObserver((records) => {
10134
+ if (disposed) return;
10135
+ const { rows, full } = rowsFromRecords(records);
10136
+ if (full || rows.size > 0) schedule(rows, full);
10003
10137
  });
10004
10138
  observer.observe(scope, {
10005
10139
  childList: true,
10006
10140
  subtree: true,
10007
10141
  characterData: true,
10008
10142
  attributes: true,
10009
- attributeFilter: ["data-streaming", "hidden", "data-chat-node-key", "data-chat-anchor-key", "data-chat-flow-kind"]
10143
+ attributeFilter: ["data-streaming", "data-chat-node-key", "data-chat-anchor-key", "data-chat-flow-kind"]
10010
10144
  });
10011
- const unsubscribeFacts = deps.chat.subscribe(schedule);
10012
- scan();
10013
- return () => {
10145
+ const onVisibility = () => {
10146
+ if (disposed) return;
10147
+ if (typeof document !== "undefined" && document.hidden) return;
10148
+ schedule(void 0, true);
10149
+ };
10150
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
10151
+ const unsubscribeChat = deps.chat.subscribe(onChatChange);
10152
+ sessionId = deps.chat.sessionId();
10153
+ fullRescan = true;
10154
+ flush();
10155
+ const dispose = () => {
10014
10156
  if (disposed) return;
10015
10157
  disposed = true;
10016
- unsubscribeFacts();
10158
+ unsubscribeChat();
10017
10159
  observer.disconnect();
10160
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
10018
10161
  for (const [source, mount] of mounts) remove(source, mount);
10162
+ knownRows.clear();
10163
+ rowSignatures.clear();
10164
+ pendingRows = /* @__PURE__ */ new Set();
10165
+ if (activeScopeRenderer !== void 0 && activeScopeRenderer.dispose === dispose) {
10166
+ activeScopeRenderer = void 0;
10167
+ }
10019
10168
  };
10169
+ activeScopeRenderer = { scope, dispose };
10170
+ return dispose;
10020
10171
  }
10172
+ var activeScopeRenderer;
10021
10173
  function persistenceKeyOf(mount, facts) {
10022
- if (mount.sessionId === void 0) return void 0;
10023
- const tag = factsTag(facts);
10174
+ if (mount.sessionId === void 0) return JSON.stringify(["dsh-rich-msg", mount.owner ?? "", mount.ref ?? ""]);
10175
+ const tag = facts === void 0 ? "no-facts" : facts.finality + "|" + facts.fenceLocation;
10024
10176
  return JSON.stringify(["dsh-rich-msg", mount.sessionId, mount.owner ?? "", tag, mount.ref ?? ""]);
10025
10177
  }
10026
10178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rich-msg",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Rich content message renderer for DeepSeek Harness: declarative JSON rendered as themed, interactive message UI, with fence and tool-bridge channels.",
5
5
  "license": "MIT",
6
6
  "type": "module",