dsh-markdown-bubble 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/LICENSE +21 -0
- package/README.md +184 -0
- package/cordis.patch.yml +12 -0
- package/package.json +54 -0
- package/src/client.js +730 -0
- package/src/index.js +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DDDMUC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# dsh-markdown-bubble
|
|
2
|
+
|
|
3
|
+
**DeepSeek Harness 用户消息 Markdown 渲染插件 —— 你发出去的每一条消息,都按 Markdown 渲染。** 标题、列表、加粗、行内代码、引用、代码块、表格与公式不再以源码形态躺在气泡里,而是经宿主自己的 Markdown 管线(`MarkdownText`)呈现;引用 chip、附件、时间/复制操作条与其它插件注入的按钮全部原样保留。实现方式是官方 keyed 槽位 `conversation.chat.node` 的 `user` / `steering` 座位替换,不修改 DSH 源码,不改写任何会话日志。
|
|
4
|
+
|
|
5
|
+
[中文](#中文) · [English](#english)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 中文
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
<img src="https://raw.githubusercontent.com/DDDMUC/dsh-markdown-bubble/main/docs/screenshots/01-markdown-bubble.png" alt="用户消息气泡按 Markdown 渲染" width="820" />
|
|
13
|
+
<br>
|
|
14
|
+
<sub>▲ 发出的 Markdown 按格式渲染;单换行保留为换行,代码块带宿主同款语法高亮卡</sub>
|
|
15
|
+
<br><br>
|
|
16
|
+
<img src="https://raw.githubusercontent.com/DDDMUC/dsh-markdown-bubble/main/docs/screenshots/02-action-strip.png" alt="操作条上的复制、删除、重跑、编辑按钮" width="520" />
|
|
17
|
+
<br>
|
|
18
|
+
<sub>▲ 操作条原样保留:时间、复制,以及 dsh-delete-turn / dsh-rerun-turn / dsh-edit-turn 注入的按钮</sub>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
### 为什么需要它
|
|
22
|
+
|
|
23
|
+
DSH 的用户消息气泡按原样显示你输入的文字(`white-space: pre-wrap`):粘贴一段 README、写一段带列表的提示词、贴一段代码,都要先在脑子里解一遍 Markdown。助手回复有完整的 Markdown 管线,你自己的消息却没有。
|
|
24
|
+
|
|
25
|
+
这个插件把你这一侧的气泡也接到同一条管线上:
|
|
26
|
+
|
|
27
|
+
- 标题、列表、有序列表、任务列表、加粗 / 斜体 / 删除线、行内代码;
|
|
28
|
+
- 围栏代码块(宿主同款语法高亮与复制 / 换行工具条)、引用、分割线;
|
|
29
|
+
- 表格(宽表可横向滚动)、链接、图片、KaTeX 公式、脚注;
|
|
30
|
+
- 单换行保留为换行(GFM 语义会把 `第一行\n第二行` 合并成一段,聊天里没有人希望这样)。
|
|
31
|
+
|
|
32
|
+
### 特性
|
|
33
|
+
|
|
34
|
+
- **官方座位替换** —— 经 `ctx.slots` 在 `conversation.chat.node` 的 `user` / `steering` 两个 keyed 座位上以 `priority: -1` 遮蔽宿主渲染器;停用/卸载即恢复宿主自己的气泡,不改源码、不动日志
|
|
35
|
+
- **宿主同款渲染** —— 不自己造 Markdown 管线,直接调用 `@deepseek-ai/dsh-client-ui-primitives` 的 `MarkdownText`:与助手回复同一套解析、高亮、表格、公式与代码卡
|
|
36
|
+
- **引用 chip 保留** —— 宿主投影函数 `projectUserText` 照常调用,返回的片段按 chip / 纯文本拆分:chip 仍是可点击的文件 / 技能 chip,纯文本段逐个进 Markdown
|
|
37
|
+
- **操作条与三方插件兼容** —— 时间 + 复制按钮用官方 primitives 重建,操作条保留 `_actions` / `_action` 类名后缀与 `data-clock` 锚点;dsh-edit-turn、dsh-delete-turn、dsh-rerun-turn 的按钮注入照常工作
|
|
38
|
+
- **附件与补充块** —— 图片走官方 `conversation.message.images` 座位(`renderMessageImages`),文件卡、引用摘要、`JsonBlock` 补充块与宿主同款
|
|
39
|
+
- **软换行保留** —— 仅对围栏代码之外的续行补 GFM 两空格硬换行;代码块一个字节不动,缩进代码与表格行不误伤
|
|
40
|
+
- **出错兜底** —— 单条消息的渲染错误被错误边界接住,回退为宿主的原始投影,不会连累整条座位被槽位机制摘除
|
|
41
|
+
- **中英双语** 随系统语言切换(代码卡文案复用宿主 `chat` 字典)
|
|
42
|
+
|
|
43
|
+
### 安装
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
# npm 安装(web profile)
|
|
47
|
+
dsh plugin --profile web add dsh-markdown-bubble
|
|
48
|
+
|
|
49
|
+
# 本地工作区(开发)
|
|
50
|
+
dsh plugin --profile web add link:/path/to/dsh-markdown-bubble
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`dsh plugin add` 会把依赖写进 profile 并把包追加到 `dsh.profile.bundles`。实测宿主随即扫描到新行、浏览器端热挂载,无需重启;若你的宿主版本未热载,重启 DSH 进程后刷新页面即可。
|
|
54
|
+
|
|
55
|
+
### 使用
|
|
56
|
+
|
|
57
|
+
安装即生效,无命令、无设置项。发一条带 Markdown 的消息即可看到渲染结果:
|
|
58
|
+
|
|
59
|
+
````markdown
|
|
60
|
+
# 标题
|
|
61
|
+
|
|
62
|
+
- 列表 **加粗**
|
|
63
|
+
- 第二项 `行内代码`
|
|
64
|
+
|
|
65
|
+
> 引用一行
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
const a = 1
|
|
69
|
+
```
|
|
70
|
+
````
|
|
71
|
+
|
|
72
|
+
排队中的消息预览、输入框本身与 goal 面板保持纯文本(见「已知限制」)。
|
|
73
|
+
|
|
74
|
+
### 工作原理
|
|
75
|
+
|
|
76
|
+
1. **座位遮蔽**:浏览器半区 `ctx.slots.inject('conversation.chat.node', …)` 注册 `user` 与 `steering` 两个 keyed 条目,`priority: -1` 低于宿主默认的 `0`(同 key 同优先级会抛错,最低者渲染)。
|
|
77
|
+
2. **内容拆分**:从节点数据取 `content`,按宿主 `UserStyleBubble` 的同一规则拆成文本 / 图片附件 / 文件卡 / 剩余块。
|
|
78
|
+
3. **chip 感知的 Markdown 合成**:调用宿主投影 `projectUserText`,把返回片段分成 chip 与纯文本;纯文本段逐个经 `MarkdownText` 渲染,chip 原样成为可点击元素。
|
|
79
|
+
4. **源码预处理**:CRLF 归一、会话回顾 wire 链接(`@[label](dsh-session:…)`)还原为显示名、围栏代码之外的续行补两空格硬换行。
|
|
80
|
+
5. **外壳复用**:行 / 堆栈 / 气泡 / 文件卡的度量值逐条对齐宿主样式表(`MessageItem.module.css`、`MessageIconActions.module.css`),颜色全部走主题 token;操作条在 DOM 序中前置、用 flex `order` 还原视觉顺序——这样三方插件 `querySelector('[class*="_actions"]')` 的首个命中永远是消息操作条,而不是气泡内代码卡的工具条。
|
|
81
|
+
|
|
82
|
+
### 已知限制
|
|
83
|
+
|
|
84
|
+
- **待确认气泡不覆盖**:本地回声与待发送 steering 由宿主 `PendingSubmissionBubble` / `PendingSteeringBubble` 直绘,不走 chat-node 座位,仍是纯文本;消息落盘后即切换为 Markdown。
|
|
85
|
+
- **队列预览不覆盖**:排队消息的一行预览与 goal 命令行保持纯文本。
|
|
86
|
+
- **行内 chip 的折衷**:chip 打断连排段落时,该段会拆成 chip 两边的块(与社区同类实现相同的取舍);chip 在段首/段尾的常见情形无缝。
|
|
87
|
+
- **皮肤边界**:若某个皮肤直接针对宿主 CSS-module 类名定制用户气泡样式,不会命中本插件的气泡;主题 token 与 `.dshmb-*` / `.dshmb_*` 自定义样式正常生效。
|
|
88
|
+
- **多行语义**:单换行按硬换行渲染;连续空格、Tab 缩进等 Markdown 忽略的空白会被折叠(代码块内除外)。
|
|
89
|
+
|
|
90
|
+
### 兼容性
|
|
91
|
+
|
|
92
|
+
- 实测 DSH `0.2.0-rc.1`(web profile,Chromium)。依赖:keyed 槽位 `conversation.chat.node`、行锚点 `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo`、primitives 导出的 `MarkdownText` / `projectUserText` / `writeClipboard`。
|
|
93
|
+
- 与 `dsh-edit-turn`、`dsh-delete-turn`、`dsh-rerun-turn` 协同:三者的按钮注入基于 `[class*="_actions"]` / `[class*="_action"]`,本插件保留该契约;被 dsh-edit-turn 改写(rollback)的消息显示其自己的改写气泡,与本插件无关。
|
|
94
|
+
- 宿主半区为惰性入口(无运行时行为),全部逻辑在 `./client` 浏览器半区;不修改官方源码,不写私有事件类型。
|
|
95
|
+
|
|
96
|
+
### 修复记录
|
|
97
|
+
|
|
98
|
+
- **0.1.1** — 修复两个引用 chip 之间只有空白时该空白被当作空 Markdown 块丢弃的问题:相邻 chip 不再粘成 `@a@b`,chip 之间的换行也按宿主 `pre-wrap` 气泡的样子保留为换行(空白段渲染为 `data-dshmb-gap` 的预换行节点,仍不产生空 Markdown 块);自建的操作条 / 复制按钮补上 `data-dshmb-actions` / `data-dshmb-action` 命名空间标记;座位优先级 `-1` 的宿主依据(宿主默认 0、最低者渲染、同优先级抛错)与三个兄弟插件依赖的锚点结构一并加进回归测试。
|
|
99
|
+
|
|
100
|
+
### License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## English
|
|
107
|
+
|
|
108
|
+
**Markdown rendering for DeepSeek Harness sent messages — every message you send is rendered as Markdown.** Headings, lists, bold, inline code, quotes, code fences, tables and math stop sitting in the bubble as source text and render through the host's own Markdown pipeline (`MarkdownText`); reference chips, attachments, the clock/copy action strip and sibling plugins' injected buttons all stay exactly where they were. It works by shadowing the official `user` / `steering` keyed seats of `conversation.chat.node` — no DSH source changes, no session-log rewrites.
|
|
109
|
+
|
|
110
|
+
### Why you need it
|
|
111
|
+
|
|
112
|
+
DSH draws your own messages verbatim (`white-space: pre-wrap`): paste a README, write a prompt with a list, drop in a code snippet, and you parse the Markdown in your head first. Assistant replies get the full pipeline; your side does not. This plugin puts your side on the same pipeline: headings, lists, task lists, bold/italic/strikethrough, inline code, fenced code blocks (with the host's syntax highlighting and copy/wrap toolbar), quotes, rules, tables (wide ones scroll), links, images, KaTeX math and footnotes — with single line breaks preserved as line breaks.
|
|
113
|
+
|
|
114
|
+
### Features
|
|
115
|
+
|
|
116
|
+
- **Official seat replacement** — registers `user` and `steering` keyed entries in `conversation.chat.node` at `priority: -1` through `ctx.slots`; disabling or uninstalling restores the host's own bubble. No source edits, no log writes.
|
|
117
|
+
- **The host's own renderer** — calls `MarkdownText` from `@deepseek-ai/dsh-client-ui-primitives`, so user messages share the assistant pipeline: the same parsing, highlighting, tables, math and code cards.
|
|
118
|
+
- **Reference chips stay chips** — the host projection (`projectUserText`) is called as usual and split into chips and plain runs; chips keep their click targets, each plain run becomes a Markdown block.
|
|
119
|
+
- **Action strip and sibling-plugin compatibility** — clock and copy are rebuilt from exported primitives; the strip keeps the `_actions` / `_action` class suffixes and the `data-clock` anchor that dsh-edit-turn, dsh-delete-turn and dsh-rerun-turn search for.
|
|
120
|
+
- **Attachments and extra blocks** — images go through the official `conversation.message.images` seat (`renderMessageImages`); file cards, the reference summary and `JsonBlock` extras match the host.
|
|
121
|
+
- **Soft breaks preserved** — continuation lines outside fenced code get GFM's two-space hard break; code keeps every byte, indented code and table rows are left alone.
|
|
122
|
+
- **Crash containment** — a per-message error boundary falls back to the host's plain projection instead of having the slot machinery retire the whole seat.
|
|
123
|
+
- **Bilingual** copy follows the active locale (code-card chrome reuses the host `chat` dictionary).
|
|
124
|
+
|
|
125
|
+
### Install
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
# from npm (web profile)
|
|
129
|
+
dsh plugin --profile web add dsh-markdown-bubble
|
|
130
|
+
|
|
131
|
+
# local workspace (development)
|
|
132
|
+
dsh plugin --profile web add link:/path/to/dsh-markdown-bubble
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`dsh plugin add` writes the dependency into the profile and appends the package to `dsh.profile.bundles`. In testing the host picked the new row up live and the browser hot-mounted it with no restart; if your host build does not hot-load, restart DSH and reload the page.
|
|
136
|
+
|
|
137
|
+
### Usage
|
|
138
|
+
|
|
139
|
+
Install and send Markdown — no command, no setting. A smoke test:
|
|
140
|
+
|
|
141
|
+
````markdown
|
|
142
|
+
# Heading
|
|
143
|
+
|
|
144
|
+
- list with **bold**
|
|
145
|
+
- second item `inline code`
|
|
146
|
+
|
|
147
|
+
> a quote
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const a = 1
|
|
151
|
+
```
|
|
152
|
+
````
|
|
153
|
+
|
|
154
|
+
Queued-message previews, the composer itself and the goal panel stay plain text (see limitations).
|
|
155
|
+
|
|
156
|
+
### How it works
|
|
157
|
+
|
|
158
|
+
1. **Seat shadowing** — the browser half registers `user` and `steering` keyed entries under `conversation.chat.node` at `priority: -1`, lower than the host's default `0` (same key at the same priority throws; the lowest renders).
|
|
159
|
+
2. **Content split** — the node's `content` is split exactly as the host's `UserStyleBubble` does: text, image attachments, file cards, remaining blocks.
|
|
160
|
+
3. **Chip-aware Markdown composition** — the host projection is split into chips and plain runs; each plain run renders through `MarkdownText`, chips stay clickable elements.
|
|
161
|
+
4. **Source preparation** — CRLF normalization, session-recall wire links (`@[label](dsh-session:…)`) reduced to their display label, and two-space hard breaks on continuation lines outside fenced code.
|
|
162
|
+
5. **Shell parity** — row/stack/bubble/file-card metrics are copied from the host sheets (`MessageItem.module.css`, `MessageIconActions.module.css`) while colors stay theme tokens; the action strip leads the row's DOM order with flex `order` restoring the visual order, so a sibling plugin's `querySelector('[class*="_actions"]')` always lands on the message bar — never on a code card's toolbar inside the bubble.
|
|
163
|
+
|
|
164
|
+
### Known limitations
|
|
165
|
+
|
|
166
|
+
- **Pending bubbles are not covered**: the local submission echo and the pending steering bubble are drawn directly by the host (not through the chat-node seats) and stay plain text until the message lands.
|
|
167
|
+
- **Queue previews are not covered**: the one-line queue preview and goal command rows stay plain text.
|
|
168
|
+
- **Inline-chip trade-off**: a chip interrupting a running paragraph splits that paragraph into blocks around the chip (the same trade-off community implementations take); boundary chips are seamless.
|
|
169
|
+
- **Skin boundary**: a skin that styles the host's CSS-module bubble classes directly will not hit this plugin's bubble; theme tokens and `.dshmb-*` / `.dshmb_*` custom styles work.
|
|
170
|
+
- **Multi-line semantics**: single newlines render as hard breaks; whitespace Markdown ignores (runs of spaces, tab indentation) is collapsed outside code blocks.
|
|
171
|
+
|
|
172
|
+
### Compatibility
|
|
173
|
+
|
|
174
|
+
- Verified against DSH `0.2.0-rc.1` (web profile, Chromium). Depends on the keyed `conversation.chat.node` slot, the `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo` row anchors, and the primitives exports `MarkdownText` / `projectUserText` / `writeClipboard`.
|
|
175
|
+
- Works alongside `dsh-edit-turn`, `dsh-delete-turn` and `dsh-rerun-turn`: their injection relies on `[class*="_actions"]` / `[class*="_action"]`, which this plugin preserves. A message rewritten (rolled back) by dsh-edit-turn shows that plugin's own replacement bubble, which is out of scope here.
|
|
176
|
+
- The host half is an inert entry (no runtime behavior); everything lives in the `./client` browser bundle. No official source is modified and no private event type is written.
|
|
177
|
+
|
|
178
|
+
### Fixes
|
|
179
|
+
|
|
180
|
+
- **0.1.1** — fixed whitespace-only plain runs between two reference chips being dropped as an empty Markdown block: adjacent chips no longer glue into `@a@b`, and a newline between two chips stays a line break the way the host's `pre-wrap` bubble shows it (the run renders as a `data-dshmb-gap` pre-wrap node, still never an empty Markdown block); the self-created action strip and copy button now carry the `data-dshmb-actions` / `data-dshmb-action` namespace markers; and the host evidence behind `priority: -1` (host default 0, lowest renders, same priority throws) plus the anchor structure the three sibling plugins rely on are covered by regression tests.
|
|
181
|
+
|
|
182
|
+
### License
|
|
183
|
+
|
|
184
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# dsh-markdown-bubble bundle patch: inserts the plugin row into the profile roster.
|
|
2
|
+
#
|
|
3
|
+
# The host half is inert: how a sent message is drawn is a browser-side
|
|
4
|
+
# projection, so the work lives entirely in the exported "./client" bundle.
|
|
5
|
+
#
|
|
6
|
+
# The entry name is the BARE package name on purpose: the client-module scanner
|
|
7
|
+
# treats it as a package specifier and resolves `<name>/package.json`, so a
|
|
8
|
+
# subpath entry would be cached as "not a client package" and the browser half
|
|
9
|
+
# would silently never load.
|
|
10
|
+
- insert:
|
|
11
|
+
- id: dsh-markdown-bubble
|
|
12
|
+
name: 'dsh-markdown-bubble'
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-markdown-bubble",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Markdown rendering for DeepSeek Harness sent user messages: every message bubble renders lists, code, tables, quotes and inline formatting through the host's own Markdown pipeline, while queues, previews and the composer keep their plain projection.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./src/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"markdown",
|
|
23
|
+
"user-message",
|
|
24
|
+
"bubble",
|
|
25
|
+
"chat",
|
|
26
|
+
"render"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/DDDMUC/dsh-markdown-bubble.git"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test \"test/*.test.js\""
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-locale",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"dsh": ">=0.2.0-rc.1"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
// dsh-markdown-bubble — browser half.
|
|
2
|
+
//
|
|
3
|
+
// What this does
|
|
4
|
+
// --------------
|
|
5
|
+
// The chat view draws a durable sent message through the keyed
|
|
6
|
+
// `conversation.chat.node` seats `user` and `steering` (one host component
|
|
7
|
+
// serves both), which project the raw logged text with `projectUserText` and
|
|
8
|
+
// paint it under `white-space: pre-wrap` — markdown shown as typed. This
|
|
9
|
+
// plugin shadows those two seats at a lower priority and re-renders the same
|
|
10
|
+
// bubble with the host's own markdown pipeline, `MarkdownText`.
|
|
11
|
+
//
|
|
12
|
+
// The replacement keeps what the host keeps:
|
|
13
|
+
//
|
|
14
|
+
// - Reference chips stay chips. The host projection is called (it is frozen,
|
|
15
|
+
// not replaceable, but perfectly callable) and its pieces are split into
|
|
16
|
+
// chips and plain runs; chips render unchanged, plain runs go through
|
|
17
|
+
// MarkdownText. A chip inside a running paragraph splits that paragraph
|
|
18
|
+
// into blocks around it — boundary chips are the common case.
|
|
19
|
+
// - The action strip survives. The host's own bar (clock + copy) is rebuilt
|
|
20
|
+
// from exported primitives, and its container keeps the `_actions` /
|
|
21
|
+
// `_action` class suffixes and the `data-clock` attribute that sibling
|
|
22
|
+
// plugins (dsh-edit-turn, dsh-delete-turn, dsh-rerun-turn) anchor their
|
|
23
|
+
// injected buttons on — verified against their sources.
|
|
24
|
+
// - Attachments, the reference summary and `JsonBlock` extras render as the
|
|
25
|
+
// host renders them, from the same node data and the same owner callbacks.
|
|
26
|
+
//
|
|
27
|
+
// The pending steering bubble and the local submission echo do not pass
|
|
28
|
+
// through these seats (they are not chat nodes yet), so they keep the host's
|
|
29
|
+
// plain projection. Queue previews and the goal panel keep it too.
|
|
30
|
+
//
|
|
31
|
+
// Soft line breaks are preserved: GFM would join "line one\nline two" into one
|
|
32
|
+
// paragraph, but a chat box Enter is a line break. Plain runs are prepared
|
|
33
|
+
// with two-space hard breaks outside fenced code before they reach the
|
|
34
|
+
// renderer.
|
|
35
|
+
//
|
|
36
|
+
// The bundle follows the client-modules contract: it registers a factory with
|
|
37
|
+
// `window.__ModuleLoader__.load` and returns the plugin exports.
|
|
38
|
+
window.__ModuleLoader__.load({
|
|
39
|
+
id: 'dsh-markdown-bubble',
|
|
40
|
+
factory: (require) => {
|
|
41
|
+
var module = { exports: {} }
|
|
42
|
+
var exports = module.exports
|
|
43
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
44
|
+
|
|
45
|
+
const react = require('react')
|
|
46
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
47
|
+
|
|
48
|
+
/** Plugin identity and diagnostics key. */
|
|
49
|
+
const NS = 'dsh-markdown-bubble'
|
|
50
|
+
|
|
51
|
+
/** Keep in sync with package.json and src/index.js. */
|
|
52
|
+
const PLUGIN_VERSION = '0.1.1'
|
|
53
|
+
|
|
54
|
+
/** Presence marker the live verifier reads. */
|
|
55
|
+
const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
|
|
56
|
+
|
|
57
|
+
/** Style tag id claimed by this plugin (HMR and dispose remove it by id). */
|
|
58
|
+
const CSS_TAG = NS + '/markdown-bubble.css'
|
|
59
|
+
|
|
60
|
+
/** Chat-node seats this renderer replaces; the host serves both with one view. */
|
|
61
|
+
const SEATS = ['user', 'steering']
|
|
62
|
+
|
|
63
|
+
// Seat priority: the host's default is 0, the plugin sits below it, and it
|
|
64
|
+
// must not merely be different — the same key at the same priority is not a
|
|
65
|
+
// shadow, it is an error.
|
|
66
|
+
//
|
|
67
|
+
// Evidence (verified against the installed host, DSH 0.2.0-rc.1):
|
|
68
|
+
// - the host registers these two keys with no priority field at all
|
|
69
|
+
// (@deepseek-ai/dsh-client-ui-chat/lib/client.js:6830-6839), so its entry
|
|
70
|
+
// carries the default;
|
|
71
|
+
// - @deepseek-ai/dsh-client-ui-slots/lib/index.js reads
|
|
72
|
+
// options.priority ?? 0 (:167), keeps the ledger sorted by priority
|
|
73
|
+
// ascending (:221) and answers a keyed cell with the FIRST live entry of
|
|
74
|
+
// that key (:278-293) — the lowest priority renders;
|
|
75
|
+
// - the same key at the same priority throws instead of shadowing
|
|
76
|
+
// (:177-178), so 0 would break the page, and a positive value would lose
|
|
77
|
+
// the seat to the host.
|
|
78
|
+
// -1 is therefore the smallest step that shadows the host and stays clear
|
|
79
|
+
// of it.
|
|
80
|
+
const SEAT_PRIORITY = -1
|
|
81
|
+
|
|
82
|
+
const { createElement: h, Fragment } = react
|
|
83
|
+
|
|
84
|
+
// --- source preparation ---------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** Session recalls log the wire form `@[label](dsh-session:...)`; the label alone is what a chip shows. */
|
|
87
|
+
const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A fenced-code opening line: up to three spaces and a run of three or
|
|
91
|
+
* more backticks or tildes. Returns the fence descriptor, or null.
|
|
92
|
+
* @param line - one source line.
|
|
93
|
+
* @returns the fence descriptor or null.
|
|
94
|
+
*/
|
|
95
|
+
function openingFence(line) {
|
|
96
|
+
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line)
|
|
97
|
+
return match === null ? null : { char: match[1][0], length: match[1].length }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A fenced-code closing line for one fence: the same character, a run at
|
|
102
|
+
* least as long as the opening one, and nothing but trailing whitespace
|
|
103
|
+
* after it (CommonMark forbids a closing info string).
|
|
104
|
+
* @param line - one source line.
|
|
105
|
+
* @param fence - the opening fence descriptor.
|
|
106
|
+
* @returns whether this line closes the fence.
|
|
107
|
+
*/
|
|
108
|
+
function isClosingFence(line, fence) {
|
|
109
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)
|
|
110
|
+
return match !== null && match[1][0] === fence.char && match[1].length >= fence.length
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Walk the source line by line, rewriting only lines that sit outside a
|
|
115
|
+
* fenced code block. Fenced code is left byte-for-byte alone — its
|
|
116
|
+
* whitespace is the point — and its opening and closing lines are never
|
|
117
|
+
* rewritten either.
|
|
118
|
+
* @param text - message text.
|
|
119
|
+
* @param visit - (line, nextLine | undefined) => replacement line.
|
|
120
|
+
* @returns the rewritten source.
|
|
121
|
+
*/
|
|
122
|
+
function walkOutsideFences(text, visit) {
|
|
123
|
+
if (!text.includes('\n')) return visit(text, undefined)
|
|
124
|
+
const lines = text.split('\n')
|
|
125
|
+
let fence = null
|
|
126
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
127
|
+
const line = lines[index]
|
|
128
|
+
if (fence !== null) {
|
|
129
|
+
if (isClosingFence(line, fence)) fence = null
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const opener = openingFence(line)
|
|
133
|
+
if (opener !== null) {
|
|
134
|
+
fence = opener
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
lines[index] = visit(line, lines[index + 1])
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The two-space GFM hard break a line needs when a soft break would
|
|
144
|
+
* otherwise join it to the next: applied only to content lines that
|
|
145
|
+
* continue (blank lines end the paragraph), and never to indented code
|
|
146
|
+
* (which preserves its own line structure).
|
|
147
|
+
* @param line - current line.
|
|
148
|
+
* @param next - the following line, absent on the last line.
|
|
149
|
+
* @returns the line with its hard-break suffix when it needs one.
|
|
150
|
+
*/
|
|
151
|
+
function hardBreakLine(line, next) {
|
|
152
|
+
if (line === '' || next === undefined || next.trim() === '') return line
|
|
153
|
+
if (/^ {4}/.test(line) || line.startsWith('\t')) return line
|
|
154
|
+
return line + ' '
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Preserve the single line breaks a reader typed.
|
|
159
|
+
*
|
|
160
|
+
* GFM treats one newline inside a paragraph as a soft break (a space), but
|
|
161
|
+
* this bubble renders what a person typed into a chat box, where Enter is
|
|
162
|
+
* a line break. Blanket `pre-wrap` is not an option: it would also expose
|
|
163
|
+
* the source whitespace markdown ignores (list indentation, continuation
|
|
164
|
+
* spacing). So the source itself is prepared for the renderer.
|
|
165
|
+
* @param text - newline-normalized message text.
|
|
166
|
+
* @returns the source with soft breaks promoted to hard breaks.
|
|
167
|
+
*/
|
|
168
|
+
function preserveHardBreaks(text) {
|
|
169
|
+
return walkOutsideFences(text, hardBreakLine)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Replace a session-recall wire link with its display label. The markdown
|
|
174
|
+
* renderer only allows http(s)/mailto destinations, so an untranslated
|
|
175
|
+
* `dsh-session:` link would lose its anchor anyway — this just makes the
|
|
176
|
+
* plain-text fallback identical to what the chip showed. Fenced code is
|
|
177
|
+
* left alone: a pasted wire link there is code, not a recall.
|
|
178
|
+
* @param text - raw logged message text.
|
|
179
|
+
* @returns the text with wire links unwrapped.
|
|
180
|
+
*/
|
|
181
|
+
function unwrapSessionReferences(text) {
|
|
182
|
+
return walkOutsideFences(text, (line) => line.replace(SESSION_WIRE_RE, '$1'))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The message text as markdown source: CRLF normalized, session wire
|
|
187
|
+
* links unwrapped, soft breaks promoted to hard breaks — each applied
|
|
188
|
+
* only outside fenced code.
|
|
189
|
+
* @param text - raw logged message text.
|
|
190
|
+
* @returns markdown source for the renderer.
|
|
191
|
+
*/
|
|
192
|
+
function toMarkdown(text) {
|
|
193
|
+
const normalized = text.replace(/\r\n?/gu, '\n')
|
|
194
|
+
return walkOutsideFences(normalized, (line, next) => hardBreakLine(line.replace(SESSION_WIRE_RE, '$1'), next))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- content projection ----------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Split content blocks exactly as the host's UserStyleBubble does: joined
|
|
201
|
+
* text, image/file attachments, and the remainder.
|
|
202
|
+
* @param content - the node's content blocks.
|
|
203
|
+
* @returns text, attachments and rest blocks.
|
|
204
|
+
*/
|
|
205
|
+
function contentParts(content) {
|
|
206
|
+
const texts = []
|
|
207
|
+
const attachments = []
|
|
208
|
+
const rest = []
|
|
209
|
+
for (const block of content) {
|
|
210
|
+
if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
211
|
+
texts.push(block.text)
|
|
212
|
+
} else if (block !== null && typeof block === 'object' && block.type === 'image' && block.attachment !== undefined) {
|
|
213
|
+
attachments.push({ type: 'image', image: { attachment: block.attachment } })
|
|
214
|
+
} else if (block !== null && typeof block === 'object' && block.type === 'file' && block.attachment !== undefined) {
|
|
215
|
+
attachments.push({ type: 'file', file: block.attachment })
|
|
216
|
+
} else {
|
|
217
|
+
rest.push(block)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { text: texts.join(''), attachments, rest }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Is this projected piece a reference chip (vs a plain text run)? */
|
|
224
|
+
function isChip(piece) {
|
|
225
|
+
return react.isValidElement(piece) && piece.props !== null && piece.props['data-ref-chip'] !== undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Plain-run text sits directly inside the projection's run spans. */
|
|
229
|
+
function runText(piece) {
|
|
230
|
+
return react.isValidElement(piece) && typeof piece.props.children === 'string' ? piece.props.children : ''
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The host projection, called in a guard: it is the fallback of last resort. */
|
|
234
|
+
function plainProjection(text, referenceLabels, skillNames, references) {
|
|
235
|
+
try {
|
|
236
|
+
return primitives.projectUserText(text, referenceLabels, skillNames, 'skill', references)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn('[' + NS + '] the host user-text projection failed; showing raw text', error)
|
|
239
|
+
return h('span', { className: 'dshmb-plain' }, text)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A whitespace-only plain run: the gap between two chips, or the leading /
|
|
245
|
+
* trailing padding the host's own pre-wrap bubble shows. It carries no
|
|
246
|
+
* markdown, so it is never a markdown block, but it is not nothing either —
|
|
247
|
+
* see the flush() comment in composeUserMarkdown.
|
|
248
|
+
* @param text - the run's whitespace.
|
|
249
|
+
* @param key - react key.
|
|
250
|
+
* @returns the gap span.
|
|
251
|
+
*/
|
|
252
|
+
function gapSpan(text, key) {
|
|
253
|
+
return h('span', { className: 'dshmb-plain', 'data-dshmb-gap': '1', key }, text)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** One markdown block for one contiguous plain run. */
|
|
257
|
+
function markdownBlock(source, labels, key) {
|
|
258
|
+
return h(
|
|
259
|
+
'div',
|
|
260
|
+
{ className: 'dshmb-md', 'data-dshmb-md': '1', key },
|
|
261
|
+
h(primitives.MarkdownText, { text: source, labels }),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Compose the bubble body: reference chips stay chips, each contiguous
|
|
267
|
+
* plain run becomes a markdown block — except a whitespace-only run, which
|
|
268
|
+
* stays a pre-wrap text node so two chips never glue together. A chip inside
|
|
269
|
+
* a running paragraph splits that paragraph around the chip; boundary chips
|
|
270
|
+
* read seamlessly.
|
|
271
|
+
* @param text - the message text.
|
|
272
|
+
* @param labels - markdown chrome labels.
|
|
273
|
+
* @param referenceLabels - session recall labels the host associates with the message.
|
|
274
|
+
* @param skillNames - skill names the host loaded for the message.
|
|
275
|
+
* @param references - file/skill open callbacks for clickable chips.
|
|
276
|
+
* @returns the composed body, or null for empty text.
|
|
277
|
+
*/
|
|
278
|
+
function composeUserMarkdown(text, labels, referenceLabels, skillNames, references) {
|
|
279
|
+
if (typeof text !== 'string' || text === '') return null
|
|
280
|
+
const projected = plainProjection(text, referenceLabels, skillNames, references)
|
|
281
|
+
const pieces =
|
|
282
|
+
react.isValidElement(projected) && projected.type === Fragment && Array.isArray(projected.props.children)
|
|
283
|
+
? projected.props.children
|
|
284
|
+
: [projected]
|
|
285
|
+
if (!pieces.some(isChip)) return markdownBlock(toMarkdown(text), labels, 'md')
|
|
286
|
+
|
|
287
|
+
const out = []
|
|
288
|
+
let run = []
|
|
289
|
+
const flush = () => {
|
|
290
|
+
if (run.length === 0) return
|
|
291
|
+
const raw = run.join('')
|
|
292
|
+
const source = toMarkdown(raw)
|
|
293
|
+
if (source.trim() === '') {
|
|
294
|
+
// Whitespace is not markdown, so it never becomes a block — but it is
|
|
295
|
+
// not nothing either. Between two chips it is the only thing keeping
|
|
296
|
+
// them apart: dropping it renders "@a@b" where the host shows
|
|
297
|
+
// "@a @b", and a newline between two chips (a real line break in the
|
|
298
|
+
// host's pre-wrap bubble) would collapse onto one line. The host
|
|
299
|
+
// projection keeps that run as a plain span with the same whitespace;
|
|
300
|
+
// this renderer keeps it as the pre-wrap stand-in instead of deleting
|
|
301
|
+
// it.
|
|
302
|
+
const gap = raw.replace(/\r\n?/gu, '\n')
|
|
303
|
+
if (gap !== '') out.push(gapSpan(gap, 'gap' + out.length))
|
|
304
|
+
} else {
|
|
305
|
+
out.push(markdownBlock(source, labels, 'md' + out.length))
|
|
306
|
+
}
|
|
307
|
+
run = []
|
|
308
|
+
}
|
|
309
|
+
for (const piece of pieces) {
|
|
310
|
+
if (isChip(piece)) {
|
|
311
|
+
flush()
|
|
312
|
+
out.push(h(Fragment, { key: 'chip' + out.length }, piece))
|
|
313
|
+
} else {
|
|
314
|
+
run.push(runText(piece))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
flush()
|
|
318
|
+
if (out.length === 0) return null
|
|
319
|
+
return out.length === 1 ? out[0] : h(Fragment, null, out)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- markdown chrome --------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Markdown chrome labels from the chat translator, matching the host's own
|
|
326
|
+
* `markdownLabels` wiring so fence cards carry identical copy.
|
|
327
|
+
* @param t - the chat locale seat.
|
|
328
|
+
* @returns labels for code fences and footnotes.
|
|
329
|
+
*/
|
|
330
|
+
function buildMarkdownLabels(t) {
|
|
331
|
+
return {
|
|
332
|
+
code: {
|
|
333
|
+
copyLabel: t('copy'),
|
|
334
|
+
copiedLabel: t('copied'),
|
|
335
|
+
toolbarLabels: {
|
|
336
|
+
codeLabel: t('codeBlock.title'),
|
|
337
|
+
wrapLabel: t('codeBlock.wrap'),
|
|
338
|
+
unwrapLabel: t('codeBlock.unwrap'),
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
footnotes: t('markdown.footnotes'),
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- clock ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** @param value - non-negative integer. @returns two-digit string. */
|
|
348
|
+
function pad2(value) {
|
|
349
|
+
return String(value).padStart(2, '0')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Midnight epoch of the current local day.
|
|
354
|
+
* @param now - epoch milliseconds.
|
|
355
|
+
* @returns the day's local start.
|
|
356
|
+
*/
|
|
357
|
+
function startOfLocalDay(now) {
|
|
358
|
+
const date = new Date(now)
|
|
359
|
+
date.setHours(0, 0, 0, 0)
|
|
360
|
+
return date.getTime()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Milliseconds until the next local midnight.
|
|
365
|
+
* @param now - epoch milliseconds.
|
|
366
|
+
* @returns the remaining interval.
|
|
367
|
+
*/
|
|
368
|
+
function msUntilNextLocalMidnight(now) {
|
|
369
|
+
const next = new Date(startOfLocalDay(now))
|
|
370
|
+
next.setDate(next.getDate() + 1)
|
|
371
|
+
return Math.max(1, next.getTime() - now)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Local calendar-day epoch that advances at each local midnight, so a
|
|
376
|
+
* message clock flips from `HH:mm` to a date without a re-render trigger.
|
|
377
|
+
* @returns midnight ms for the current local day.
|
|
378
|
+
*/
|
|
379
|
+
function useLocalDay() {
|
|
380
|
+
const [day, setDay] = react.useState(() => startOfLocalDay(Date.now()))
|
|
381
|
+
react.useEffect(() => {
|
|
382
|
+
const timer = setTimeout(() => setDay(startOfLocalDay(Date.now())), msUntilNextLocalMidnight(Date.now()))
|
|
383
|
+
return () => clearTimeout(timer)
|
|
384
|
+
}, [day])
|
|
385
|
+
return day
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The host's message clock: `HH:mm` for today, the `clock.md` date plus
|
|
390
|
+
* the clock earlier this year, the `clock.ymd` form for other years.
|
|
391
|
+
* @param time - message time in epoch milliseconds.
|
|
392
|
+
* @param t - the chat locale seat.
|
|
393
|
+
* @param now - current epoch milliseconds (already local-day bucketed by the caller).
|
|
394
|
+
* @returns the display string.
|
|
395
|
+
*/
|
|
396
|
+
function formatMessageClock(time, t, now) {
|
|
397
|
+
const date = new Date(time)
|
|
398
|
+
const reference = new Date(now)
|
|
399
|
+
const clock = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
|
400
|
+
if (date.getFullYear() === reference.getFullYear() && date.getMonth() === reference.getMonth() && date.getDate() === reference.getDate()) return clock
|
|
401
|
+
const params = { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() }
|
|
402
|
+
return `${date.getFullYear() === reference.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)} ${clock}`
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// --- pieces -----------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Contain a renderer crash to one message: the bubble falls back to the
|
|
409
|
+
* host's plain projection instead of crashing the seat, which the slot
|
|
410
|
+
* machinery would answer by retiring the whole renderer.
|
|
411
|
+
*/
|
|
412
|
+
class MarkdownBoundary extends react.Component {
|
|
413
|
+
constructor(props) {
|
|
414
|
+
super(props)
|
|
415
|
+
this.state = { failed: false }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
static getDerivedStateFromError() {
|
|
419
|
+
return { failed: true }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
componentDidCatch(error) {
|
|
423
|
+
console.warn('[' + NS + '] markdown rendering failed; falling back to the plain projection', error)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
render() {
|
|
427
|
+
return this.state.failed ? this.props.fallback : this.props.children
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The host's action strip for a sent message: clock + copy. The container
|
|
433
|
+
* keeps the `_actions` class suffix and the `data-clock` attribute that
|
|
434
|
+
* dsh-edit-turn / dsh-delete-turn / dsh-rerun-turn search for when they
|
|
435
|
+
* inject their own buttons, and the copy button keeps the `_action`
|
|
436
|
+
* suffix their ordering heuristic reads.
|
|
437
|
+
* @param props - message text, time, and the chat translator.
|
|
438
|
+
* @returns the action strip.
|
|
439
|
+
*/
|
|
440
|
+
// Anchor fidelity, live-verified on the running instance: this strip is the
|
|
441
|
+
// row's first DOM child, its direct children are [clock span, copy button]
|
|
442
|
+
// exactly like the host's own MessageIconActions
|
|
443
|
+
// (@deepseek-ai/dsh-client-ui-chat/lib/client.js:1157-1172), the copy button
|
|
444
|
+
// keeps the _action suffix on a direct child (the host's Tooltip adds no
|
|
445
|
+
// wrapper box) and data-clock stays "start". dsh-edit-turn, dsh-delete-turn
|
|
446
|
+
// and dsh-rerun-turn therefore find the bar with
|
|
447
|
+
// row.querySelector('[class*="_actions"]') and their insertion point with
|
|
448
|
+
// bar.querySelectorAll('button') without knowing a single class hash of the
|
|
449
|
+
// host's.
|
|
450
|
+
function MessageActions({ text, time, t }) {
|
|
451
|
+
const day = useLocalDay()
|
|
452
|
+
const [copied, setCopied] = react.useState(false)
|
|
453
|
+
const pending = react.useRef(false)
|
|
454
|
+
const timer = react.useRef(null)
|
|
455
|
+
|
|
456
|
+
react.useEffect(
|
|
457
|
+
() => () => {
|
|
458
|
+
pending.current = false
|
|
459
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
460
|
+
},
|
|
461
|
+
[],
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const onCopy = react.useCallback(() => {
|
|
465
|
+
if (copied || pending.current) return
|
|
466
|
+
pending.current = true
|
|
467
|
+
primitives.writeClipboard(text).then((ok) => {
|
|
468
|
+
pending.current = false
|
|
469
|
+
if (!ok) return
|
|
470
|
+
setCopied(true)
|
|
471
|
+
timer.current = window.setTimeout(() => {
|
|
472
|
+
timer.current = null
|
|
473
|
+
setCopied(false)
|
|
474
|
+
}, 1000)
|
|
475
|
+
})
|
|
476
|
+
}, [copied, text])
|
|
477
|
+
|
|
478
|
+
const clock =
|
|
479
|
+
time === undefined ? null : h('span', { className: 'dshmb-timeStart' }, formatMessageClock(time, t, day))
|
|
480
|
+
const label = copied ? t('copied') : t('copy')
|
|
481
|
+
|
|
482
|
+
return h(
|
|
483
|
+
'div',
|
|
484
|
+
{ className: 'dshmb_actions', 'data-clock': 'start', 'data-dshmb-actions': '1' },
|
|
485
|
+
clock,
|
|
486
|
+
h(
|
|
487
|
+
primitives.Tooltip,
|
|
488
|
+
{ label, side: 'bottom' },
|
|
489
|
+
h(
|
|
490
|
+
'button',
|
|
491
|
+
{ type: 'button', className: 'dshmb_action', 'aria-label': label, 'data-dshmb-action': '1', onClick: onCopy },
|
|
492
|
+
copied ? h(primitives.IconCheckOutlineRegular, {}) : h(primitives.IconCopyOutlineRegular, {}),
|
|
493
|
+
),
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* One attachment row entry: an image goes through the host's durable
|
|
500
|
+
* image renderer, a file renders as the host's file card.
|
|
501
|
+
* @param props - the attachment entry and the owner's image renderer.
|
|
502
|
+
* @returns the row entry.
|
|
503
|
+
*/
|
|
504
|
+
function AttachmentEntry({ attachment, renderMessageImages, compactImages }) {
|
|
505
|
+
if (attachment.type === 'image') {
|
|
506
|
+
return h(Fragment, null, renderMessageImages({ images: [attachment.image], align: 'end', compact: compactImages }))
|
|
507
|
+
}
|
|
508
|
+
const file = attachment.file ?? {}
|
|
509
|
+
const meta = [primitives.fileExtension(file.name).toUpperCase().slice(0, 8), primitives.fileSizeText(file.bytes)]
|
|
510
|
+
.filter(Boolean)
|
|
511
|
+
.join(' ')
|
|
512
|
+
return h(
|
|
513
|
+
'span',
|
|
514
|
+
{ className: 'dshmb-fileCard', title: file.name },
|
|
515
|
+
h(primitives.FileTypeIcon, { path: file.name, className: 'dshmb-fileIcon' }),
|
|
516
|
+
h(
|
|
517
|
+
'span',
|
|
518
|
+
{ className: 'dshmb-fileContent' },
|
|
519
|
+
h('span', { className: 'dshmb-fileName' }, file.name),
|
|
520
|
+
h('span', { className: 'dshmb-fileMeta' }, meta),
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The markdown-rendered user message bubble — the keyed `user`/`steering`
|
|
527
|
+
* seat replacement. The host serves both keys with one identical view and
|
|
528
|
+
* so does this component.
|
|
529
|
+
* @param props - keyed chat renderer seat for the `user`/`steering` kinds.
|
|
530
|
+
* @returns the right-aligned bubble with attachments, markdown, chips and actions.
|
|
531
|
+
*/
|
|
532
|
+
const MarkdownBubbleSeat = react.memo(function MarkdownBubbleSeat({
|
|
533
|
+
node,
|
|
534
|
+
renderMessageImages,
|
|
535
|
+
openFile,
|
|
536
|
+
openSkill,
|
|
537
|
+
t,
|
|
538
|
+
}) {
|
|
539
|
+
const data = node.data
|
|
540
|
+
const { text, attachments, rest } = contentParts(data.content)
|
|
541
|
+
const referenceLabels = data.referenceLabels ?? []
|
|
542
|
+
const skillNames = data.skillNames ?? []
|
|
543
|
+
const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
|
|
544
|
+
const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
|
|
545
|
+
const compactImages = attachments.length > 1
|
|
546
|
+
const showBubble = text !== '' || rest.length > 0
|
|
547
|
+
|
|
548
|
+
// The action strip leads the row in DOM order and is pushed below the
|
|
549
|
+
// bubble by flex `order`. Sibling plugins pick their bar with
|
|
550
|
+
// `row.querySelector('[class*="_actions"]')` — first match in document
|
|
551
|
+
// order — and a markdown bubble can contain a code card whose own
|
|
552
|
+
// toolbar carries an `_actions` class (`_actions_1pq26_43`). With the
|
|
553
|
+
// strip first, that lookup always lands on the message bar, the way it
|
|
554
|
+
// did before markdown put code cards inside the row.
|
|
555
|
+
return h(
|
|
556
|
+
'div',
|
|
557
|
+
{ className: 'dshmb-row', 'data-dshmb-row': '1' },
|
|
558
|
+
h(MessageActions, { text, time: data.time, t }),
|
|
559
|
+
h(
|
|
560
|
+
'div',
|
|
561
|
+
{ className: 'dshmb-stack' },
|
|
562
|
+
attachments.length > 0 &&
|
|
563
|
+
h(
|
|
564
|
+
'div',
|
|
565
|
+
{ className: 'dshmb-attachments', 'data-message-attachments': true },
|
|
566
|
+
attachments.map((attachment, index) =>
|
|
567
|
+
h(AttachmentEntry, {
|
|
568
|
+
key: `${attachment.type}:${index}`,
|
|
569
|
+
attachment,
|
|
570
|
+
renderMessageImages,
|
|
571
|
+
compactImages,
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
),
|
|
575
|
+
showBubble &&
|
|
576
|
+
h(
|
|
577
|
+
'div',
|
|
578
|
+
{ className: 'dshmb-bubble' },
|
|
579
|
+
h(
|
|
580
|
+
MarkdownBoundary,
|
|
581
|
+
{ key: text, fallback: plainProjection(text, referenceLabels, skillNames, references) },
|
|
582
|
+
composeUserMarkdown(text, labels, referenceLabels, skillNames, references),
|
|
583
|
+
),
|
|
584
|
+
rest.map((block, index) =>
|
|
585
|
+
h(primitives.JsonBlock, {
|
|
586
|
+
key: 'rest' + index,
|
|
587
|
+
label: t('message.extraBlock'),
|
|
588
|
+
payload: block,
|
|
589
|
+
truncatedLabel: (total) => t('json.truncated', { total }),
|
|
590
|
+
}),
|
|
591
|
+
),
|
|
592
|
+
),
|
|
593
|
+
referenceLabels.length > 0 &&
|
|
594
|
+
h(
|
|
595
|
+
'div',
|
|
596
|
+
{ className: 'dshmb-referenceSummary' },
|
|
597
|
+
t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) }),
|
|
598
|
+
),
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// --- style ------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
// Metrics are copied from the host's own sheets (MessageItem.module.css,
|
|
606
|
+
// MessageIconActions.module.css) so the bubble is the same bubble, but the
|
|
607
|
+
// classes are this plugin's own — no CSS-module hash to chase between
|
|
608
|
+
// builds. Colors stay theme tokens; the two class suffixes sibling plugins
|
|
609
|
+
// search for (`_actions`, `_action`) are preserved on the strip and its
|
|
610
|
+
// buttons on purpose.
|
|
611
|
+
const CSS = [
|
|
612
|
+
'.dshmb-row{flex-direction:column;align-items:flex-end;gap:6px;display:flex}',
|
|
613
|
+
// The strip leads the row's DOM order so sibling plugins' `_actions`
|
|
614
|
+
// lookup finds it before any code-card toolbar inside the bubble;
|
|
615
|
+
// `order` restores the visual order (bubble above, actions below).
|
|
616
|
+
'.dshmb-stack{order:1;min-width:0;max-width:min(calc(var(--dsh-chat-content-width,748px) * .702), 82%);flex-direction:column;align-items:flex-end;gap:8px;display:flex}',
|
|
617
|
+
'.dshmb-bubble{background:var(--dsw-specific-bubble);border-radius:var(--dsw-radius-xl);max-width:100%;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-primary);word-break:break-word;padding:10px 16px}',
|
|
618
|
+
'.dshmb-referenceSummary{color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(18px + var(--dsh-content-font-delta-secondary,0px))}',
|
|
619
|
+
'.dshmb-attachments{flex-wrap:wrap;justify-content:flex-end;gap:8px;max-width:100%;display:flex}',
|
|
620
|
+
'.dshmb-fileCard{border:.5px solid var(--dsw-alias-border-l2,#0000001f);border-radius:var(--dsw-radius-xl);background:var(--dsw-specific-input-major,transparent);box-sizing:border-box;flex:0 0 240px;align-items:center;gap:10px;width:240px;min-height:64px;padding:8px 12px;display:inline-flex}',
|
|
621
|
+
'.dshmb-fileIcon{flex:none;width:28px;height:28px}',
|
|
622
|
+
'.dshmb-fileContent{flex-direction:column;flex:1;min-width:0;display:flex}',
|
|
623
|
+
'.dshmb-fileName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px;overflow:hidden}',
|
|
624
|
+
'.dshmb-fileMeta{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-tertiary,#00000073);font-size:12px;line-height:15px;overflow:hidden}',
|
|
625
|
+
// The bubble wraps with `pre-wrap`; markdown brings its own line
|
|
626
|
+
// structure, so the markdown container returns to the normal policy.
|
|
627
|
+
'.dshmb-md{white-space:normal;word-break:normal;overflow-wrap:anywhere;min-width:0;max-width:100%}',
|
|
628
|
+
// MarkdownText's document spacing is tuned for a full transcript column;
|
|
629
|
+
// inside a bubble those margins read as gaps. The host sheet's
|
|
630
|
+
// first/last-child collapse is kept.
|
|
631
|
+
'.dshmb-md > div > :first-child{margin-top:0!important}',
|
|
632
|
+
'.dshmb-md > div > :last-child{margin-bottom:0!important}',
|
|
633
|
+
'.dshmb-md > div p{margin:8px 0}',
|
|
634
|
+
'.dshmb-md > div :where(h1,h2,h3){margin:16px 0 8px}',
|
|
635
|
+
'.dshmb-md > div :where(h4,h5,h6){margin:12px 0 6px}',
|
|
636
|
+
'.dshmb-md > div :where(ul,ol){margin:8px 0}',
|
|
637
|
+
'.dshmb-md > div blockquote{margin:8px 0 0}',
|
|
638
|
+
'.dshmb-md > div hr{margin:16px 0}',
|
|
639
|
+
'.dshmb-md > div pre{margin:12px 0}',
|
|
640
|
+
'.dshmb-plain{white-space:pre-wrap;word-break:break-word}',
|
|
641
|
+
// Action strip metrics from the host's MessageIconActions.module.css.
|
|
642
|
+
'.dshmb_actions{order:2;height:calc(28px + var(--dsh-content-font-delta,0px));align-items:center;gap:8px;display:flex}',
|
|
643
|
+
'.dshmb-timeStart{font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px}',
|
|
644
|
+
'.dshmb_action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:6px;display:inline-flex}',
|
|
645
|
+
'.dshmb_action svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}',
|
|
646
|
+
'.dshmb_action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
647
|
+
'.dshmb_action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsw-alias-state-business-primary));outline-offset:2px}',
|
|
648
|
+
// Host parity: every sent message but the latest hides its strip until
|
|
649
|
+
// hover or focus, and the `data-actions-reveal` shell preference does
|
|
650
|
+
// the same globally.
|
|
651
|
+
'@media (hover:hover){',
|
|
652
|
+
'[data-actions-reveal=hover] .dshmb_actions,',
|
|
653
|
+
':is([data-chat-flow-kind=user],[data-chat-flow-kind=steering]):has(~:is([data-chat-flow-kind=user],[data-chat-flow-kind=steering])) .dshmb_actions{opacity:0;transition:opacity 80ms}',
|
|
654
|
+
'[data-actions-reveal=hover]:hover .dshmb_actions,',
|
|
655
|
+
'[data-actions-reveal=hover]:focus-within .dshmb_actions,',
|
|
656
|
+
':is([data-chat-flow-kind=user],[data-chat-flow-kind=steering]):has(~:is([data-chat-flow-kind=user],[data-chat-flow-kind=steering])):hover .dshmb_actions,',
|
|
657
|
+
':is([data-chat-flow-kind=user],[data-chat-flow-kind=steering]):has(~:is([data-chat-flow-kind=user],[data-chat-flow-kind=steering])):focus-within .dshmb_actions{opacity:1}',
|
|
658
|
+
'}',
|
|
659
|
+
].join('')
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Inject the plugin's stylesheet once. Runs at apply time; the loader's
|
|
663
|
+
* HMR sweep removes it by `data-plugin`, dispose by tag id.
|
|
664
|
+
*/
|
|
665
|
+
function injectStyles() {
|
|
666
|
+
if (typeof document === 'undefined') return
|
|
667
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG) + ']') !== null) return
|
|
668
|
+
const tag = document.createElement('style')
|
|
669
|
+
tag.dataset.plugin = NS
|
|
670
|
+
tag.dataset.pluginCss = CSS_TAG
|
|
671
|
+
tag.textContent = CSS
|
|
672
|
+
document.head.appendChild(tag)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Remove the stylesheet this plugin injected, if still present. */
|
|
676
|
+
function removeStyles() {
|
|
677
|
+
if (typeof document === 'undefined') return
|
|
678
|
+
const tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_TAG) + ']')
|
|
679
|
+
if (tag !== null) tag.remove()
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// --- plugin -----------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Browser plugin body: inject the stylesheet and shadow the two sent-text
|
|
686
|
+
* chat seats for as long as this fiber lives. Disposal (or an uninstall)
|
|
687
|
+
* restores the host's own renderers.
|
|
688
|
+
* @param ctx - client cordis context (slots and locale by injection).
|
|
689
|
+
*/
|
|
690
|
+
function apply(ctx) {
|
|
691
|
+
injectStyles()
|
|
692
|
+
for (const key of SEATS) {
|
|
693
|
+
ctx.slots.inject('conversation.chat.node', () =>
|
|
694
|
+
ctx.slots.register(
|
|
695
|
+
{
|
|
696
|
+
name: 'conversation.chat.node',
|
|
697
|
+
key,
|
|
698
|
+
priority: SEAT_PRIORITY,
|
|
699
|
+
locale: 'chat',
|
|
700
|
+
},
|
|
701
|
+
MarkdownBubbleSeat,
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
globalThis[DEBUG_KEY] = { version: PLUGIN_VERSION, seats: SEATS.slice() }
|
|
707
|
+
|
|
708
|
+
ctx.effect(
|
|
709
|
+
() => () => {
|
|
710
|
+
delete globalThis[DEBUG_KEY]
|
|
711
|
+
removeStyles()
|
|
712
|
+
},
|
|
713
|
+
NS + ': lifecycle',
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
exports.apply = apply
|
|
718
|
+
exports.inject = ['slots', 'locale']
|
|
719
|
+
exports.PLUGIN_VERSION = PLUGIN_VERSION
|
|
720
|
+
exports.SEATS = SEATS
|
|
721
|
+
exports.SEAT_PRIORITY = SEAT_PRIORITY
|
|
722
|
+
exports.toMarkdown = toMarkdown
|
|
723
|
+
exports.preserveHardBreaks = preserveHardBreaks
|
|
724
|
+
exports.unwrapSessionReferences = unwrapSessionReferences
|
|
725
|
+
exports.contentParts = contentParts
|
|
726
|
+
exports.composeUserMarkdown = composeUserMarkdown
|
|
727
|
+
exports.MarkdownBubbleSeat = MarkdownBubbleSeat
|
|
728
|
+
return module.exports
|
|
729
|
+
},
|
|
730
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-markdown-bubble — host half.
|
|
3
|
+
*
|
|
4
|
+
* The plugin has no host-side behavior: a sent message's bubble is drawn by
|
|
5
|
+
* the browser's chat view, and how it renders is a client-side projection of
|
|
6
|
+
* the same text the drawer already receives. The host row exists so the
|
|
7
|
+
* package is mounted by the Loader — the client-module scanner scans Loader
|
|
8
|
+
* entries for packages declaring `dsh.client` — and so the bundle patch has a
|
|
9
|
+
* row id to insert.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-markdown-bubble
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Stable plugin id (matches the bundle patch's insert id and the client module id). */
|
|
15
|
+
export const PLUGIN_ID = 'dsh-markdown-bubble'
|
|
16
|
+
|
|
17
|
+
/** Keep in sync with package.json and src/client.js. */
|
|
18
|
+
export const PLUGIN_VERSION = '0.1.1'
|
|
19
|
+
|
|
20
|
+
/** Cordis plugin name; the bundle patch resolves the package by this row. */
|
|
21
|
+
export const name = PLUGIN_ID
|
|
22
|
+
|
|
23
|
+
/** Services the inert entry needs: none. */
|
|
24
|
+
export const inject = []
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Inert apply: the browser half owns every behavior.
|
|
28
|
+
* @param {import('@deepseek-ai/cordis').Context} _ctx - owning context.
|
|
29
|
+
*/
|
|
30
|
+
export function apply(_ctx) {}
|