dsh-client-ui-quote 0.10.0 → 0.11.0
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 +42 -15
- package/lib/client.js +367 -34
- package/package.json +2 -2
- package/test/capsule.mjs +247 -0
- package/test/dom.mjs +6 -0
- package/test/panel.mjs +22 -0
package/README.md
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
| **引用清单** | 右侧栏的一个**页面**:本会话发出去的引用与评论的清单。搜索框按引用/评论过滤、点一条跳回原文、`导出 Markdown` 落一个文件、历史没读完时可 `加载更早的消息`。它作为**页面类型**注册进右侧栏(`ctx.sidebarRightTabs.register`),并在指南页留下一个胶囊作入口;`Alt+L` 同样能打开。数据来自会话自己的事件日志(`ctx.sessions.retain` → `session.eventSource`),不依赖屏幕上有没有那条消息。 |
|
|
17
17
|
| **侧边线程回流** | 同一个页面底部是这条会话的**侧边线程**:显示它最后问的那个问题与消息条数,并给两个动作——**`带回主会话`** 把线程最后「问题 + 答案」写成主会话输入框里的草稿(**不发送**,你自己按发送),**`导出 Markdown`** 把整条线程落成一个文件。线程内容同样从它自己的事件日志读;没有线程时这一节只说明「还没有侧边线程」。 |
|
|
18
18
|
| **改这条引用** | 悬停卡片里的 `改这条引用`:把那条引用当初的**引用与评论都原样放回评论框**(引用正文、评论各占一个输入框,都能改),你改完按 `⏎` 就发出一条**新的**引用(DSH 没有编辑已发送消息的 API,这是唯一诚实的做法)。旧胶囊随即挂上 **`已修订`** 标记,卡片里直接显示改后的评论;**连正文一起改过的**,卡片里也把改后的原文亮出来。标记按引用原文记在本机,跨刷新仍在。 |
|
|
19
|
+
| **逐条回应** | 评论框里的一格**勾选项**(默认关):勾上,这条引用的块尾就多一行**只有模型读得到的指令**——请按出现顺序把引用记为「引用1」「引用2」…并在回复中逐条对应。模型的回复里每个 `「引用N」` 会变成一枚**小徽章**:悬停就是那条引用的完整卡片(含 `跳回原文` 与 `改这条引用`),点了就跳;编号超出这条消息引用数的标签保持纯文本。指令行与围栏一样只是**线上形态**,胶囊、卡片、导出里都不出现。 |
|
|
19
20
|
- 引用以 DSH 原生行内原子节点 `reference-chip` 插入(带图标的胶囊);走「评论」时评论一起放进这颗芯片(芯片标签显示为 `引用摘要 · 评论摘要`),引用与评论始终是一体,草稿里不会散成两段。
|
|
20
|
-
- **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~`
|
|
21
|
+
- **从文件预览或终端划选的引用是代码**:线格式不再是纯块引用,而是 `> ❝ ``` ` / `> 代码行` / `> ``` ` —— 模型看到的是真正的围栏代码块;胶囊与悬停卡片随之换等宽字体、按代码的空白排版,引用清单导出时也落成 ``` 围栏而非 `>`。围栏只是线格式(传输时剥掉),胶囊上显示的仍是原始代码文本;正文里本身就有 ``` 时改用 `~~~` 围栏。**文件预览的引用还带着它出自哪个文件**:宿主在预览 DOM 上标了路径时,路径就写进围栏的信息位(`> ❝ ``` src/app.ts`)——模型由此可以直接打开这个文件动手,清单导出的围栏行同样带着它;探测不到路径时就是原来的样子,什么也不损失。
|
|
21
22
|
- **引用正文可以编辑**:评论框上半格是引用自己的输入框(评论在下半格),划选带进了提示符噪音或多选了几行,当场裁掉再发;「改这条引用」时旧正文回填同一格,改完随新引用发出。
|
|
22
23
|
- 发送时由 DSH 回调本插件取「模型形态」:**`> ❝ …` 引用行 + 同一个块里的 `> ❞ …` 评论行**。消息里那段块会被就地换成 Kimi 那样的一颗胶囊
|
|
23
24
|
(`❝ 引用摘要 │ 评论摘要`,鼠标移上去就能读到引用与评论的全文、点开可展开、悬停出现 `复制`),你自己写的话保持原样,模型收到的仍是完整引用文本。
|
|
@@ -52,18 +53,23 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
|
|
|
52
53
|
name: dsh-client-ui-quote
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
### 方式三:npm
|
|
56
|
+
### 方式三:npm 包(已上架)
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
已发布到 npm:`dsh-client-ui-quote@0.10.0`。发布由 GitHub Actions 完成(`.github/workflows/publish.yml`):推 `v*` 标签或手动 Run `publish` workflow,`prepublishOnly` 的 285 项断言会先在 runner 上跑完,任何一项失败都不会发出去。`npm pack --dry-run` 可核对内容(0.10.0 = 14 个文件 / 97.6 kB)。
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
装:
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
```bash
|
|
63
|
+
dsh plugin --profile desktop add dsh-client-ui-quote
|
|
64
|
+
```
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
npmjs 上架后 npmmirror 一般 10 分钟内同步,DSH 配置了镜像源即可国内直装。
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
**发布运维备忘**(给未来的自己):
|
|
69
|
+
|
|
70
|
+
- 发布用的是 90 天期 granular token(勾选 **Bypass 2FA**——npm 2026-08 起强制 CI 直发必须带这个标记),**2027-01-09 到期**。到期前重签:npmjs.com → Access Tokens → Generate New Token → Granular → 勾 Bypass 2FA、Read and write (publish and stage)、All packages、90 days → 生成后 `gh secret set NPM_TOKEN --repo BangBang-03/dsh-client-ui-quote`(gh 需已登录)。
|
|
71
|
+
- npm 对**新账号的首包**有安全审核 hold:发布成功后包页会短暂显示 `0.0.0-stage` 占位,属于正常现象,审核自动放行(本包 0.10.0 实测约 1 小时内过审),无需人工操作。
|
|
72
|
+
- npm 官方的长期方向是 Trusted Publishing(OIDC 直发):2027-01 废弃 bypass-2FA 直发之前,建议在包 Settings → Trusted Publisher 登记 `BangBang-03/dsh-client-ui-quote` + `publish.yml`,并在 workflow 里加 `id-token: write` 权限与 `--provenance` 参数。
|
|
67
73
|
|
|
68
74
|
### 卸载
|
|
69
75
|
|
|
@@ -73,7 +79,7 @@ DSH 会把包记进 profile 的依赖并加入组合包列表,装完在侧边
|
|
|
73
79
|
|
|
74
80
|
1. 在会话正文、**文件预览**或**终端**里**拖动选中**一段文字(后两者没有自己的会话 id,引用会落进当前屏幕上那条会话,并且以**围栏代码块**的线格式发给模型;输入框内的选区属于改稿,不触发)。
|
|
75
81
|
2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择 **评论**、**添加到对话** 或 **侧边对话**。同一组动作也有键位:**`Alt+Q`** 直接引用、**`Alt+C`** 直接开评论框、**`Alt+B`** 直接开侧边对话(都在设置里可改;没有选区时按键原样让给其它快捷键)。
|
|
76
|
-
3.
|
|
82
|
+
3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。
|
|
77
83
|
4. **侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),引用以纯块引用形态落在它的草稿里,**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面。同一会话上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。
|
|
78
84
|
5. 浮层收起条件:单击别处、选区清空、按 `Esc`、滚动会话、窗口尺寸变化,以及动作完成之后。双击选词不会把浮层关掉。
|
|
79
85
|
6. 发送出去以后,消息里那段块会显示成一颗**胶囊**:收起时是单行 `❝ 引用摘要 │ 评论摘要`,**鼠标移上去**会浮出一张卡片显示完整的引用与评论
|
|
@@ -129,6 +135,10 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
|
|
|
129
135
|
模型读到块引用里一个真正的围栏代码块;`splitQuoteRuns` 解析时把首行的围栏(和收尾那一行)剥掉,胶囊拿到的仍是原始代码文本,
|
|
130
136
|
渲染加 `data-code="true"`(等宽、`pre`、展开时横向滚动),卡片加 `dshq-pop-code`,侧边对话落进子线程草稿的则是不带标记的裸围栏;
|
|
131
137
|
正文本身含 ``` 时序列化自动换 `~~~` 围栏。没有围栏的块照旧按普通引用解析,新旧消息互不影响;
|
|
138
|
+
**预览引用的文件路径**走的是围栏自带的信息位(`> ❝ ``` src/app.ts`)——Markdown 里这本来就是「渲染器不显示、模型读得到」的位置,
|
|
139
|
+
`sanitizePath` 把带空白/围栏字符的伪路径挡掉,`previewPathOf` 在预览 DOM 与其几层祖先上探测一组候选属性,探测不到就原样发;
|
|
140
|
+
- **逐条回应的指令行**同样只是线上形态:勾了评论框里的开关,块尾多一行 `> ※ 指令:…`,`splitQuoteRuns` 按 `※ 指令` 前缀把它从块里剥掉,
|
|
141
|
+
所以胶囊、卡片、清单导出全都看不到它(用户自己的评论/引用恰好以 `※ 指令` 开头的概率,比它带来的清晰收益小得多,已记入已知限制);
|
|
132
142
|
- 客户端插件用 `MutationObserver` 找「只含一个文本子节点、内部有 `❝`、位于 `[data-conversation-content]` 里」的那个 run,
|
|
133
143
|
把整条消息拆成 `前文 / 引用 / 中间文字 / 引用 / 后文` 这样一串分段(`splitQuoteRuns`),给原 run 加 `display:none`,
|
|
134
144
|
在它**前面**按顺序插入自己的元素:每段引用一颗胶囊,每段用户自己的话一个 `span.dshq-body`(保持 `pre-wrap`)——
|
|
@@ -147,6 +157,15 @@ DSH 的输入框是 Lexical,没有 Kimi 的 `quote` 节点,但有一个通
|
|
|
147
157
|
卡片跟着锚点活:锚点一旦离开文档(发消息会把芯片拿走,而被移除的节点不会再发 `mouseleave`)卡片立刻收起,
|
|
148
158
|
React 重渲染换掉胶囊时同理 —— 不会留下一张浮在空处的卡片。
|
|
149
159
|
|
|
160
|
+
### 逐条回应的回复徽章
|
|
161
|
+
|
|
162
|
+
指令行只是让模型**肯按编号回答**;让回答**连得回来**的是另一套覆盖层。模型按指令把每条回应标成 `「引用1」…「引用N」` 之后:
|
|
163
|
+
|
|
164
|
+
- 徽章增强器在**同一条会话面板**里找这条回复之前**最近的一条带胶囊的消息**(一次树序遍历同时解决「最近」和「在前」:走到徽章文本节点之前最后记账的那条 run 就是它),把它发出去的引用按顺序编号——第 N 颗胶囊就是「引用N」。
|
|
165
|
+
- 回复文本里的 `「引用N」` 于是被切成一枚 `span.dshq-ref` 徽章(**原文一个字不改**,只是套上底色和悬停行为);编号超出那条消息引用数的标签(模型数错了,或有人在正文里自己写了「引用1」)保持纯文本——一枚点了没反应的徽章比没有徽章更糟。
|
|
166
|
+
- 徽章复用悬停卡片:`mouseenter` 就是那张卡(引用、评论全文,`跳回原文` 与 `改这条引用` 按坐标有无自动出现),点击等同清单页里点一行——有坐标就跳,没坐标无声。切分文本节点比胶囊那套「只隐藏、再旁边画」更进一步,规则也补得更紧:一个文本节点只切一次(账本记着原文);React 重写了文本(值不再是账本里的首块)就整体撤掉重切;卸载时原文放回。这层只挂在**助手消息**的文本上——用户自己消息里的「引用1」是草稿的一部分,谁也不该动。
|
|
167
|
+
- 侧边线程(第三项)的围栏同样带路径(``` src/app.ts 的裸围栏),子线程的模型由此也知道代码出自哪个文件。
|
|
168
|
+
|
|
150
169
|
### 侧边对话(第三项)
|
|
151
170
|
|
|
152
171
|
第三项是本插件自己的扩展(Kimi 的浮层只有两项),融合了三家 agent 同类组件的优点:**Kimi Code 的 `/btw` 侧边聊天**——一键在侧边栏开一条继承主会话上下文的线程;**Qoder 的「在侧边任务中提问」**——同一会话进行中的创建请求自动并入第一次,失败只弹一条提示,绝不大张旗鼓;**WorkBuddy 的划选引用**——引用是线程里持久的内容,而不是一次性的草稿附件。与前两家不同的是**问什么由你来写**:引用只落进那条线程的输入框,不会被当成第一条消息替你发出去。样式上第三项用 DeepSeek 的鲸鱼蓝(`#4d6bfe`)画图标与悬停色,白底浅色主题是主基调,深色主题同一色相。
|
|
@@ -206,7 +225,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
206
225
|
1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
|
|
207
226
|
2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标不随引用文本发给模型(不污染 prompt)。
|
|
208
227
|
3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在 4471 条里没有对应物。
|
|
209
|
-
4. **工程化**:
|
|
228
|
+
4. **工程化**:285 项离线断言(含真跑 `sessions.retain` / `setDraft` / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里多数是一两百行、没有测试的插件。
|
|
210
229
|
|
|
211
230
|
反过来也要说清楚:**本插件不做的**——不做翻译/解释类选区动作(81 条同类在做)、不做多选合并引用(7 条有)、不做选区折叠/行内 diff 这类渲染花样。它只把「引用」这一件事做穿。
|
|
212
231
|
|
|
@@ -239,8 +258,8 @@ locale/zh.json 插件页卡片文案(中文)
|
|
|
239
258
|
locale/en.json 插件页卡片文案(英文)
|
|
240
259
|
test/check.mjs 离线自检(清单 + 组件契约)
|
|
241
260
|
test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
|
|
242
|
-
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 +
|
|
243
|
-
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,
|
|
261
|
+
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径 + 逐条回应的回复徽章,189 项断言)
|
|
262
|
+
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 侧边线程回流,96 项断言)
|
|
244
263
|
test/capsule.html 真浏览器里跑同一套胶囊断言(可选)
|
|
245
264
|
```
|
|
246
265
|
|
|
@@ -287,10 +306,10 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
|
|
|
287
306
|
卡片给出 `跳回原文` 与 `改这条引用` 两个动作、点后者把栏切到评论模式并**预填旧评论与旧正文**、
|
|
288
307
|
按确认后 `insertReference` 恰好一次(新芯片的 `ref` 带改后的评论)、旧引用被记为「已修订」并落进 `localStorage`、
|
|
289
308
|
旧胶囊挂上 `已修订` 徽标、它的卡片显示改后的评论(连正文改过的连改后原文一起显示)、空修订不算修订、
|
|
290
|
-
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共
|
|
309
|
+
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出(共 189 项,含文件路径与逐条回应的线上形态、回复徽章的建立/重建/卸载与悬停卡片复用)。
|
|
291
310
|
`test/capsule.html` 是同一套断言的真浏览器版,用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。
|
|
292
311
|
|
|
293
|
-
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(
|
|
312
|
+
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(96 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
|
|
294
313
|
引用按原文逐行拆出来而评论跟着同一条记录、最新一条排最前、空查询保持全量、引用与评论都能被搜到且忽略大小写、
|
|
295
314
|
Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超长引用在标题里被截断、非法时间导出为空;
|
|
296
315
|
读取侧用桩 `sessions.retain` 真跑一遍——读出两行且 `hasMore` 透传、**读完必须 release**(记录 `source: "quoteList"`)、
|
|
@@ -306,7 +325,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
306
325
|
`bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
|
|
307
326
|
`exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
|
|
308
327
|
|
|
309
|
-
合计 **
|
|
328
|
+
合计 **285 项**(check.mjs 的契约断言 + capsule 189 + panel 96),`npm test` 全绿。
|
|
310
329
|
|
|
311
330
|
## 已知限制
|
|
312
331
|
|
|
@@ -318,6 +337,9 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
318
337
|
线程创建要求父会话处于运行态(历史归档会话会收到 409,同样落 toast)。
|
|
319
338
|
- **「改这条引用」是追加,不是原地编辑**:DSH 的 transcript 只追加(侦察确认全树没有 `editMessage` / `updateMessage` / `rewriteMessage` / 回收 API,只有**尚未进入会话**的队列条目可改)。所以修订发出一条新引用(评论、引用正文都可改),旧胶囊只挂 `已修订` 标记——标记存在**本机** `localStorage`(`dsh-client-ui-quote:v1:quote-revised`,按引用原文归一键、上限 60 条),因此不会跨标签页、跨设备同步,也不进入模型上下文;模型看到的是那条改后的新引用。正文改过后键仍是旧原文:`已修订` 挂在**旧**胶囊上(它的卡片里显示改后的原文与评论),改后的新胶囊不挂标。
|
|
320
339
|
- **代码引用的识别靠首行围栏**:`> ❝ ``` ` 开头的块按代码解析(围栏剥掉、胶囊等宽)。因此一条**普通**引用如果正文恰好以 ``` 或 `~~~` 开头(比如在 Markdown 文件预览里引用了源码),回读时也会被当成代码引用——显示上是等宽排版,文字不丢。
|
|
340
|
+
- **逐条回应的指令行按 `※ 指令` 前缀剥离**:剥离规则收得很窄,但一条引用或评论如果恰好以 `※ 指令` 开头,回读时那一行会被当成指令剥掉(胶囊里看不到它)。要让这样的内容活着,别勾逐条回应即可——指令行只在勾选时才写入。
|
|
341
|
+
- **文件预览的路径是探测来的**:宿主没有文档承诺预览 DOM 的路径属性,插件按一组候选属性在预览节点及其几层祖先上探测,拿不到就不带路径(引用形态与从前完全一样);终端来源的引用没有路径可言。路径写进围栏信息位,`sanitizePath` 把带空白或围栏字符的值挡掉。
|
|
342
|
+
- **回复徽章依赖模型守格式**:模型没按指令输出 `「引用N」` 就没有徽章——无损,只是少了联动;输出越界的编号(数错了)保持纯文本。徽章的编号以回复**之前最近的一条带胶囊的消息**为准;切分的是助手消息的文本节点,DSH 若改了助手消息的渲染形态,徽章增强器要跟着调(与胶囊的 `isQuoteRun` 同一条既有限制)。
|
|
321
343
|
- 依赖 DSH 客户端的内部接口(`conversation.input.overlay` 槽位、`inputTriggers.registerSource`、`conversation.input.shell(id).insertReference`、
|
|
322
344
|
`reference-chip` 节点),DSH 升级有可能失效。
|
|
323
345
|
- **引用清单页依赖右侧栏的两个服务**(`sidebarRightTabs` 与 `sidebarRight`,实测 0.2.0-rc.2);宿主没有它们时页面不注册、`Alt+L` 给一条 toast
|
|
@@ -355,3 +377,8 @@ and it closes with the chip as soon as the message is sent. Each chip resolves i
|
|
|
355
377
|
reuses a chip node for the next quote, so a card can never show the previous one's text.
|
|
356
378
|
Install with `dsh plugin --profile desktop add link:<clone-path>` (DSH >= 0.2.0-rc.2, web/desktop client).
|
|
357
379
|
Test with `npm test`. MIT licensed.
|
|
380
|
+
|
|
381
|
+
Two wire-only extras ride on the same pipeline: a per-quote reply toggle in the comment box (the block carries a
|
|
382
|
+
transport-only instruction, the model answers `「引用N」` by `「引用N」`, and each label becomes a badge that reopens
|
|
383
|
+
the quote's hover card), and a preview quote that puts its file into the fence's info string so the model can open
|
|
384
|
+
the file itself.
|
package/lib/client.js
CHANGED
|
@@ -83,7 +83,8 @@ window.__ModuleLoader__.load({
|
|
|
83
83
|
threadBackFailed: "没读到这个线程的内容",
|
|
84
84
|
threadCount: "{n} 条消息",
|
|
85
85
|
revise: "改这条引用",
|
|
86
|
-
revised: "已修订"
|
|
86
|
+
revised: "已修订",
|
|
87
|
+
replyToggle: "让模型逐条回应"
|
|
87
88
|
};
|
|
88
89
|
|
|
89
90
|
/** English copy, verbatim from Kimi Code's `selection` namespace. */
|
|
@@ -119,7 +120,8 @@ window.__ModuleLoader__.load({
|
|
|
119
120
|
threadBackFailed: "Could not read that thread",
|
|
120
121
|
threadCount: "{n} messages",
|
|
121
122
|
revise: "Revise this quote",
|
|
122
|
-
revised: "Revised"
|
|
123
|
+
revised: "Revised",
|
|
124
|
+
replyToggle: "Ask for per-quote replies"
|
|
123
125
|
};
|
|
124
126
|
|
|
125
127
|
/* ------------------------------------------------------------------ *
|
|
@@ -150,6 +152,35 @@ window.__ModuleLoader__.load({
|
|
|
150
152
|
*/
|
|
151
153
|
const COMMENT_MARK = "\u275E";
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The mark that opens the transport-only reply instruction line. When the
|
|
157
|
+
* comment box's 逐条回应 toggle is on, the block ends with one more
|
|
158
|
+
* blockquote line starting with this mark; the model reads it and answers
|
|
159
|
+
* per quote (`「引用1」…「引用N」`), while `splitQuoteRuns` strips the line
|
|
160
|
+
* on the way back out — a capsule, a card and the export never show it.
|
|
161
|
+
* The `指令:` phrase after the mark is part of the strip rule, so a line
|
|
162
|
+
* that happens to start with a bare `※` is left alone.
|
|
163
|
+
*/
|
|
164
|
+
const REPLY_MARK = "\u203B";
|
|
165
|
+
const REPLY_INSTRUCTION =
|
|
166
|
+
"指令:请把本条消息里的引用块按出现顺序记为「引用1」「引用2」…,回复时逐条对应回应,每条回应前标注「引用N」。";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Attributes a file preview may carry its path in. The preview DOM is a
|
|
170
|
+
* host detail this plugin does not own, so the lookup walks a few ancestors
|
|
171
|
+
* and takes the first hit; when none matches, the quote travels without a
|
|
172
|
+
* path — exactly the shape it had before the probe existed.
|
|
173
|
+
*/
|
|
174
|
+
const PREVIEW_PATH_ATTRS = [
|
|
175
|
+
"data-textpreview-path",
|
|
176
|
+
"data-preview-path",
|
|
177
|
+
"data-document-path",
|
|
178
|
+
"data-file-path",
|
|
179
|
+
"data-path",
|
|
180
|
+
"data-file",
|
|
181
|
+
"data-filename"
|
|
182
|
+
];
|
|
183
|
+
|
|
153
184
|
/** Transcript host: a selection outside it is none of our business. */
|
|
154
185
|
const CONTENT_SELECTOR = "[data-conversation-content]";
|
|
155
186
|
/** The composer card: a selection inside it is a draft edit, not a quote. */
|
|
@@ -300,6 +331,10 @@ background:transparent;color:inherit;font:inherit;cursor:default}
|
|
|
300
331
|
#${BAR_ID} .dshq-btn-primary:hover{background:var(--dshq-accent);opacity:.9}
|
|
301
332
|
#${BAR_ID} .dshq-btn:disabled{opacity:.45}
|
|
302
333
|
#${BAR_ID} .dshq-enter{margin-left:4px;opacity:.65}
|
|
334
|
+
#${BAR_ID} .dshq-reply{display:flex;align-items:center;gap:6px;font-size:11.5px;line-height:1.5;
|
|
335
|
+
opacity:.78;cursor:pointer;user-select:none}
|
|
336
|
+
#${BAR_ID} .dshq-reply:hover{opacity:1}
|
|
337
|
+
#${BAR_ID} .dshq-reply-check{margin:0;accent-color:var(--dshq-accent,currentColor)}
|
|
303
338
|
#${TOAST_ID}{position:fixed;z-index:2147483001;display:none;padding:4px 10px;border-radius:6px;
|
|
304
339
|
background:rgba(24,24,27,.92);color:#f4f4f5;font-size:12px;line-height:1.5;pointer-events:none;
|
|
305
340
|
-webkit-app-region:no-drag}
|
|
@@ -334,6 +369,9 @@ text-overflow:clip;overflow-wrap:anywhere}
|
|
|
334
369
|
.dshq-cap[data-expanded="true"] .dshq-cap-comment{flex:1 1 100%;max-width:none;margin-top:5px;
|
|
335
370
|
padding-left:9px;white-space:pre-wrap;overflow:visible;text-overflow:clip;overflow-wrap:anywhere}
|
|
336
371
|
.dshq-body{display:block;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
372
|
+
.dshq-ref{display:inline;padding:0 4px;border-radius:6px;cursor:pointer;color:inherit;font-weight:600;
|
|
373
|
+
background:rgba(127,127,127,.12);background:color-mix(in srgb,currentColor 9%,transparent)}
|
|
374
|
+
.dshq-ref:hover{background:rgba(127,127,127,.2);background:color-mix(in srgb,currentColor 16%,transparent)}
|
|
337
375
|
/* Hover card: a collapsed pill keeps one clipped line per half and the chip in
|
|
338
376
|
the composer is clipped even harder, so hovering either one shows the quote and
|
|
339
377
|
the comment in full. It is parked on <body> with position:fixed because a chat
|
|
@@ -447,12 +485,44 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
447
485
|
* ------------------------------------------------------------------ */
|
|
448
486
|
|
|
449
487
|
/**
|
|
450
|
-
* @returns {{ text: string, x: number, y: number, bottom: number, sessionId: string, hostKind: "chat" | "preview" | "terminal" } | null}
|
|
488
|
+
* @returns {{ text: string, x: number, y: number, bottom: number, sessionId: string, hostKind: "chat" | "preview" | "terminal", path: string | null } | null}
|
|
451
489
|
* the selected text plus its viewport anchor, or null when the current
|
|
452
490
|
* selection is not a quotable transcript selection. `hostKind` says where
|
|
453
491
|
* the selection lives: a preview/terminal quote is code and travels in a
|
|
454
|
-
* fenced block, a chat quote stays a plain blockquote.
|
|
492
|
+
* fenced block, a chat quote stays a plain blockquote. `path` is the file
|
|
493
|
+
* a preview selection came from, when the host exposes one.
|
|
494
|
+
*/
|
|
495
|
+
/**
|
|
496
|
+
* A path is only worth sending when it reads as one: no whitespace (the
|
|
497
|
+
* fence info string cannot carry it), no fence characters, not absurdly
|
|
498
|
+
* long. Anything else degrades to no path at all.
|
|
499
|
+
*/
|
|
500
|
+
function sanitizePath(value) {
|
|
501
|
+
const path = String(value === null || value === undefined ? "" : value).trim();
|
|
502
|
+
if (path === "" || path.length > 200 || /\s|[`~]/u.test(path)) return null;
|
|
503
|
+
return path;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The file a preview selection came from, when the host exposes it. The
|
|
508
|
+
* probe walks a few ancestors and takes the first path-looking attribute;
|
|
509
|
+
* the preview DOM belongs to the host, so a miss is the normal case and
|
|
510
|
+
* costs nothing — the quote then travels exactly as before.
|
|
455
511
|
*/
|
|
512
|
+
function previewPathOf(host) {
|
|
513
|
+
let node = host;
|
|
514
|
+
for (let hop = 0; node !== null && node !== undefined && hop < 6; hop += 1, node = node.parentElement) {
|
|
515
|
+
if (typeof node.getAttribute !== "function") continue;
|
|
516
|
+
for (const attr of PREVIEW_PATH_ATTRS) {
|
|
517
|
+
const value = node.getAttribute(attr);
|
|
518
|
+
if (typeof value !== "string") continue;
|
|
519
|
+
const clean = sanitizePath(value);
|
|
520
|
+
if (clean !== null) return clean;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
456
526
|
function readSelection() {
|
|
457
527
|
const selection = document.getSelection === undefined ? null : document.getSelection();
|
|
458
528
|
if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
@@ -492,6 +562,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
492
562
|
const rect = range.getBoundingClientRect();
|
|
493
563
|
if (rect.width === 0 && rect.height === 0) return null;
|
|
494
564
|
|
|
565
|
+
// A preview selection may carry the file it came from: the path rides
|
|
566
|
+
// in the fence's info string so the model can open the file itself.
|
|
567
|
+
const path = hostKind === "preview" ? previewPathOf(host) : null;
|
|
568
|
+
|
|
495
569
|
return {
|
|
496
570
|
text,
|
|
497
571
|
x: rect.left + rect.width / 2,
|
|
@@ -499,6 +573,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
499
573
|
bottom: rect.bottom,
|
|
500
574
|
sessionId,
|
|
501
575
|
hostKind,
|
|
576
|
+
path: typeof path === "string" ? path : null,
|
|
502
577
|
// Only a transcript selection has a row to jump back to.
|
|
503
578
|
source: chat === null ? null : chatRowOf(range.startContainer)
|
|
504
579
|
};
|
|
@@ -530,8 +605,18 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
530
605
|
* `> ❝ ``` ` / `> …` / `> ``` ` — so the model sees a real code block
|
|
531
606
|
* while the block (and its capsule) still opens at the ❝ mark. The fence
|
|
532
607
|
* is transport only: `splitQuoteRuns` strips it again on the way out.
|
|
608
|
+
* A preview quote can carry its file the same way: `meta.path` rides in
|
|
609
|
+
* the fence's info string (`> ❝ ``` src/app.ts`), which is where Markdown
|
|
610
|
+
* keeps metadata the model reads but renderers never show as content.
|
|
611
|
+
*
|
|
612
|
+
* `meta.reply === true` appends one transport line at the block's tail —
|
|
613
|
+
* the 逐条回应 instruction the model answers quote by quote. Like the
|
|
614
|
+
* fence it is wire-only: `splitQuoteRuns` strips it before anything
|
|
615
|
+
* downstream (capsule, card, export) ever sees it.
|
|
533
616
|
*/
|
|
534
|
-
function quoteBlock(text, comment, code) {
|
|
617
|
+
function quoteBlock(text, comment, code, meta) {
|
|
618
|
+
const options = meta === null || meta === undefined || typeof meta !== "object" ? {} : meta;
|
|
619
|
+
const path = sanitizePath(options.path) ?? "";
|
|
535
620
|
const halves = [
|
|
536
621
|
{ mark: QUOTE_MARK, lines: blockLines(text) },
|
|
537
622
|
{ mark: COMMENT_MARK, lines: blockLines(comment) }
|
|
@@ -540,7 +625,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
540
625
|
for (const half of halves) {
|
|
541
626
|
if (code === true && half.mark === QUOTE_MARK && half.lines.length > 0) {
|
|
542
627
|
const fence = half.lines.some((line) => /^\s*```/u.test(line)) ? "~~~" : "```";
|
|
543
|
-
block.push("> " + half.mark + " " + fence);
|
|
628
|
+
block.push("> " + half.mark + " " + fence + (path === "" ? "" : " " + path));
|
|
544
629
|
for (const line of half.lines) block.push(line.trim() === "" ? ">" : "> " + line);
|
|
545
630
|
block.push("> " + fence);
|
|
546
631
|
continue;
|
|
@@ -551,6 +636,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
551
636
|
else block.push(line.trim() === "" ? ">" : "> " + line);
|
|
552
637
|
}
|
|
553
638
|
}
|
|
639
|
+
if (options.reply === true) block.push("> " + REPLY_MARK + " " + REPLY_INSTRUCTION);
|
|
554
640
|
return block.join("\n");
|
|
555
641
|
}
|
|
556
642
|
|
|
@@ -594,14 +680,19 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
594
680
|
body.push(lines[index].replace(/^>\s?/u, ""));
|
|
595
681
|
index += 1;
|
|
596
682
|
}
|
|
683
|
+
// The 逐条回应 instruction is transport for the model: it rides at
|
|
684
|
+
// the block's tail and never reaches a capsule, a card or the
|
|
685
|
+
// export. The `指令:` phrase keeps the strip narrow — a quote or
|
|
686
|
+
// comment that merely starts with a `※` is left alone.
|
|
687
|
+
const carried = body.filter((entry) => !/^\s*※\s*指令/u.test(entry));
|
|
597
688
|
const before = loose.replace(/^\s+/u, "").replace(/\s+$/u, "");
|
|
598
689
|
if (before !== "") segments.push({ type: "text", text: before });
|
|
599
690
|
loose = "";
|
|
600
691
|
// `> ❞ …` opens the comment half of the same block: everything from
|
|
601
692
|
// there down is the comment, everything above it is the quote.
|
|
602
|
-
const cut =
|
|
603
|
-
let quoteLines = cut === -1 ?
|
|
604
|
-
const commentLines = cut === -1 ? [] :
|
|
693
|
+
const cut = carried.findIndex((entry) => entry.replace(/^\s+/u, "").startsWith(COMMENT_MARK));
|
|
694
|
+
let quoteLines = cut === -1 ? carried : carried.slice(0, cut);
|
|
695
|
+
const commentLines = cut === -1 ? [] : carried.slice(cut);
|
|
605
696
|
if (commentLines.length > 0) {
|
|
606
697
|
commentLines[0] = commentLines[0]
|
|
607
698
|
.replace(/^\s+/u, "")
|
|
@@ -638,9 +729,9 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
638
729
|
}
|
|
639
730
|
|
|
640
731
|
/** Quote (+ optional comment) exactly as it should land in the draft. */
|
|
641
|
-
function buildPayload(text, comment, code) {
|
|
732
|
+
function buildPayload(text, comment, code, meta) {
|
|
642
733
|
const prefix = composerText().trim() === "" ? "" : "\n";
|
|
643
|
-
return prefix + quoteBlock(text, comment, code) + "\n\n";
|
|
734
|
+
return prefix + quoteBlock(text, comment, code, meta) + "\n\n";
|
|
644
735
|
}
|
|
645
736
|
|
|
646
737
|
/** Kimi inserts at the caret; the composer answers a collapsed span at the end. */
|
|
@@ -667,11 +758,11 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
667
758
|
* comment rides in that same ref, which is what lets the transcript keep
|
|
668
759
|
* the pair in one capsule instead of two unrelated pieces of text.
|
|
669
760
|
*/
|
|
670
|
-
function chipRef(text, comment, code) {
|
|
761
|
+
function chipRef(text, comment, code, meta) {
|
|
671
762
|
// The leading newline puts the block on its own paragraph when the chip
|
|
672
763
|
// lands after typed text; the send path trims it when the quote is
|
|
673
764
|
// the whole message.
|
|
674
|
-
return "\n" + quoteBlock(text, comment, code) + "\n\n";
|
|
765
|
+
return "\n" + quoteBlock(text, comment, code, meta) + "\n\n";
|
|
675
766
|
}
|
|
676
767
|
|
|
677
768
|
/** The chip label spells the pair out the way Kimi's `display_text` does. */
|
|
@@ -729,7 +820,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
729
820
|
* shell owns the editor that can place an atomic node. Every step is
|
|
730
821
|
* optional — a missing service just falls back to the Markdown form.
|
|
731
822
|
*/
|
|
732
|
-
function insertChip(entry, sessionId, text, comment, code) {
|
|
823
|
+
function insertChip(entry, sessionId, text, comment, code, meta) {
|
|
733
824
|
if (!ensureSource() || pluginCtx === null) return false;
|
|
734
825
|
let hub = null;
|
|
735
826
|
try {
|
|
@@ -746,7 +837,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
746
837
|
shell = null;
|
|
747
838
|
}
|
|
748
839
|
if (shell === null || shell === undefined || typeof shell.insertReference !== "function") return false;
|
|
749
|
-
const model = chipRef(text, comment, code);
|
|
840
|
+
const model = chipRef(text, comment, code, meta);
|
|
750
841
|
const attachment = {
|
|
751
842
|
source: SOURCE_NAME,
|
|
752
843
|
ref: model,
|
|
@@ -1541,7 +1632,9 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1541
1632
|
|
|
1542
1633
|
const parent = run.parentNode;
|
|
1543
1634
|
if (parent === null) return;
|
|
1544
|
-
|
|
1635
|
+
// The segments ride along in the ledger: the reply badges need the
|
|
1636
|
+
// numbered quotes of the nearest preceding capsule-carrying message.
|
|
1637
|
+
capsules.set(run, { text, nodes, segments });
|
|
1545
1638
|
run.style.display = "none";
|
|
1546
1639
|
for (const node of nodes) parent.insertBefore(node, run);
|
|
1547
1640
|
}
|
|
@@ -1572,9 +1665,13 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1572
1665
|
for (const record of records) {
|
|
1573
1666
|
if (record.type === "characterData") {
|
|
1574
1667
|
collectRuns(record.target, capsuleDirty);
|
|
1668
|
+
collectRefRuns(record.target, refDirty);
|
|
1575
1669
|
continue;
|
|
1576
1670
|
}
|
|
1577
|
-
for (const node of record.addedNodes)
|
|
1671
|
+
for (const node of record.addedNodes) {
|
|
1672
|
+
collectRuns(node, capsuleDirty);
|
|
1673
|
+
collectRefRuns(node, refDirty);
|
|
1674
|
+
}
|
|
1578
1675
|
for (const node of record.removedNodes) {
|
|
1579
1676
|
if (node.nodeType === 1 && capsules.has(node)) capsuleDirty.add(node);
|
|
1580
1677
|
}
|
|
@@ -1601,6 +1698,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1601
1698
|
if (run.isConnected) enhance(run);
|
|
1602
1699
|
else if (capsules.has(run)) dropCapsule(run);
|
|
1603
1700
|
}
|
|
1701
|
+
// Badges come last: the quotes they link to must be in the ledger first.
|
|
1702
|
+
flushRefBadges();
|
|
1604
1703
|
wireChips();
|
|
1605
1704
|
}
|
|
1606
1705
|
|
|
@@ -1626,7 +1725,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1626
1725
|
capsuleObserver.observe(target, { childList: true, subtree: true, characterData: true });
|
|
1627
1726
|
}
|
|
1628
1727
|
if (typeof document.querySelectorAll === "function") {
|
|
1629
|
-
for (const host of document.querySelectorAll(CONTENT_SELECTOR))
|
|
1728
|
+
for (const host of document.querySelectorAll(CONTENT_SELECTOR)) {
|
|
1729
|
+
collectRuns(host, capsuleDirty);
|
|
1730
|
+
collectRefRuns(host, refDirty);
|
|
1731
|
+
}
|
|
1630
1732
|
}
|
|
1631
1733
|
// A fixed card would otherwise hang in place after the page moved.
|
|
1632
1734
|
if (!popoverWired && typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
@@ -1649,6 +1751,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1649
1751
|
}
|
|
1650
1752
|
capsuleDirty.clear();
|
|
1651
1753
|
for (const run of Array.from(capsules.keys())) dropCapsule(run);
|
|
1754
|
+
uninstallRefBadges();
|
|
1652
1755
|
cancelPopoverHide();
|
|
1653
1756
|
unhookChips();
|
|
1654
1757
|
if (popover !== null) {
|
|
@@ -1668,6 +1771,199 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1668
1771
|
}
|
|
1669
1772
|
}
|
|
1670
1773
|
|
|
1774
|
+
/* ------------------------------------------------------------------ *
|
|
1775
|
+
* Reply badges — the model's per-quote answers, linked back
|
|
1776
|
+
* ------------------------------------------------------------------ */
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* The other half of 逐条回应: when the instruction rode along with the
|
|
1780
|
+
* quotes, the model labels its answers `「引用1」…「引用N」`, and each
|
|
1781
|
+
* label becomes a badge — hover it and the quote's card opens (jump and
|
|
1782
|
+
* revise included, exactly as on a capsule), because the reply is only
|
|
1783
|
+
* useful while it stays tied to the passage it answers.
|
|
1784
|
+
*
|
|
1785
|
+
* The label lives inside a markdown-rendered assistant run, so this pass
|
|
1786
|
+
* splits the text node it sits in — one step more invasive than the
|
|
1787
|
+
* capsule overlay, which only hides and re-reads whole runs. The rules
|
|
1788
|
+
* that keep it safe: a text node is split only once (the ledger holds the
|
|
1789
|
+
* original), a rewrite by React is detected by the value no longer being
|
|
1790
|
+
* our head chunk (our nodes go, the node is re-read fresh), and uninstall
|
|
1791
|
+
* puts the original text back. A label whose number outruns the quotes of
|
|
1792
|
+
* the nearest preceding quoted message is left as plain text — the model
|
|
1793
|
+
* may miscount, and a dead badge would be worse than no badge.
|
|
1794
|
+
*/
|
|
1795
|
+
|
|
1796
|
+
/** text node -> { text, head, nodes } for every split reply run. */
|
|
1797
|
+
const refBadges = new Map();
|
|
1798
|
+
const refDirty = new Set();
|
|
1799
|
+
const REF_LABEL = /\u300c\u5f15\u7528\d+\u300d/u;
|
|
1800
|
+
|
|
1801
|
+
function isRefCandidate(node) {
|
|
1802
|
+
if (node === null || node.nodeType !== 3) return false;
|
|
1803
|
+
const value = typeof node.nodeValue === "string" ? node.nodeValue : "";
|
|
1804
|
+
if (REF_LABEL.test(value) !== true) return false;
|
|
1805
|
+
const parent = node.parentElement;
|
|
1806
|
+
if (parent === null || typeof parent.closest !== "function") return false;
|
|
1807
|
+
if (parent.closest("[data-dshq]") !== null) return false;
|
|
1808
|
+
return parent.closest(CONTENT_SELECTOR) !== null;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Gather candidate text nodes out of a mutation's added subtree. */
|
|
1812
|
+
function collectRefRuns(node, into) {
|
|
1813
|
+
if (node.nodeType === 3) {
|
|
1814
|
+
if (isRefCandidate(node) === true) into.add(node);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const element = elementOf(node);
|
|
1818
|
+
if (element === null) return;
|
|
1819
|
+
const stack = [element];
|
|
1820
|
+
while (stack.length > 0) {
|
|
1821
|
+
const current = stack.pop();
|
|
1822
|
+
for (const child of current.childNodes) {
|
|
1823
|
+
if (child.nodeType === 3) {
|
|
1824
|
+
if (isRefCandidate(child) === true) into.add(child);
|
|
1825
|
+
} else if (child.nodeType === 1 && child.childNodes.length > 0) {
|
|
1826
|
+
stack.push(child);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* The quotes of the nearest preceding capsule-carrying run, in message
|
|
1834
|
+
* order. One walk over the session host settles both "nearest" and
|
|
1835
|
+
* "before" — the run that was last seen when the walk reached the badge's
|
|
1836
|
+
* text node is the one that precedes it.
|
|
1837
|
+
*/
|
|
1838
|
+
function precedingQuoteSegments(node, host) {
|
|
1839
|
+
let best = null;
|
|
1840
|
+
let seen = false;
|
|
1841
|
+
const walk = (current) => {
|
|
1842
|
+
if (seen === true) return;
|
|
1843
|
+
if (current === node) {
|
|
1844
|
+
seen = true;
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (capsules.has(current)) best = current;
|
|
1848
|
+
for (const child of current.childNodes) walk(child);
|
|
1849
|
+
};
|
|
1850
|
+
walk(host);
|
|
1851
|
+
if (best === null) return [];
|
|
1852
|
+
const known = capsules.get(best);
|
|
1853
|
+
const segments = known === undefined || known === null ? null : known.segments;
|
|
1854
|
+
if (!Array.isArray(segments)) return [];
|
|
1855
|
+
return segments.filter((segment) => segment !== null && segment !== undefined && segment.type === "quote");
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function removeRefNodes(known) {
|
|
1859
|
+
if (known === undefined || known === null) return;
|
|
1860
|
+
for (const node of known.nodes) {
|
|
1861
|
+
if (node !== null && node !== undefined && node.isConnected) node.remove();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function refBadgeFor(doc, sessionId, label, segment) {
|
|
1866
|
+
const info = {
|
|
1867
|
+
quote: segment.quote,
|
|
1868
|
+
comment: typeof segment.comment === "string" ? segment.comment : "",
|
|
1869
|
+
sessionId,
|
|
1870
|
+
code: segment.code !== null && segment.code !== undefined
|
|
1871
|
+
};
|
|
1872
|
+
const badge = el(doc, "span", { class: "dshq-ref", "data-dshq": "ref", text: label });
|
|
1873
|
+
badge.addEventListener("mouseenter", () => showPopover(badge, info));
|
|
1874
|
+
badge.addEventListener("mouseleave", hidePopoverSoon);
|
|
1875
|
+
badge.addEventListener("click", (event) => {
|
|
1876
|
+
// A drag-select over the badge must not fire a jump.
|
|
1877
|
+
const selection = doc.getSelection === undefined ? null : doc.getSelection();
|
|
1878
|
+
if (selection !== null && selection.isCollapsed === false) return;
|
|
1879
|
+
jumpToSource(segment.quote, sessionId);
|
|
1880
|
+
});
|
|
1881
|
+
return badge;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/** Split one text node into plain text and badges, once. */
|
|
1885
|
+
function enhanceRefNode(node) {
|
|
1886
|
+
if (refBadges.has(node)) return;
|
|
1887
|
+
if (isRefCandidate(node) !== true) return;
|
|
1888
|
+
const value = node.nodeValue;
|
|
1889
|
+
const parts = value.split(/(\u300c\u5f15\u7528\d+\u300d)/u);
|
|
1890
|
+
if (parts.length < 3) return;
|
|
1891
|
+
const parent = node.parentNode;
|
|
1892
|
+
const doc = node.ownerDocument === null || node.ownerDocument === undefined ? (parent === null ? null : parent.ownerDocument) : node.ownerDocument;
|
|
1893
|
+
if (parent === null || doc === null || doc === undefined) return;
|
|
1894
|
+
const host = parent.closest !== undefined ? parent.closest(CONTENT_SELECTOR) : null;
|
|
1895
|
+
if (host === null) return;
|
|
1896
|
+
const sessionId = (() => {
|
|
1897
|
+
const id = host.getAttribute("data-conversation-session");
|
|
1898
|
+
return typeof id === "string" ? id : null;
|
|
1899
|
+
})();
|
|
1900
|
+
const quotes = precedingQuoteSegments(node, host);
|
|
1901
|
+
if (quotes.length === 0) return;
|
|
1902
|
+
|
|
1903
|
+
const nodes = [];
|
|
1904
|
+
// The original node keeps the first chunk; everything else is inserted
|
|
1905
|
+
// after it, in order, so React's node stays where reconciliation left it.
|
|
1906
|
+
node.nodeValue = parts[0];
|
|
1907
|
+
let previous = node;
|
|
1908
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
1909
|
+
const part = parts[index];
|
|
1910
|
+
let created = null;
|
|
1911
|
+
if (index % 2 === 1) {
|
|
1912
|
+
const number = Number(part.slice(3, -1));
|
|
1913
|
+
const segment = number >= 1 && number <= quotes.length ? quotes[number - 1] : null;
|
|
1914
|
+
created = segment === null ? doc.createTextNode(part) : refBadgeFor(doc, sessionId, part, segment);
|
|
1915
|
+
} else {
|
|
1916
|
+
created = doc.createTextNode(part);
|
|
1917
|
+
}
|
|
1918
|
+
if (created === null) continue;
|
|
1919
|
+
parent.insertBefore(created, previous.nextSibling);
|
|
1920
|
+
previous = created;
|
|
1921
|
+
nodes.push(created);
|
|
1922
|
+
}
|
|
1923
|
+
refBadges.set(node, { text: value, head: parts[0], nodes });
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/** Reconcile the ledger, then split whatever is still pending. */
|
|
1927
|
+
function flushRefBadges() {
|
|
1928
|
+
for (const [node, known] of Array.from(refBadges)) {
|
|
1929
|
+
if (node.isConnected === false) {
|
|
1930
|
+
removeRefNodes(known);
|
|
1931
|
+
refBadges.delete(node);
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
// React rewrote the text: our chunks duplicate the tail now. Drop
|
|
1935
|
+
// everything we added and re-read the node's fresh value.
|
|
1936
|
+
if (node.nodeValue !== known.head) {
|
|
1937
|
+
removeRefNodes(known);
|
|
1938
|
+
refBadges.delete(node);
|
|
1939
|
+
refDirty.add(node);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
if (refDirty.size === 0) return;
|
|
1943
|
+
const candidates = Array.from(refDirty);
|
|
1944
|
+
refDirty.clear();
|
|
1945
|
+
for (const node of candidates) {
|
|
1946
|
+
if (node.isConnected === true) enhanceRefNode(node);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function uninstallRefBadges() {
|
|
1951
|
+
for (const [node, known] of Array.from(refBadges)) {
|
|
1952
|
+
removeRefNodes(known);
|
|
1953
|
+
// Restoring is only honest while React never touched the value
|
|
1954
|
+
// after the split; otherwise the fresh value is the truth.
|
|
1955
|
+
if (node.isConnected && node.nodeValue === known.head) {
|
|
1956
|
+
try {
|
|
1957
|
+
node.nodeValue = known.text;
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
// A read-only node only costs its original wording.
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
refBadges.clear();
|
|
1964
|
+
refDirty.clear();
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1671
1967
|
/* ------------------------------------------------------------------ *
|
|
1672
1968
|
* Bar element
|
|
1673
1969
|
* ------------------------------------------------------------------ */
|
|
@@ -1683,11 +1979,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1683
1979
|
mode: null,
|
|
1684
1980
|
sessionId: null,
|
|
1685
1981
|
comment: "",
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1982
|
+
/** True when the selection came from the preview or the terminal:
|
|
1983
|
+
* the quote travels as a fenced code block, not a plain quote. */
|
|
1984
|
+
code: false,
|
|
1985
|
+
/** The file a preview selection came from, when the host exposes it. */
|
|
1986
|
+
path: null,
|
|
1987
|
+
/** 逐条回应: the current quote asks the model to answer per quote. */
|
|
1988
|
+
reply: false,
|
|
1989
|
+
/** Where the current selection was picked up, for the jump-back row. */
|
|
1990
|
+
source: null,
|
|
1691
1991
|
/** The quote key being corrected, when the box was opened by 改这条引用. */
|
|
1692
1992
|
revising: null,
|
|
1693
1993
|
settle: null,
|
|
@@ -1871,6 +2171,20 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1871
2171
|
quoteInput.addEventListener("keydown", onKey);
|
|
1872
2172
|
input.addEventListener("keydown", onKey);
|
|
1873
2173
|
|
|
2174
|
+
// 逐条回应: one opt-in checkbox. On, the block carries a transport
|
|
2175
|
+
// instruction asking the model to answer per quote, and the reply's
|
|
2176
|
+
// `「引用N」` labels become badges that link back to these quotes.
|
|
2177
|
+
// Off (the default), the wire stays exactly the shape it always was.
|
|
2178
|
+
const replyCheck = el(doc, "input", { type: "checkbox", class: "dshq-reply-check" });
|
|
2179
|
+
replyCheck.checked = state.reply === true;
|
|
2180
|
+
replyCheck.addEventListener("change", () => {
|
|
2181
|
+
state.reply = replyCheck.checked === true;
|
|
2182
|
+
});
|
|
2183
|
+
const replyToggle = el(doc, "label", { class: "dshq-reply" }, [
|
|
2184
|
+
replyCheck,
|
|
2185
|
+
el(doc, "span", { text: translate("replyToggle") })
|
|
2186
|
+
]);
|
|
2187
|
+
|
|
1874
2188
|
grow(quoteInput);
|
|
1875
2189
|
syncConfirm();
|
|
1876
2190
|
|
|
@@ -1878,6 +2192,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1878
2192
|
el(doc, "div", { class: "dshq-comment" }, [
|
|
1879
2193
|
quoteInput,
|
|
1880
2194
|
input,
|
|
2195
|
+
replyToggle,
|
|
1881
2196
|
el(doc, "div", { class: "dshq-actions" }, [
|
|
1882
2197
|
el(doc, "button", { type: "button", class: "dshq-btn", "data-action": "cancel" }, [translate("cancel")]),
|
|
1883
2198
|
confirm
|
|
@@ -1921,6 +2236,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1921
2236
|
state.sessionId = found.sessionId;
|
|
1922
2237
|
state.comment = "";
|
|
1923
2238
|
state.code = found.hostKind === "preview" || found.hostKind === "terminal";
|
|
2239
|
+
state.path = typeof found.path === "string" ? found.path : null;
|
|
2240
|
+
state.reply = false;
|
|
1924
2241
|
state.source = found.source === undefined ? null : found.source;
|
|
1925
2242
|
state.mode = "menu";
|
|
1926
2243
|
state.suppress = false;
|
|
@@ -1952,6 +2269,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1952
2269
|
state.quote = quote;
|
|
1953
2270
|
state.comment = typeof comment === "string" ? comment : "";
|
|
1954
2271
|
state.code = code === true;
|
|
2272
|
+
state.path = null;
|
|
2273
|
+
state.reply = false;
|
|
1955
2274
|
state.sessionId = sessionId === null || sessionId === undefined ? null : sessionId;
|
|
1956
2275
|
state.source = sourceFor(quote);
|
|
1957
2276
|
state.revising = sourceKeyOf(quote);
|
|
@@ -1977,6 +2296,8 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
1977
2296
|
state.sessionId = null;
|
|
1978
2297
|
state.comment = "";
|
|
1979
2298
|
state.code = false;
|
|
2299
|
+
state.path = null;
|
|
2300
|
+
state.reply = false;
|
|
1980
2301
|
state.source = null;
|
|
1981
2302
|
state.revising = null;
|
|
1982
2303
|
state.suppress = true;
|
|
@@ -2014,6 +2335,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2014
2335
|
const source = state.source;
|
|
2015
2336
|
const revising = state.revising;
|
|
2016
2337
|
const code = state.code;
|
|
2338
|
+
const meta = { path: state.path, reply: state.reply === true };
|
|
2017
2339
|
const entry = sessionId === null ? null : sessions.get(sessionId);
|
|
2018
2340
|
const trimmed = typeof comment === "string" ? comment.trim() : "";
|
|
2019
2341
|
close();
|
|
@@ -2034,11 +2356,11 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2034
2356
|
// the fallback for every build where that service face is missing.
|
|
2035
2357
|
// A comment rides inside that chip — one node, one capsule after the
|
|
2036
2358
|
// send — so 评论 writes nothing else into the draft.
|
|
2037
|
-
if (insertChip(entry, sessionId, text, trimmed, code)) {
|
|
2359
|
+
if (insertChip(entry, sessionId, text, trimmed, code, meta)) {
|
|
2038
2360
|
focusComposer();
|
|
2039
2361
|
return;
|
|
2040
2362
|
}
|
|
2041
|
-
if (insertPayload(entry, buildPayload(text, trimmed, code))) focusComposer();
|
|
2363
|
+
if (insertPayload(entry, buildPayload(text, trimmed, code, meta))) focusComposer();
|
|
2042
2364
|
else toast(translate("failed"));
|
|
2043
2365
|
}
|
|
2044
2366
|
|
|
@@ -2093,13 +2415,15 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2093
2415
|
* `❝` marks stay out on purpose — they exist for the MAIN transcript's
|
|
2094
2416
|
* capsule enhancer, and the side thread renders its own way. A code quote
|
|
2095
2417
|
* keeps its fence instead: that composer talks to the model, and a fenced
|
|
2096
|
-
* block is what the model reads as code.
|
|
2418
|
+
* block is what the model reads as code. A preview quote puts its file
|
|
2419
|
+
* into the fence's info string, the same slot the main wire uses.
|
|
2097
2420
|
*/
|
|
2098
|
-
function sideQuestion(text, code) {
|
|
2421
|
+
function sideQuestion(text, code, path) {
|
|
2099
2422
|
const lines = blockLines(text);
|
|
2100
2423
|
if (code === true && lines.length > 0) {
|
|
2101
2424
|
const fence = lines.some((line) => /^\s*```/u.test(line)) ? "~~~" : "```";
|
|
2102
|
-
|
|
2425
|
+
const clean = sanitizePath(path) ?? "";
|
|
2426
|
+
return [fence + (clean === "" ? "" : " " + clean)].concat(lines, [fence]).join("\n");
|
|
2103
2427
|
}
|
|
2104
2428
|
return lines
|
|
2105
2429
|
.map((line) => (line.trim() === "" ? ">" : "> " + line))
|
|
@@ -2301,9 +2625,10 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2301
2625
|
* @param {string} sessionId - the session the quote came from.
|
|
2302
2626
|
* @param {string} text - the quoted text.
|
|
2303
2627
|
* @param {boolean} [code] - true when the quote is code (preview/terminal).
|
|
2628
|
+
* @param {string | null} [path] - the file a preview quote came from.
|
|
2304
2629
|
* @returns {Promise<void>} resolves once the draft is written or refused.
|
|
2305
2630
|
*/
|
|
2306
|
-
function sideChatRequest(service, sessionId, text, code) {
|
|
2631
|
+
function sideChatRequest(service, sessionId, text, code, path) {
|
|
2307
2632
|
if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text.trim() === "") {
|
|
2308
2633
|
return Promise.resolve();
|
|
2309
2634
|
}
|
|
@@ -2335,7 +2660,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2335
2660
|
rememberSideThread(sessionId, childId);
|
|
2336
2661
|
const threadId = await displayedThreadId(sessionId, childId);
|
|
2337
2662
|
rememberSideThread(sessionId, threadId);
|
|
2338
|
-
if (!(await prefillDraft(threadId, sideQuestion(text, code === true)))) {
|
|
2663
|
+
if (!(await prefillDraft(threadId, sideQuestion(text, code === true, path)))) {
|
|
2339
2664
|
console.warn("[ui-quote] sidechat draft not written:", threadId);
|
|
2340
2665
|
toast(translateFor(sessionId, "sideChatDraftFailed"));
|
|
2341
2666
|
}
|
|
@@ -2360,6 +2685,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2360
2685
|
if (sessionId === null || text === null) return;
|
|
2361
2686
|
const service = sideChatService();
|
|
2362
2687
|
const code = state.code;
|
|
2688
|
+
const path = state.path;
|
|
2363
2689
|
close();
|
|
2364
2690
|
// Kimi drops the transcript selection the moment an action is chosen.
|
|
2365
2691
|
const selection = document.getSelection === undefined ? null : document.getSelection();
|
|
@@ -2368,7 +2694,7 @@ code's own spacing — collapsed keeps one clipped line, expanded scrolls. */
|
|
|
2368
2694
|
toast(translateFor(sessionId, "sideChatFailed"));
|
|
2369
2695
|
return;
|
|
2370
2696
|
}
|
|
2371
|
-
sideChatRequest(service, sessionId, text, code);
|
|
2697
|
+
sideChatRequest(service, sessionId, text, code, path);
|
|
2372
2698
|
}
|
|
2373
2699
|
|
|
2374
2700
|
/* ------------------------------------------------------------------ *
|
|
@@ -2624,7 +2950,10 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
2624
2950
|
time: typeof event.time === "string" ? event.time : "",
|
|
2625
2951
|
quote: segment.quote,
|
|
2626
2952
|
comment: typeof segment.comment === "string" ? segment.comment : "",
|
|
2627
|
-
code: segment.code !== null && segment.code !== undefined
|
|
2953
|
+
code: segment.code !== null && segment.code !== undefined,
|
|
2954
|
+
// The fence's info string (a preview quote carries its file
|
|
2955
|
+
// there) — the export puts it back on its own fence line.
|
|
2956
|
+
fence: typeof segment.code === "string" ? segment.code : ""
|
|
2628
2957
|
});
|
|
2629
2958
|
}
|
|
2630
2959
|
}
|
|
@@ -2651,9 +2980,11 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
2651
2980
|
}
|
|
2652
2981
|
list.forEach((row, index) => {
|
|
2653
2982
|
lines.push("## " + (index + 1) + ". " + firstLineOf(row.quote, 80), "");
|
|
2654
|
-
// A code quote exports as a real fenced block, not a blockquote
|
|
2983
|
+
// A code quote exports as a real fenced block, not a blockquote; a
|
|
2984
|
+
// preview quote's file rides in the info string, as it did on the wire.
|
|
2655
2985
|
if (row.code === true) {
|
|
2656
|
-
|
|
2986
|
+
const info = typeof row.fence === "string" && row.fence !== "" ? " " + row.fence : "";
|
|
2987
|
+
lines.push("```" + info, String(row.quote), "```", "");
|
|
2657
2988
|
} else {
|
|
2658
2989
|
for (const line of String(row.quote).split("\n")) lines.push("> " + line);
|
|
2659
2990
|
lines.push("");
|
|
@@ -3384,6 +3715,8 @@ border-top:1px solid color-mix(in srgb,currentColor 18%,transparent)}
|
|
|
3384
3715
|
splitQuoteRuns,
|
|
3385
3716
|
chipRef,
|
|
3386
3717
|
chipLabel,
|
|
3718
|
+
sanitizePath,
|
|
3719
|
+
previewPathOf,
|
|
3387
3720
|
rememberChip,
|
|
3388
3721
|
normalizeLabel,
|
|
3389
3722
|
barTop,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-ui-quote",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording),
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Select text in the DSH transcript, a file preview or the terminal to quote it into the composer or into a side thread's composer, Kimi-Code style (comment / add to conversation / side chat), with Alt+Q/C/B, a jump back to the quoted source, a searchable quote list page that exports as Markdown, one-click back-flow of a side thread's conclusion, and revisions of a sent quote that mark the old capsule as superseded. The quoted text itself is editable in the comment box (trim a selection, correct a revision's wording), a selection from the file preview or the terminal travels to the model as a fenced code block carrying its file path, and a per-quote reply toggle has the model answer quote by quote, each 「引用N」 label in the reply becoming a badge linked back to the quote.",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "BangBang-03",
|
package/test/capsule.mjs
CHANGED
|
@@ -1047,6 +1047,253 @@ try {
|
|
|
1047
1047
|
codeCap.dispatch("mouseleave", {});
|
|
1048
1048
|
}
|
|
1049
1049
|
|
|
1050
|
+
/* ------------------------------------------------------------------ *
|
|
1051
|
+
* 文件路径 + 逐条回应 — the wire's two new transport-only forms
|
|
1052
|
+
* ------------------------------------------------------------------ */
|
|
1053
|
+
|
|
1054
|
+
{
|
|
1055
|
+
// 预览引用: the file rides in the fence's info string, where Markdown keeps
|
|
1056
|
+
// metadata the model reads but renderers never show as content.
|
|
1057
|
+
check(
|
|
1058
|
+
"a preview quote puts its file into the fence info string",
|
|
1059
|
+
testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts" }) === "> ❝ ``` src/app.ts\n> const x = 1;\n> ```",
|
|
1060
|
+
JSON.stringify(testing.quoteBlock("const x = 1;", "", true, { path: "src/app.ts" }))
|
|
1061
|
+
);
|
|
1062
|
+
const pathSegments = testing.splitQuoteRuns(testing.quoteBlock("const x = 1;", "看一下", true, { path: "src/app.ts" }));
|
|
1063
|
+
check(
|
|
1064
|
+
"the path parses back as the fence's info string",
|
|
1065
|
+
pathSegments?.length === 1 && pathSegments[0].code === "src/app.ts" && pathSegments[0].quote === "const x = 1;" && pathSegments[0].comment === "看一下",
|
|
1066
|
+
JSON.stringify(pathSegments)
|
|
1067
|
+
);
|
|
1068
|
+
check(
|
|
1069
|
+
"a path rides along when the content forces ~~~",
|
|
1070
|
+
testing.quoteBlock("```\nx = 1\n```", "", true, { path: "src/a.ts" }) === "> ❝ ~~~ src/a.ts\n> ```\n> x = 1\n> ```\n> ~~~",
|
|
1071
|
+
JSON.stringify(testing.quoteBlock("```\nx = 1\n```", "", true, { path: "src/a.ts" }))
|
|
1072
|
+
);
|
|
1073
|
+
check(
|
|
1074
|
+
"a path with whitespace is dropped, not smuggled",
|
|
1075
|
+
testing.quoteBlock("x", "", true, { path: "my file.ts" }) === "> ❝ ```\n> x\n> ```",
|
|
1076
|
+
JSON.stringify(testing.quoteBlock("x", "", true, { path: "my file.ts" }))
|
|
1077
|
+
);
|
|
1078
|
+
check(
|
|
1079
|
+
"sanitizePath takes the obvious rejects",
|
|
1080
|
+
testing.sanitizePath(" src/a.ts ") === "src/a.ts" &&
|
|
1081
|
+
testing.sanitizePath("a b") === null &&
|
|
1082
|
+
testing.sanitizePath("a`b") === null &&
|
|
1083
|
+
testing.sanitizePath("a~b") === null &&
|
|
1084
|
+
testing.sanitizePath("") === null,
|
|
1085
|
+
JSON.stringify([testing.sanitizePath(" src/a.ts "), testing.sanitizePath("a b"), testing.sanitizePath("a`b"), testing.sanitizePath("a~b"), testing.sanitizePath("")])
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
// 逐条回应: one instruction line at the block's tail, wire-only.
|
|
1089
|
+
const replyWire = testing.quoteBlock("正文", "看看这里", false, { reply: true });
|
|
1090
|
+
check(
|
|
1091
|
+
"逐条回应 appends one instruction line to the block",
|
|
1092
|
+
replyWire === "> ❝ 正文\n> ❞ 看看这里\n> ※ 指令:请把本条消息里的引用块按出现顺序记为「引用1」「引用2」…,回复时逐条对应回应,每条回应前标注「引用N」。",
|
|
1093
|
+
JSON.stringify(replyWire)
|
|
1094
|
+
);
|
|
1095
|
+
const replySegments = testing.splitQuoteRuns(replyWire);
|
|
1096
|
+
check(
|
|
1097
|
+
"the instruction never reaches the capsule",
|
|
1098
|
+
replySegments?.length === 1 && replySegments[0].comment === "看看这里" && JSON.stringify(replySegments).includes("指令") === false,
|
|
1099
|
+
JSON.stringify(replySegments)
|
|
1100
|
+
);
|
|
1101
|
+
const replyBare = testing.splitQuoteRuns(testing.quoteBlock("正文", "", false, { reply: true }));
|
|
1102
|
+
check(
|
|
1103
|
+
"a reply instruction without a comment leaves the comment half empty",
|
|
1104
|
+
replyBare?.length === 1 && replyBare[0].comment === "" && replyBare[0].quote === "正文",
|
|
1105
|
+
JSON.stringify(replyBare)
|
|
1106
|
+
);
|
|
1107
|
+
const replyCode = testing.splitQuoteRuns(testing.quoteBlock("x=1", "改这里", true, { path: "src/a.ts", reply: true }));
|
|
1108
|
+
check(
|
|
1109
|
+
"path and instruction travel in one code block",
|
|
1110
|
+
replyCode?.length === 1 && replyCode[0].code === "src/a.ts" && replyCode[0].comment === "改这里" && JSON.stringify(replyCode).includes("指令") === false,
|
|
1111
|
+
JSON.stringify(replyCode)
|
|
1112
|
+
);
|
|
1113
|
+
check(
|
|
1114
|
+
"the side thread's fence carries the file too",
|
|
1115
|
+
testing.sideQuestion("x=1", true, "src/app.ts") === "``` src/app.ts\nx=1\n```",
|
|
1116
|
+
JSON.stringify(testing.sideQuestion("x=1", true, "src/app.ts"))
|
|
1117
|
+
);
|
|
1118
|
+
check(
|
|
1119
|
+
"the side thread drops a whitespace path",
|
|
1120
|
+
testing.sideQuestion("x=1", true, "my file.ts") === "```\nx=1\n```",
|
|
1121
|
+
JSON.stringify(testing.sideQuestion("x=1", true, "my file.ts"))
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
// previewPathOf: walk the ancestors, skip values that cannot be a path.
|
|
1125
|
+
const outer = dom.document.createElement("div");
|
|
1126
|
+
outer.setAttribute("data-file-path", "src/app.ts");
|
|
1127
|
+
const mid = dom.document.createElement("div");
|
|
1128
|
+
mid.setAttribute("data-textpreview-body", "");
|
|
1129
|
+
mid.setAttribute("data-path", "bad path");
|
|
1130
|
+
outer.append(mid);
|
|
1131
|
+
check(
|
|
1132
|
+
"previewPathOf finds the ancestor's path and skips a whitespace one",
|
|
1133
|
+
testing.previewPathOf(mid) === "src/app.ts",
|
|
1134
|
+
JSON.stringify(testing.previewPathOf(mid))
|
|
1135
|
+
);
|
|
1136
|
+
check("previewPathOf misses cleanly on a bare host", testing.previewPathOf(dom.document.createElement("div")) === null);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/* ------------------------------------------------------------------ *
|
|
1140
|
+
* 回复徽章 — the model's 「引用N」 labels link back to the quotes
|
|
1141
|
+
* ------------------------------------------------------------------ */
|
|
1142
|
+
|
|
1143
|
+
{
|
|
1144
|
+
const badgeHost = dom.document.createElement("div");
|
|
1145
|
+
badgeHost.setAttribute("data-conversation-content", "");
|
|
1146
|
+
badgeHost.setAttribute("data-conversation-session", "session-badge");
|
|
1147
|
+
const userBubble = dom.document.createElement("div");
|
|
1148
|
+
const quoteRun = dom.document.createElement("span");
|
|
1149
|
+
quoteRun.className = "plainRun";
|
|
1150
|
+
const replyValue = "回答一「引用1」;回答二「引用2」;越界「引用9」;结尾";
|
|
1151
|
+
quoteRun.textContent = "问题" + testing.quoteBlock("第一处引用", "注一") + "\n\n中间的话\n\n" + testing.quoteBlock("第二处引用", "注二") + "\n\n";
|
|
1152
|
+
userBubble.append(quoteRun);
|
|
1153
|
+
badgeHost.append(userBubble);
|
|
1154
|
+
const replyParagraph = dom.document.createElement("p");
|
|
1155
|
+
const replyText = dom.document.createTextNode(replyValue);
|
|
1156
|
+
replyParagraph.append(replyText);
|
|
1157
|
+
badgeHost.append(replyParagraph);
|
|
1158
|
+
dom.document.body.append(badgeHost);
|
|
1159
|
+
dom.document.deliver();
|
|
1160
|
+
await wait(120);
|
|
1161
|
+
|
|
1162
|
+
const badges = () => replyParagraph.querySelectorAll(".dshq-ref");
|
|
1163
|
+
check("two quoted messages come back as two capsules", badgeHost.querySelectorAll(".dshq-cap").length === 2, badgeHost.querySelectorAll(".dshq-cap").length + " capsule(s)");
|
|
1164
|
+
check("a reply label becomes a badge", badges().length === 2, badges().length + " badge(s)");
|
|
1165
|
+
check(
|
|
1166
|
+
"the reply reads the same with badges in place",
|
|
1167
|
+
replyParagraph.textContent === replyValue,
|
|
1168
|
+
JSON.stringify(replyParagraph.textContent)
|
|
1169
|
+
);
|
|
1170
|
+
check(
|
|
1171
|
+
"only labels the quotes can answer are badged",
|
|
1172
|
+
JSON.stringify(Array.from(badges()).map((badge) => badge.textContent)) === JSON.stringify(["「引用1」", "「引用2」"]),
|
|
1173
|
+
JSON.stringify(Array.from(badges()).map((badge) => badge.textContent))
|
|
1174
|
+
);
|
|
1175
|
+
|
|
1176
|
+
// An assistant reply in a conversation with no quoted message stays plain.
|
|
1177
|
+
const bareHost = dom.document.createElement("div");
|
|
1178
|
+
bareHost.setAttribute("data-conversation-content", "");
|
|
1179
|
+
bareHost.setAttribute("data-conversation-session", "session-bare");
|
|
1180
|
+
const bareParagraph = dom.document.createElement("p");
|
|
1181
|
+
bareParagraph.textContent = "回应「引用1」但没有先前的引用";
|
|
1182
|
+
bareHost.append(bareParagraph);
|
|
1183
|
+
dom.document.body.append(bareHost);
|
|
1184
|
+
dom.document.deliver();
|
|
1185
|
+
await wait(120);
|
|
1186
|
+
check("a reply without a preceding quote is not badged", bareParagraph.querySelectorAll(".dshq-ref").length === 0, bareParagraph.querySelectorAll(".dshq-ref").length + " badge(s)");
|
|
1187
|
+
|
|
1188
|
+
// The badge reuses the hover card, quote and comment in full.
|
|
1189
|
+
const firstBadge = badges()[0];
|
|
1190
|
+
firstBadge.rect = { left: 80, top: 300, width: 64, height: 20 };
|
|
1191
|
+
firstBadge.dispatch("mouseenter", {});
|
|
1192
|
+
check(
|
|
1193
|
+
"hovering a badge opens the quote's card",
|
|
1194
|
+
pop()?.querySelector(".dshq-pop-quote")?.textContent === "第一处引用",
|
|
1195
|
+
JSON.stringify(pop()?.querySelector(".dshq-pop-quote")?.textContent)
|
|
1196
|
+
);
|
|
1197
|
+
check(
|
|
1198
|
+
"the badge's card shows the comment too",
|
|
1199
|
+
pop()?.querySelector(".dshq-pop-comment")?.textContent === "注一",
|
|
1200
|
+
JSON.stringify(pop()?.querySelector(".dshq-pop-comment")?.textContent)
|
|
1201
|
+
);
|
|
1202
|
+
firstBadge.dispatch("mouseleave", {});
|
|
1203
|
+
await wait(200);
|
|
1204
|
+
check("leaving a badge closes the card", pop()?.dataset.visible === "false", JSON.stringify(pop()?.dataset.visible));
|
|
1205
|
+
|
|
1206
|
+
// A quote the plugin never watched has no coordinates: clicking the badge
|
|
1207
|
+
// must not pretend to jump — no throw, no toast. (An earlier section left a
|
|
1208
|
+
// jump-failure toast mid-display; hide it first so only this click counts.)
|
|
1209
|
+
const leftoverToast = dom.document.getElementById("dsh-quote-toast");
|
|
1210
|
+
if (leftoverToast !== null) leftoverToast.dataset.visible = "false";
|
|
1211
|
+
check("the badge's quote was never traced", testing.sourceFor("第一处引用") === null);
|
|
1212
|
+
firstBadge.dispatch("click", { stopPropagation() {} });
|
|
1213
|
+
check(
|
|
1214
|
+
"a badge without coordinates jumps nowhere quietly",
|
|
1215
|
+
dom.document.getElementById("dsh-quote-toast")?.dataset.visible !== "true",
|
|
1216
|
+
JSON.stringify(dom.document.getElementById("dsh-quote-toast")?.dataset.visible)
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
dom.document.deliver();
|
|
1220
|
+
await wait(120);
|
|
1221
|
+
check("an unrelated flush does not duplicate badges", badges().length === 2, badges().length + " badge(s)");
|
|
1222
|
+
|
|
1223
|
+
// React rewrites the text node: the stale split goes and the fresh value
|
|
1224
|
+
// is re-read, so the badges always match what the reply says now.
|
|
1225
|
+
replyText.textContent = "改写后只提「引用2」一次";
|
|
1226
|
+
dom.document.deliver();
|
|
1227
|
+
await wait(120);
|
|
1228
|
+
check(
|
|
1229
|
+
"a rewritten reply re-badges from the fresh text",
|
|
1230
|
+
badges().length === 1 && badges()[0].textContent === "「引用2」" && replyParagraph.textContent === "改写后只提「引用2」一次",
|
|
1231
|
+
JSON.stringify([badges().length, replyParagraph.textContent])
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
// Uninstall puts the original text back and takes every badge with it.
|
|
1235
|
+
capsuleEffect.dispose();
|
|
1236
|
+
check(
|
|
1237
|
+
"uninstall restores the reply text and drops the badges",
|
|
1238
|
+
replyParagraph.querySelectorAll(".dshq-ref").length === 0 && replyParagraph.textContent === "改写后只提「引用2」一次",
|
|
1239
|
+
JSON.stringify(replyParagraph.textContent)
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/* ------------------------------------------------------------------ *
|
|
1244
|
+
* 逐条回应的开关 — the comment box checkbox drives the wire
|
|
1245
|
+
* ------------------------------------------------------------------ */
|
|
1246
|
+
|
|
1247
|
+
{
|
|
1248
|
+
const overlay = registered.find((entry) => entry.spec.name === "conversation.input.overlay");
|
|
1249
|
+
const toggleInsertions = [];
|
|
1250
|
+
shells.set("session-toggle", {
|
|
1251
|
+
insertReference(attachment) {
|
|
1252
|
+
toggleInsertions.push(attachment);
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
if (overlay !== undefined) overlay.Component({ sessionId: "session-toggle", inputActions: { captureInsertion: () => ({ draftRev: 1 }) } });
|
|
1257
|
+
|
|
1258
|
+
testing.startRevision("一处引用", "", "session-toggle", null, false);
|
|
1259
|
+
const bar = dom.document.getElementById("dsh-quote-bar");
|
|
1260
|
+
const replyCheck = bar?.querySelector(".dshq-reply-check");
|
|
1261
|
+
check("the comment box carries the per-quote toggle", replyCheck !== null && replyCheck !== undefined);
|
|
1262
|
+
check("the toggle starts off", replyCheck?.checked === false, String(replyCheck?.checked));
|
|
1263
|
+
const quoteBox = bar.querySelector(".dshq-quote-input");
|
|
1264
|
+
const commentBox = bar.querySelector(".dshq-comment-input");
|
|
1265
|
+
const confirmBtn = bar.querySelector('[data-action="confirm"]');
|
|
1266
|
+
quoteBox.value = "一处引用";
|
|
1267
|
+
quoteBox.dispatch("input", {});
|
|
1268
|
+
commentBox.value = "请照着改";
|
|
1269
|
+
commentBox.dispatch("input", {});
|
|
1270
|
+
bar.dispatch("click", { target: confirmBtn });
|
|
1271
|
+
check(
|
|
1272
|
+
"toggle off: the wire carries no instruction",
|
|
1273
|
+
toggleInsertions.length === 1 && String(toggleInsertions[0]?.ref ?? "").includes("指令") === false,
|
|
1274
|
+
JSON.stringify(String(toggleInsertions[0]?.ref ?? "").trim())
|
|
1275
|
+
);
|
|
1276
|
+
|
|
1277
|
+
testing.startRevision("一处引用", "", "session-toggle", null, false);
|
|
1278
|
+
const bar2 = dom.document.getElementById("dsh-quote-bar");
|
|
1279
|
+
const replyCheck2 = bar2.querySelector(".dshq-reply-check");
|
|
1280
|
+
replyCheck2.checked = true;
|
|
1281
|
+
replyCheck2.dispatch("change", {});
|
|
1282
|
+
const quoteBox2 = bar2.querySelector(".dshq-quote-input");
|
|
1283
|
+
const commentBox2 = bar2.querySelector(".dshq-comment-input");
|
|
1284
|
+
const confirmBtn2 = bar2.querySelector('[data-action="confirm"]');
|
|
1285
|
+
quoteBox2.value = "一处引用";
|
|
1286
|
+
quoteBox2.dispatch("input", {});
|
|
1287
|
+
commentBox2.value = "请照着改";
|
|
1288
|
+
commentBox2.dispatch("input", {});
|
|
1289
|
+
bar2.dispatch("click", { target: confirmBtn2 });
|
|
1290
|
+
check(
|
|
1291
|
+
"toggle on: the wire carries the per-quote instruction",
|
|
1292
|
+
toggleInsertions.length === 2 && String(toggleInsertions[1]?.ref ?? "").includes("※ 指令:"),
|
|
1293
|
+
JSON.stringify(String(toggleInsertions[1]?.ref ?? "").trim().slice(-40))
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1050
1297
|
console.log(
|
|
1051
1298
|
failures.length === 0
|
|
1052
1299
|
? "\ndsh-client-ui-quote capsule: " + passed + " checks passed"
|
package/test/dom.mjs
CHANGED
|
@@ -35,6 +35,12 @@ class ShimNode {
|
|
|
35
35
|
return this.childNodes.length === 0 ? null : this.childNodes[this.childNodes.length - 1];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
get nextSibling() {
|
|
39
|
+
if (this.parentNode === null) return null;
|
|
40
|
+
const siblings = this.parentNode.childNodes;
|
|
41
|
+
return siblings[siblings.indexOf(this) + 1] ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
append(child) {
|
|
39
45
|
this.insertBefore(child, null);
|
|
40
46
|
}
|
package/test/panel.mjs
CHANGED
|
@@ -240,6 +240,28 @@ check(
|
|
|
240
240
|
testing.quoteRowsFromEntries(entries).every((row) => row.code === false),
|
|
241
241
|
JSON.stringify(testing.quoteRowsFromEntries(entries).map((row) => row.code))
|
|
242
242
|
);
|
|
243
|
+
const pathEntries = [
|
|
244
|
+
{
|
|
245
|
+
type: "event",
|
|
246
|
+
event: {
|
|
247
|
+
type: "user/message",
|
|
248
|
+
seq: 5,
|
|
249
|
+
time: "",
|
|
250
|
+
data: { id: "e6", content: [{ type: "text", text: "> ❝ ``` src/app.ts\n> const x = 1;\n> ```\n> ❞ 看一下" }] }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
];
|
|
254
|
+
const pathRows = testing.quoteRowsFromEntries(pathEntries);
|
|
255
|
+
check(
|
|
256
|
+
"a preview quote's file rides in the row",
|
|
257
|
+
pathRows.length === 1 && pathRows[0].code === true && pathRows[0].fence === "src/app.ts" && pathRows[0].comment === "看一下",
|
|
258
|
+
JSON.stringify(pathRows[0])
|
|
259
|
+
);
|
|
260
|
+
check(
|
|
261
|
+
"the export puts the file on its fence line",
|
|
262
|
+
testing.quoteListMarkdown(pathRows, "引用清单").includes("``` src/app.ts\nconst x = 1;\n```"),
|
|
263
|
+
JSON.stringify(testing.quoteListMarkdown(pathRows, "引用清单"))
|
|
264
|
+
);
|
|
243
265
|
|
|
244
266
|
// ---- reading a session that is not on screen -------------------------------
|
|
245
267
|
sessionFixture = {
|