dsh-quote-annotate-pro 0.2.12 → 0.3.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/CHANGELOG.md +26 -0
- package/README.en.md +7 -4
- package/README.md +18 -9
- package/lib/client.js +266 -51
- package/package.json +3 -2
- package/tools/test-client.mjs +123 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
本项目的所有重要变更都记在这里。版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## 0.3.1 — 引用管线懒重试:页面刷新后不再退化成纯文本
|
|
6
|
+
|
|
7
|
+
- **根因**:页面刷新时插件 `apply` 早于宿主 `inputTriggers` 服务就绪,`ctx.get('inputTriggers')` 拿到空值后
|
|
8
|
+
`pipelineAvailable` 被永久置为 `false` → 之后每次批注插入都走「纯文本回退」,输入框里出现 `【批注 1 …】` 裸文本块
|
|
9
|
+
(实测证据:localStorage 草稿记录为 `{"draft":{"text":"【批注 1 · …】\n钟)\n\n批注:1…","references":[]}}`,即没有 chip)
|
|
10
|
+
- **修复**:引用源注册改为幂等懒加载 `ensureQuoteSource()`,服务未就绪时按 500ms × 20 次(≈10s)自动重试,
|
|
11
|
+
每次插入前也会先试一次;重试成功即恢复正常 chip 插入,无需用户刷新页面或重启
|
|
12
|
+
- **可观测性**:`insert:draft` 诊断补充 `draftLen` / `pipeline`;`insert:fallback-plaintext` 补充 `draftLen` / `bodyLen` / `pipeline`;
|
|
13
|
+
编辑器提示按状态区分(`null` = 正在初始化引用管线,`false` = 回退为纯文本)
|
|
14
|
+
- 自检照旧:`npm test`(8 组纯函数断言)、`node --check`、`check-manifest` 全绿
|
|
15
|
+
|
|
16
|
+
## 0.3.0 — 编号批注 + 回复逐条对照
|
|
17
|
+
|
|
18
|
+
- **批注自动编号**:同一条消息里每次插入批注都会编号(`批注 1`、`批注 2`…,删掉中间某条不会重号,见 `nextAnnotationNo()`);
|
|
19
|
+
输入框里 chip 变成 `批注1`,发送后的引用块首行变成 `【批注 2 · 第5轮 · DSH 回复】` —— 模型看到的是"第几处批注 + 哪一轮 + 谁说的"
|
|
20
|
+
- **评语写进正文**:评语不再作为普通文本追加在 chip 之后,而是作为 `批注:…` 跟在原文后面(空行分隔)——
|
|
21
|
+
发送出去的整条消息自带"原文 + 评语",不再依赖草稿里散落的文本
|
|
22
|
+
- **回复逐条对照**:第一条批注的位置行里内嵌「请按『Annotation 1:』『Annotation 2:』…格式逐条回应我的批注」的指令
|
|
23
|
+
(位置行在界面上被隐藏,模型仍然看得到);模型回复里的 `Annotation N:` 会被渲染成 `批注 N` 芯片:
|
|
24
|
+
**悬浮显示该条批注的原文与评语,点击跳回原文并高亮**
|
|
25
|
+
- **完全客户端实现**:编号、评语、回复芯片的数据都从消息 DOM 里自解析(引用块带 `data-dshq-no` / `-quote` / `-comment`),
|
|
26
|
+
不新增宿主半区代码、不改消息文本以外的任何语义;装饰逻辑仍遵守「不破坏用户选区 / 幂等 / 观察器内同步执行」三条约束
|
|
27
|
+
- 兼容旧消息:0.2.x 发出去的 `【引用 · …】` 仍按原样渲染成紧凑引用块(只是没有编号与回复芯片)
|
|
28
|
+
- 新增 `tools/test-client.mjs` 纯函数自检(编号 / 位置行解析 / 输入框摘要等 8 组断言,无需浏览器与网络):
|
|
29
|
+
`npm test` 与 CI 都会跑;`lib/client.js` 末尾导出 `__test`(宿主只读 `name` / `inject` / `apply`,不影响加载)
|
|
30
|
+
|
|
5
31
|
## 0.2.12 — 定位改造:从"功能罗列"改成"设计取舍"(README 首屏)
|
|
6
32
|
|
|
7
33
|
- 首屏不再从"fork 了什么"讲起,改成一句话主张:**划词 → 批注 → 引用;发送之后引用块仍然看得见,点一下回到原文**
|
package/README.en.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Select → annotate → quote. After you send, the quote is still visible — and one click takes you back to the source.**
|
|
8
8
|
|
|
9
|
-
Selected text becomes an anchored quote block: in the composer and in the sent message it renders as a **compact quoted snippet** (max 3 lines, the turn/role line hidden), and clicking it jumps back to the source message and highlights it. What the model receives still
|
|
9
|
+
Selected text becomes an anchored quote block: in the composer and in the sent message it renders as a **compact quoted snippet** (max 3 lines, the turn/role line hidden), and clicking it jumps back to the source message and highlights it. What the model receives still carries **which annotation it is, which turn and whose message** (`【批注 2 · 第5轮 · DSH 回复】`) plus an instruction to answer with `Annotation 1:`, `Annotation 2:` … — and **each `Annotation N:` in the reply becomes a `批注 N` chip: hover to see the quoted text and your comment, click to jump back to the source.**
|
|
10
10
|
|
|
11
11
|
> Listed in the in-DSH market (`dshmarket`), [DSH Market](https://dsh.market/?q=zhao-guang-ai%2Fdsh-quote-annotate-pro), [dsh-plugin.org](https://dsh-plugin.org/zh/plugins/zhao-guang-ai/dsh-quote-annotate-pro), dsh.pub, dsh-xray, HackSing/dsh-plugins, and [awesome-deepseek-harness](https://github.com/0xsline/awesome-deepseek-harness).
|
|
12
12
|
|
|
@@ -35,10 +35,12 @@ In one sentence: **a quote is part of the conversation and should not be hidden.
|
|
|
35
35
|
| Capability | Detail |
|
|
36
36
|
| --- | --- |
|
|
37
37
|
| Select & annotate | Select text in the transcript → a bubble appears above the selection → write a comment (optional) → `Enter` |
|
|
38
|
-
|
|
|
38
|
+
| **Automatic numbering** | Multiple annotations in one message are numbered (`批注 1`, `批注 2`, …; deleting one never reuses a number); the chip reads `批注1`, the model sees `【批注 N · 第5轮 · 角色】` |
|
|
39
|
+
| Quote chip | An atomic `批注1` chip (short label) lands in the composer; it expands to the full quote block on send |
|
|
39
40
|
| **Quote block (in the composer)** | After inserting a quote the composer shows only the **quoted text** (the turn/role line is hidden); click it to jump back to the source and highlight it — no visible "jump" affordance |
|
|
40
|
-
| **Compact quotes in the transcript** | A sent message shows only a **compact quote block** (up to 3 lines)
|
|
41
|
-
| **
|
|
41
|
+
| **Compact quotes in the transcript** | A sent message shows only a **compact quote block** (up to 3 lines) followed by `批注:…`. The message text still starts with the position line — hidden in the UI, so the model keeps the position context |
|
|
42
|
+
| **Per-annotation replies** | The protocol instruction is embedded in the first position line (hidden in the UI); `Annotation N:` in the model's reply renders as a `批注 N` chip — hover for the quoted text + comment, click to jump back to the source |
|
|
43
|
+
| **Turn-accurate context** | The quote block starts with `【批注 2 · 第3轮 · 你的消息】` followed by the quoted text and your comment, so the model knows exactly which annotation, which turn and whose message |
|
|
42
44
|
| Turn-level quoting | Every finished turn gets `引用提问` / `引用回答` buttons that quote the whole question or answer |
|
|
43
45
|
| Anchors survive reload | The `ref → anchor` map is persisted in `localStorage` (per session, 60 entries max), so quotes still jump after a page refresh |
|
|
44
46
|
|
|
@@ -102,6 +104,7 @@ Official surfaces used: `slots` (`shell.overlay`, `conversation.chat.turnTail`,
|
|
|
102
104
|
|
|
103
105
|
```sh
|
|
104
106
|
npm run check # manifest self-check (package.json / patch / bundle registration id)
|
|
107
|
+
npm test # pure-function self-check for the client bundle (numbering, block parsing, snippets — no browser/network)
|
|
105
108
|
npm run diag # local diagnostics sink (http://127.0.0.1:47821)
|
|
106
109
|
```
|
|
107
110
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**划词 → 批注 → 引用;发送之后,引用块仍然看得见,点一下就能回到原文。**
|
|
8
8
|
|
|
9
|
-
选中的文字变成带锚点的引用块:输入框里、已发送的消息里都只显示**紧凑的原文片段**(最多 3
|
|
9
|
+
选中的文字变成带锚点的引用块:输入框里、已发送的消息里都只显示**紧凑的原文片段**(最多 3 行,轮次/角色那一行被隐藏),点它跳回原文并高亮;而发给模型的文本仍标明**这是第几处批注 + 哪一轮 + 谁说的**(`【批注 2 · 第5轮 · DSH 回复】`),并要求模型按 `Annotation 1:`、`Annotation 2:` 逐条回应 —— **回复里的每个 `Annotation N:` 都会渲染成 `批注 N` 芯片,悬浮看原文与评语,点击跳回原文。**
|
|
10
10
|
|
|
11
11
|
> 已收录:in-DSH 市场(`dshmarket`)、[DSH Market](https://dsh.market/?q=zhao-guang-ai%2Fdsh-quote-annotate-pro)、[dsh-plugin.org](https://dsh-plugin.org/zh/plugins/zhao-guang-ai/dsh-quote-annotate-pro)、dsh.pub、dsh-xray、HackSing/dsh-plugins、[awesome-deepseek-harness](https://github.com/0xsline/awesome-deepseek-harness)。
|
|
12
12
|
|
|
@@ -27,18 +27,22 @@
|
|
|
27
27
|
## 三步上手
|
|
28
28
|
|
|
29
29
|
1. 在任意消息里**选中一段文字** → 选区上方浮出小气泡 → 点「批注」→ 写评语(可留空)→ `Enter`;
|
|
30
|
-
2
|
|
31
|
-
|
|
30
|
+
同一条消息里可以连续批注多处,插件会自动编号(`批注 1`、`批注 2`…);
|
|
31
|
+
2. 输入框里出现带编号的引用;**输入框上沿**同时出现一条引用块,点它可跳回原文;
|
|
32
|
+
3. 发送后气泡里仍是紧凑引用块,点它随时回到原文并高亮;模型回复里的 `Annotation 1:`、`Annotation 2:`
|
|
33
|
+
会变成 `批注 N` 芯片 —— **悬浮看这条批注的原文与评语,点击跳回原文**。
|
|
32
34
|
|
|
33
35
|
## 功能
|
|
34
36
|
|
|
35
37
|
| 能力 | 说明 |
|
|
36
38
|
|---|---|
|
|
37
39
|
| 划词批注 | 会话里选中任意文字 → 选区上方浮出气泡(预览 + 「批注」)→ 编辑框里写评语(可留空)→ `Enter` 插入 |
|
|
38
|
-
|
|
|
40
|
+
| **自动编号** | 同一条消息里多处批注依次编号(删掉中间某条不会重号);chip 显示 `批注1`,模型侧显示 `【批注 N · 第5轮 · 角色】` |
|
|
41
|
+
| 引用 chip | 输入框里出现 `批注1` 的原子 chip(短标签);发送时展开为完整引用块 |
|
|
39
42
|
| **引用块(输入框内)** | 插入引用后输入框里只显示**原文片段**(位置行隐藏);**点一下即跳回原文并高亮** —— 不做任何可见的"跳转"提示 |
|
|
40
|
-
| **对话页里的紧凑引用** | 已发送消息里的引用同样只显示**紧凑原文块**(最多 3
|
|
41
|
-
|
|
|
43
|
+
| **对话页里的紧凑引用** | 已发送消息里的引用同样只显示**紧凑原文块**(最多 3 行),评语以 `批注:…` 跟在其后。**消息文本仍以位置行开头** —— 界面隐藏、模型照旧知道出处 |
|
|
44
|
+
| **回复逐条对照** | 协议指令内嵌在第一条批注的位置行里(界面隐藏);模型回复的 `Annotation N:` 渲染成 `批注 N` 芯片,悬浮显示原文 + 评语,点击跳回原文 |
|
|
45
|
+
| **模型侧精确定位** | 引用块首行是 `【批注 2 · 第5轮 · 你的消息】`,后接原文与评语,让模型明确知道**哪一处、哪一轮、谁说的** |
|
|
42
46
|
| 回合级引用 | 每条已完成回合尾部有「引用提问 / 引用回答」,整段引用该轮 |
|
|
43
47
|
| 锚点持久化 | `ref → 锚点` 映射写入 `localStorage`(按会话,最多 60 条),**刷新页面后引用块仍能跳回原文** |
|
|
44
48
|
| 中英自适应 | 按钮文案跟随界面语言(沿用上游) |
|
|
@@ -82,11 +86,15 @@ emit('slash/input-insert-reference', { reference, span }) ← 官方会话
|
|
|
82
86
|
│ 发送
|
|
83
87
|
▼
|
|
84
88
|
本插件 codec.serialize(ref) → 完整引用块
|
|
85
|
-
|
|
89
|
+
【批注 1 · 第5轮 · DSH 回复(请按「Annotation 1:」「Annotation 2:」…格式逐条回应)】
|
|
86
90
|
原文……
|
|
87
|
-
|
|
91
|
+
|
|
92
|
+
批注:你的评语
|
|
93
|
+
▼
|
|
94
|
+
模型收到"第几处批注 + 哪一轮 + 谁说的 + 原文 + 评语"
|
|
88
95
|
▼
|
|
89
|
-
|
|
96
|
+
模型回复里出现 Annotation 1:… → 插件渲染成「批注 1」芯片
|
|
97
|
+
(悬浮看原文+评语,点击跳回原文)
|
|
90
98
|
|
|
91
99
|
界面侧:插件在渲染时把位置行与引用正文包成紧凑块(隐藏位置行、只留原文),
|
|
92
100
|
点击引用块 → 按锚点 scrollIntoView + 高亮(无可见提示)。
|
|
@@ -137,6 +145,7 @@ tools/check-manifest.mjs 清单一致性自检(CI 同款)
|
|
|
137
145
|
|
|
138
146
|
```sh
|
|
139
147
|
npm run check # 校验 package.json / patch / bundle 注册 id 是否一致
|
|
148
|
+
npm test # 客户端纯函数自检(编号 / 位置行解析 / 输入框摘要,无需浏览器与网络)
|
|
140
149
|
npm run diag # 起诊断接收端(默认 http://127.0.0.1:47821)
|
|
141
150
|
```
|
|
142
151
|
|
package/lib/client.js
CHANGED
|
@@ -83,10 +83,72 @@ window.__ModuleLoader__.load({
|
|
|
83
83
|
if (role !== '') parts.push(role)
|
|
84
84
|
return parts.join(' · ')
|
|
85
85
|
}
|
|
86
|
-
/** 引用块首行:模型据此确定"
|
|
87
|
-
function buildQuoteHeader(item) {
|
|
86
|
+
/** 引用块首行:模型据此确定"这是第几处批注、哪一轮、谁说的",多轮对话里不再靠猜。 */
|
|
87
|
+
function buildQuoteHeader(item, no) {
|
|
88
88
|
const label = anchorLabelOf(item)
|
|
89
|
-
|
|
89
|
+
const numbered = typeof no === 'number' && Number.isFinite(no) && no > 0
|
|
90
|
+
const parts = [numbered ? '批注 ' + no : '引用']
|
|
91
|
+
if (label !== '') parts.push(label)
|
|
92
|
+
// 老格式(无编号且无位置信息)保持空串,避免出现只有「【引用】」的块
|
|
93
|
+
if (parts.length === 1 && parts[0] === '引用') return ''
|
|
94
|
+
return '【' + parts.join(' · ') + '】'
|
|
95
|
+
}
|
|
96
|
+
/** 批注编号:同一条消息内从 1 开始;删掉中间某条不会重号(取现有最大号 + 1)。 */
|
|
97
|
+
function nextAnnotationNo(draft) {
|
|
98
|
+
let max = 0
|
|
99
|
+
try {
|
|
100
|
+
const text = String(draft || '')
|
|
101
|
+
const re = /【批注\s*(\d+)/g
|
|
102
|
+
let m
|
|
103
|
+
while ((m = re.exec(text)) !== null) {
|
|
104
|
+
const n = Number(m[1])
|
|
105
|
+
if (Number.isFinite(n) && n > max) max = n
|
|
106
|
+
}
|
|
107
|
+
} catch (e) { /* ignore */ }
|
|
108
|
+
return max + 1
|
|
109
|
+
}
|
|
110
|
+
/** 位置行(兼容旧格式「【引用 · …】」与新格式「【批注 N · …】」;中间分隔符可能是 " · ") */
|
|
111
|
+
const QUOTE_HEADER_RE = /【(?:引用|批注)[^\n】]*】/
|
|
112
|
+
/** 从位置行里取批注编号(旧格式「【引用 · …】」没有编号,返回 null) */
|
|
113
|
+
function annotationNoOf(headerText) {
|
|
114
|
+
const m = /^【批注\s*(\d+)/.exec(String(headerText || ''))
|
|
115
|
+
if (!m) return null
|
|
116
|
+
const n = Number(m[1])
|
|
117
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 把「位置行 + 原文(空行)批注:…」拆成结构化块 —— 纯函数,单测与渲染共用:
|
|
121
|
+
* { before, header, no, quote, comment, rest }
|
|
122
|
+
* 解析不了(没有位置行/没有原文)返回 null。
|
|
123
|
+
*/
|
|
124
|
+
function parseQuoteBlock(raw) {
|
|
125
|
+
const text = String(raw || '')
|
|
126
|
+
const m = QUOTE_HEADER_RE.exec(text)
|
|
127
|
+
if (!m) return null
|
|
128
|
+
const after = text.slice(m.index + m[0].length)
|
|
129
|
+
const nl = after.indexOf('\n')
|
|
130
|
+
if (nl < 0) return null
|
|
131
|
+
const lines = after.slice(nl + 1).split('\n')
|
|
132
|
+
// 引用正文 = 位置行之后、直到空行之前的连续非空行(兼容旧格式的 "> " 前缀)
|
|
133
|
+
const quote = []
|
|
134
|
+
let i = 0
|
|
135
|
+
for (; i < lines.length; i++) {
|
|
136
|
+
if (lines[i].trim() === '') break
|
|
137
|
+
quote.push(lines[i].replace(/^>\s?/, ''))
|
|
138
|
+
}
|
|
139
|
+
if (quote.length === 0) return null
|
|
140
|
+
const rest = lines.slice(i).join('\n')
|
|
141
|
+
let comment = ''
|
|
142
|
+
const cm = /(?:^|\n)\s*批注[::]\s*([^\n]*)/.exec(rest)
|
|
143
|
+
if (cm) comment = cm[1].trim()
|
|
144
|
+
return {
|
|
145
|
+
before: text.slice(0, m.index),
|
|
146
|
+
header: m[0],
|
|
147
|
+
no: annotationNoOf(m[0]),
|
|
148
|
+
quote: quote.join(' '),
|
|
149
|
+
comment,
|
|
150
|
+
rest,
|
|
151
|
+
}
|
|
90
152
|
}
|
|
91
153
|
|
|
92
154
|
// ============ 锚点持久化:刷新后 chip 仍能跳回原文 ============
|
|
@@ -153,6 +215,8 @@ window.__ModuleLoader__.load({
|
|
|
153
215
|
let currentSessionId = null
|
|
154
216
|
let refSeq = 0
|
|
155
217
|
let pipelineAvailable = null // null=未知 false=不可用 true=可用
|
|
218
|
+
// 引用 source 的注册函数(懒加载 + 重试):服务晚就绪、HMR 重载后仍能恢复 chip 插入
|
|
219
|
+
let ensureQuoteSource = null
|
|
156
220
|
// ref -> { sessionId, anchorKey, body, label } 用于点击 chip 跳回原文 / 悬停显示原文
|
|
157
221
|
const refTargets = new Map()
|
|
158
222
|
// ref -> { body, clipboardText } 提交时经 codec 序列化
|
|
@@ -441,7 +505,7 @@ window.__ModuleLoader__.load({
|
|
|
441
505
|
if (!anchorKey && info.body) {
|
|
442
506
|
const probe = String(info.body)
|
|
443
507
|
.split('\n')
|
|
444
|
-
.map((line) => line.replace(
|
|
508
|
+
.map((line) => line.replace(/^【(?:引用|批注)[^\n]*】\s*/, '').replace(/^> ?/, ''))
|
|
445
509
|
.join('\n')
|
|
446
510
|
.trim()
|
|
447
511
|
.slice(0, 24)
|
|
@@ -467,9 +531,13 @@ window.__ModuleLoader__.load({
|
|
|
467
531
|
function refByLabel(label) {
|
|
468
532
|
if (typeof label !== 'string' || label === '') return null
|
|
469
533
|
const wanted = label.trim()
|
|
534
|
+
// 同名的 chip label 可能出现多次(每条消息的编号都从 1 开始)——
|
|
535
|
+
// refTargets 按插入顺序排列,取最后一个命中项(最近插入的那条)
|
|
536
|
+
let found = null
|
|
470
537
|
for (const [ref, info] of refTargets) {
|
|
471
|
-
if (info && typeof info.label === 'string' && info.label.trim() === wanted)
|
|
538
|
+
if (info && typeof info.label === 'string' && info.label.trim() === wanted) found = ref
|
|
472
539
|
}
|
|
540
|
+
if (found !== null) return found
|
|
473
541
|
// 退化:只按 `引用#N` 前缀匹配(label 可能被截断显示)
|
|
474
542
|
const short = wanted.split('·')[0].trim()
|
|
475
543
|
if (short.startsWith('引用#')) {
|
|
@@ -799,9 +867,6 @@ window.__ModuleLoader__.load({
|
|
|
799
867
|
if (!open || !item) return
|
|
800
868
|
try {
|
|
801
869
|
const c = (comment || '').trim()
|
|
802
|
-
const header = buildQuoteHeader(item)
|
|
803
|
-
// 引用正文不再加 "> " 前缀:输入框与消息里都由插件渲染成紧凑块(前缀会在编辑器里露出一截符号)
|
|
804
|
-
const quoteBody = header === '' ? item.text : header + '\n' + item.text
|
|
805
870
|
const conversation = rootCtx ? rootCtx.get('conversation') : undefined
|
|
806
871
|
const sessions = rootCtx ? rootCtx.get('sessions') : undefined
|
|
807
872
|
if (!conversation || !conversation.input || !sessions) {
|
|
@@ -822,11 +887,21 @@ window.__ModuleLoader__.load({
|
|
|
822
887
|
}
|
|
823
888
|
const input = conversation.input.for(binding.ctx)
|
|
824
889
|
const st = input.state.getSnapshot()
|
|
890
|
+
// 编号:同一条消息内从 1 开始(删掉中间某条也不会重号)
|
|
891
|
+
const no = nextAnnotationNo(st.draft)
|
|
892
|
+
let header = buildQuoteHeader(item, no)
|
|
893
|
+
// 首条批注的头部内嵌"回复格式"指令:头部在界面上被隐藏,但模型看得到
|
|
894
|
+
if (no === 1 && header !== '') {
|
|
895
|
+
header = header.replace(/】$/, '(请按「Annotation 1:」「Annotation 2:」…格式逐条回应我的批注)】')
|
|
896
|
+
}
|
|
897
|
+
// 引用正文不加 "> " 前缀(前缀会在编辑器里露出一截符号);
|
|
898
|
+
// 评语写进正文(空行 + 「批注:」前缀):发送后整条消息自带"原文 + 评语",回复侧才能对上号
|
|
899
|
+
const commentLine = c === '' ? '' : '\n\n批注:' + c
|
|
900
|
+
const quoteBody = header === '' ? item.text + commentLine : header + '\n' + item.text + commentLine
|
|
825
901
|
const base = (st.draft || '').replace(/[\s\u00A0]+$/, '')
|
|
826
|
-
const commentTail = c === '' ? '' : '\n\n' + c
|
|
827
902
|
const ref = 'q' + REF_RUN + (++refSeq)
|
|
828
|
-
// 输入框里的 chip
|
|
829
|
-
const chipLabel = '
|
|
903
|
+
// 输入框里的 chip 文案只要短、可区分即可(轮次/角色/评语写在发送后的引用块里,不在输入框展示)
|
|
904
|
+
const chipLabel = '批注' + no
|
|
830
905
|
// 输入框与消息里显示的就是这段文本(实测:宿主消息 = 草稿文本,WYSIWYG);
|
|
831
906
|
// 因此位置行(【引用 · 第N轮 · 角色】)保留在文本里给模型用,界面上由插件渲染时隐藏。
|
|
832
907
|
refTargets.set(ref, { sessionId, anchorKey: item.anchorKey || null, body: quoteBody, label: chipLabel })
|
|
@@ -837,6 +912,8 @@ window.__ModuleLoader__.load({
|
|
|
837
912
|
refCodecs.delete(oldest)
|
|
838
913
|
refTargets.delete(oldest)
|
|
839
914
|
}
|
|
915
|
+
// 懒重试:若 apply 时 inputTriggers 还没就绪,插入前再试一次(常见于"刚启动就点批注")
|
|
916
|
+
try { if (typeof ensureQuoteSource === 'function') ensureQuoteSource() } catch (e) { /* ignore */ }
|
|
840
917
|
// 事件派发目标:优先用 sessions.scope()(社区插件在本版本已验证的路径),退回 binding.ctx
|
|
841
918
|
let emitCtx = binding.ctx
|
|
842
919
|
try {
|
|
@@ -853,18 +930,16 @@ window.__ModuleLoader__.load({
|
|
|
853
930
|
clipboardText: quoteBody,
|
|
854
931
|
})
|
|
855
932
|
if (applied) {
|
|
856
|
-
// 把评论追加到 chip 之后
|
|
857
|
-
if (commentTail !== '') {
|
|
858
|
-
const st3 = input.state.getSnapshot()
|
|
859
|
-
input.setDraft(st3.draft.replace(/[ \t]+$/, '') + commentTail)
|
|
860
|
-
}
|
|
861
933
|
try {
|
|
862
934
|
const snap2 = input.state.getSnapshot()
|
|
863
935
|
const occ2 = (snap2 && snap2.occurrences) || []
|
|
936
|
+
const draft2 = String((snap2 && snap2.draft) || '')
|
|
864
937
|
report('insert:draft', {
|
|
865
|
-
draft:
|
|
938
|
+
draft: draft2.replace(/\s+/g, ' ').slice(0, 400),
|
|
939
|
+
draftLen: draft2.length,
|
|
866
940
|
occ: occ2.length,
|
|
867
941
|
occLabels: occ2.map((o) => (o && o.label) || '').join('|').slice(0, 80),
|
|
942
|
+
pipeline: pipelineAvailable,
|
|
868
943
|
})
|
|
869
944
|
} catch (e) { /* ignore */ }
|
|
870
945
|
report('insert:chip', { ref, anchorKey: item.anchorKey || null, header, chars: item.text.length })
|
|
@@ -881,8 +956,10 @@ window.__ModuleLoader__.load({
|
|
|
881
956
|
// 未生效:回退纯文本(内容不丢)
|
|
882
957
|
refTargets.delete(ref)
|
|
883
958
|
refCodecs.delete(ref)
|
|
884
|
-
input.setDraft(base === '' ? quoteBody
|
|
885
|
-
|
|
959
|
+
input.setDraft(base === '' ? quoteBody : base + '\n\n' + quoteBody)
|
|
960
|
+
let draftLen = 0
|
|
961
|
+
try { draftLen = String((input.state.getSnapshot() || {}).draft || '').length } catch (e) { /* ignore */ }
|
|
962
|
+
report('insert:fallback-plaintext', { ref, header, draftLen, bodyLen: quoteBody.length, pipeline: pipelineAvailable })
|
|
886
963
|
setStatus({ ok: false, text: 'chip 插入未生效,已回退为纯文本引用' })
|
|
887
964
|
editorStore.close()
|
|
888
965
|
} catch (e) {
|
|
@@ -957,7 +1034,7 @@ window.__ModuleLoader__.load({
|
|
|
957
1034
|
const snippetOf = (body) => {
|
|
958
1035
|
const raw = String(body || '')
|
|
959
1036
|
.split('\n')
|
|
960
|
-
.filter((line) => line.trim() !== '' &&
|
|
1037
|
+
.filter((line) => line.trim() !== '' && !/^【(?:引用|批注)/.test(line.trim()) && !/^批注[::]/.test(line.trim()))
|
|
961
1038
|
.map((line) => line.replace(/^>\s?/, ''))
|
|
962
1039
|
.join(' ')
|
|
963
1040
|
.replace(/\s+/g, ' ')
|
|
@@ -1081,6 +1158,9 @@ window.__ModuleLoader__.load({
|
|
|
1081
1158
|
.dshq-cmp-quote{background:rgba(127,127,127,.10);border-radius:8px;padding:2px 10px;margin:2px 0}
|
|
1082
1159
|
.dshq-cmp-quote-first{margin-top:4px}
|
|
1083
1160
|
.dshq-cmp-quote-last{margin-bottom:4px}
|
|
1161
|
+
/* 回复里的「Annotation N」芯片:悬浮看原文+评语,点击跳回原文 */
|
|
1162
|
+
.dshq-anno-chip{display:inline-flex;align-items:center;margin:0 2px;padding:0 7px;border-radius:999px;background:rgba(127,127,127,.12);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:18px;cursor:pointer;vertical-align:baseline;user-select:none;white-space:nowrap}
|
|
1163
|
+
.dshq-anno-chip:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}
|
|
1084
1164
|
`
|
|
1085
1165
|
|
|
1086
1166
|
// ============ 插件主体 ============
|
|
@@ -1112,9 +1192,21 @@ window.__ModuleLoader__.load({
|
|
|
1112
1192
|
available: pipelineAvailable,
|
|
1113
1193
|
hasInsertReference: !!(inputTriggers && typeof inputTriggers.insertReference === 'function'),
|
|
1114
1194
|
})
|
|
1115
|
-
|
|
1195
|
+
// 注册机:幂等、可重试。宿主服务可能晚于插件就绪(或 HMR 重载后旧注册仍在),
|
|
1196
|
+
// 一次性判定会让整页退化成"纯文本回退",因此这里允许后续再次调用。
|
|
1197
|
+
let quoteSourceRegistered = false
|
|
1198
|
+
ensureQuoteSource = () => {
|
|
1199
|
+
if (quoteSourceRegistered) return true
|
|
1200
|
+
let triggers = inputTriggers
|
|
1201
|
+
if (!triggers) {
|
|
1202
|
+
try { triggers = ctx.get('inputTriggers') } catch (e) { triggers = undefined }
|
|
1203
|
+
}
|
|
1204
|
+
if (!triggers || typeof triggers.registerSource !== 'function') {
|
|
1205
|
+
pipelineAvailable = false
|
|
1206
|
+
return false
|
|
1207
|
+
}
|
|
1116
1208
|
try {
|
|
1117
|
-
ctx.effect(() =>
|
|
1209
|
+
ctx.effect(() => triggers.registerSource({
|
|
1118
1210
|
trigger: '@',
|
|
1119
1211
|
name: 'quote-ref',
|
|
1120
1212
|
order: 100,
|
|
@@ -1125,12 +1217,42 @@ window.__ModuleLoader__.load({
|
|
|
1125
1217
|
serialize: (ref) => Promise.resolve(refCodecs.get(ref) ? refCodecs.get(ref).body : ''),
|
|
1126
1218
|
},
|
|
1127
1219
|
}), 'dsh-quote-annotate-pro: quote-ref source')
|
|
1220
|
+
quoteSourceRegistered = true
|
|
1221
|
+
pipelineAvailable = true
|
|
1128
1222
|
report('register:quote-ref-source', 'ok')
|
|
1223
|
+
return true
|
|
1129
1224
|
} catch (e) {
|
|
1225
|
+
const msg = String((e && e.stack) || (e && e.message) || e)
|
|
1226
|
+
// 已注册(HMR 重载后宿主保留旧注册)不算失败:序列化仍会路由到当前模块的 codec
|
|
1227
|
+
if (/already|duplicate|exist|注册/i.test(msg)) {
|
|
1228
|
+
quoteSourceRegistered = true
|
|
1229
|
+
pipelineAvailable = true
|
|
1230
|
+
report('register:quote-ref-source', 'already')
|
|
1231
|
+
return true
|
|
1232
|
+
}
|
|
1130
1233
|
pipelineAvailable = false
|
|
1131
|
-
report('register:quote-ref-source', 'throw ' +
|
|
1234
|
+
report('register:quote-ref-source', 'throw ' + msg)
|
|
1235
|
+
return false
|
|
1132
1236
|
}
|
|
1133
1237
|
}
|
|
1238
|
+
if (!ensureQuoteSource()) {
|
|
1239
|
+
// 退避重试:最多 20 次、间隔 500ms(约 10s),把启动时序造成的退化救回来
|
|
1240
|
+
try {
|
|
1241
|
+
ctx.effect(() => {
|
|
1242
|
+
let tries = 0
|
|
1243
|
+
let timer = null
|
|
1244
|
+
const tick = () => {
|
|
1245
|
+
timer = null
|
|
1246
|
+
if (quoteSourceRegistered || tries >= 20) return
|
|
1247
|
+
tries += 1
|
|
1248
|
+
if (!ensureQuoteSource()) timer = setTimeout(tick, 500)
|
|
1249
|
+
}
|
|
1250
|
+
timer = setTimeout(tick, 500)
|
|
1251
|
+
report('register:quote-ref-source', 'retry-scheduled')
|
|
1252
|
+
return () => { if (timer !== null) clearTimeout(timer) }
|
|
1253
|
+
}, 'dsh-quote-annotate-pro: quote-ref retry')
|
|
1254
|
+
} catch (e) { /* ignore */ }
|
|
1255
|
+
}
|
|
1134
1256
|
// 全局监听(选区气泡 / chip 点击跳转 / chip 悬停提示;ctx.effect 保证卸载时全部清理)
|
|
1135
1257
|
try {
|
|
1136
1258
|
ctx.effect(() => {
|
|
@@ -1322,7 +1444,6 @@ window.__ModuleLoader__.load({
|
|
|
1322
1444
|
ctx.effect(() => {
|
|
1323
1445
|
let timer = null
|
|
1324
1446
|
let observer = null
|
|
1325
|
-
const HEADER_RE = /【引用\s*·[^\n】]*】/
|
|
1326
1447
|
const jumpByText = (text, pos) => {
|
|
1327
1448
|
const probe = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 24)
|
|
1328
1449
|
if (probe === '') return
|
|
@@ -1368,37 +1489,128 @@ window.__ModuleLoader__.load({
|
|
|
1368
1489
|
} catch (e) { /* ignore */ }
|
|
1369
1490
|
return false
|
|
1370
1491
|
}
|
|
1371
|
-
/**
|
|
1492
|
+
/** 把承载「位置行 + 原文(+ 批注:…)」的文本节点拆成:隐藏头部 + 紧凑引用块 + 其余文本 */
|
|
1372
1493
|
const transformTextNode = (node) => {
|
|
1373
1494
|
const raw = node.nodeValue || ''
|
|
1374
|
-
const
|
|
1375
|
-
if (!
|
|
1376
|
-
const after = raw.slice(m.index + m[0].length)
|
|
1377
|
-
const nl = after.indexOf('\n')
|
|
1378
|
-
if (nl < 0) return false
|
|
1379
|
-
const lines = after.slice(nl + 1).split('\n')
|
|
1380
|
-
// 引用正文 = 头部之后、直到空行之前的连续非空行(兼容旧格式的 "> " 前缀)
|
|
1381
|
-
const quote = []
|
|
1382
|
-
let i = 0
|
|
1383
|
-
for (; i < lines.length; i++) {
|
|
1384
|
-
if (lines[i].trim() === '') break
|
|
1385
|
-
quote.push(lines[i].replace(/^>\s?/, ''))
|
|
1386
|
-
}
|
|
1387
|
-
if (quote.length === 0) return false
|
|
1388
|
-
const rest = lines.slice(i).join('\n')
|
|
1495
|
+
const parsed = parseQuoteBlock(raw)
|
|
1496
|
+
if (!parsed) return false
|
|
1389
1497
|
const frag = document.createDocumentFragment()
|
|
1390
|
-
|
|
1391
|
-
if (before !== '') frag.appendChild(document.createTextNode(before))
|
|
1498
|
+
if (parsed.before !== '') frag.appendChild(document.createTextNode(parsed.before))
|
|
1392
1499
|
const hidden = document.createElement('span')
|
|
1393
1500
|
hidden.className = 'dshq-sent-header'
|
|
1394
|
-
hidden.textContent =
|
|
1501
|
+
hidden.textContent = parsed.header // 位置信息仍留在 DOM(只是不显示)
|
|
1395
1502
|
frag.appendChild(hidden)
|
|
1396
|
-
|
|
1397
|
-
|
|
1503
|
+
const block = buildBlock(parsed.quote)
|
|
1504
|
+
// 回复侧「Annotation N」芯片靠这几项 dataset 对上号
|
|
1505
|
+
if (parsed.no !== null) block.setAttribute('data-dshq-no', String(parsed.no))
|
|
1506
|
+
block.setAttribute('data-dshq-quote', parsed.quote)
|
|
1507
|
+
if (parsed.comment !== '') block.setAttribute('data-dshq-comment', parsed.comment)
|
|
1508
|
+
frag.appendChild(block)
|
|
1509
|
+
if (parsed.rest.trim() !== '') frag.appendChild(document.createTextNode(parsed.rest))
|
|
1510
|
+
node.parentNode.replaceChild(frag, node)
|
|
1511
|
+
report('sent:decorated', { chars: parsed.quote.length, no: parsed.no })
|
|
1512
|
+
return true
|
|
1513
|
+
}
|
|
1514
|
+
// ---- 回复侧「Annotation N:」→ 批注芯片(悬浮显示原文+评语,点击跳回原文) ----
|
|
1515
|
+
const ANNO_TOKEN_RE = /Annotation\s*(\d+)\s*[::]/g
|
|
1516
|
+
/** 取"离这条回复最近的那条用户消息"里的批注数据(编号 → 原文/评语) */
|
|
1517
|
+
const annotationMapBefore = (row) => {
|
|
1518
|
+
const map = new Map()
|
|
1519
|
+
try {
|
|
1520
|
+
const all = document.querySelectorAll('[data-chat-flow-kind]')
|
|
1521
|
+
const idx = Array.prototype.indexOf.call(all, row)
|
|
1522
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
1523
|
+
const r = all[i]
|
|
1524
|
+
if (r.getAttribute('data-chat-flow-kind') !== 'user') continue
|
|
1525
|
+
const blocks = r.querySelectorAll('.dshq-sent-quote[data-dshq-no]')
|
|
1526
|
+
for (const b of blocks) {
|
|
1527
|
+
const no = Number(b.getAttribute('data-dshq-no'))
|
|
1528
|
+
if (!Number.isFinite(no)) continue
|
|
1529
|
+
map.set(no, {
|
|
1530
|
+
quote: b.getAttribute('data-dshq-quote') || '',
|
|
1531
|
+
comment: b.getAttribute('data-dshq-comment') || '',
|
|
1532
|
+
})
|
|
1533
|
+
}
|
|
1534
|
+
break
|
|
1535
|
+
}
|
|
1536
|
+
} catch (e) { /* ignore */ }
|
|
1537
|
+
return map
|
|
1538
|
+
}
|
|
1539
|
+
const buildAnnoChip = (no, ann) => {
|
|
1540
|
+
const chip = document.createElement('span')
|
|
1541
|
+
chip.className = 'dshq-anno-chip'
|
|
1542
|
+
chip.setAttribute('role', 'button')
|
|
1543
|
+
chip.setAttribute('tabindex', '0')
|
|
1544
|
+
chip.textContent = '批注 ' + no
|
|
1545
|
+
chip.title = ann.comment !== '' ? '批注 ' + no + ':' + ann.comment : '批注 ' + no
|
|
1546
|
+
chip.addEventListener('click', (ev) => {
|
|
1547
|
+
const r = chip.getBoundingClientRect()
|
|
1548
|
+
jumpByText(ann.quote, { left: r.left + 16, top: r.top })
|
|
1549
|
+
if (ev && ev.stopPropagation) ev.stopPropagation()
|
|
1550
|
+
})
|
|
1551
|
+
chip.addEventListener('mouseenter', () => {
|
|
1552
|
+
const r = chip.getBoundingClientRect()
|
|
1553
|
+
const text = '原文:' + ann.quote + (ann.comment !== '' ? '\n\n批注:' + ann.comment : '')
|
|
1554
|
+
tooltipStore.show({ text, label: '批注 ' + no, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } })
|
|
1555
|
+
})
|
|
1556
|
+
chip.addEventListener('mouseleave', () => { tooltipStore.hide() })
|
|
1557
|
+
return chip
|
|
1558
|
+
}
|
|
1559
|
+
/** 把一个文本节点里的「Annotation N:」替换成芯片(同一节点可能有多处) */
|
|
1560
|
+
const transformReplyNode = (node, map) => {
|
|
1561
|
+
const raw = node.nodeValue || ''
|
|
1562
|
+
if (raw.indexOf('Annotation') < 0) return false
|
|
1563
|
+
ANNO_TOKEN_RE.lastIndex = 0
|
|
1564
|
+
const parts = []
|
|
1565
|
+
let last = 0
|
|
1566
|
+
let m
|
|
1567
|
+
let any = false
|
|
1568
|
+
while ((m = ANNO_TOKEN_RE.exec(raw)) !== null) {
|
|
1569
|
+
const no = Number(m[1])
|
|
1570
|
+
const ann = map.get(no)
|
|
1571
|
+
if (!ann || ann.quote === '') continue
|
|
1572
|
+
parts.push(raw.slice(last, m.index))
|
|
1573
|
+
parts.push(buildAnnoChip(no, ann))
|
|
1574
|
+
last = m.index + m[0].length
|
|
1575
|
+
any = true
|
|
1576
|
+
}
|
|
1577
|
+
if (!any) return false
|
|
1578
|
+
parts.push(raw.slice(last))
|
|
1579
|
+
const frag = document.createDocumentFragment()
|
|
1580
|
+
for (const p of parts) {
|
|
1581
|
+
if (typeof p === 'string') { if (p !== '') frag.appendChild(document.createTextNode(p)) }
|
|
1582
|
+
else frag.appendChild(p)
|
|
1583
|
+
}
|
|
1398
1584
|
node.parentNode.replaceChild(frag, node)
|
|
1399
|
-
report('sent:decorated', { chars: quote.join(' ').length })
|
|
1400
1585
|
return true
|
|
1401
1586
|
}
|
|
1587
|
+
const decorateReplies = () => {
|
|
1588
|
+
try {
|
|
1589
|
+
for (const row of document.querySelectorAll('[data-chat-flow-kind="assistant-step"], [data-chat-flow-kind="assistant"]')) {
|
|
1590
|
+
if (row.dataset && row.dataset.dshqAnnoDone === '1') continue
|
|
1591
|
+
// 流式输出中不动 DOM(还没定稿)
|
|
1592
|
+
if (row.hasAttribute('data-streaming') || row.querySelector('[data-streaming]')) continue
|
|
1593
|
+
if (selectionTouches(row)) continue
|
|
1594
|
+
const map = annotationMapBefore(row)
|
|
1595
|
+
if (map.size === 0) { if (row.dataset) row.dataset.dshqAnnoDone = '1'; continue }
|
|
1596
|
+
let walker
|
|
1597
|
+
try { walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null) } catch (e) { continue }
|
|
1598
|
+
const targets = []
|
|
1599
|
+
let n = walker.nextNode()
|
|
1600
|
+
while (n) {
|
|
1601
|
+
const parent = n.parentElement
|
|
1602
|
+
const managed = parent && typeof parent.closest === 'function'
|
|
1603
|
+
? parent.closest('.dshq-anno-chip') : null
|
|
1604
|
+
if (!managed && (n.nodeValue || '').indexOf('Annotation') >= 0) targets.push(n)
|
|
1605
|
+
n = walker.nextNode()
|
|
1606
|
+
}
|
|
1607
|
+
let done = false
|
|
1608
|
+
for (const t of targets) { try { if (transformReplyNode(t, map)) done = true } catch (e) { /* ignore */ } }
|
|
1609
|
+
if (row.dataset) row.dataset.dshqAnnoDone = '1'
|
|
1610
|
+
if (done) report('reply:chips', { row: String(row.getAttribute('data-chat-anchor-key') || '').slice(0, 12) })
|
|
1611
|
+
}
|
|
1612
|
+
} catch (e) { report('reply:scan-throw', String((e && e.message) || e)) }
|
|
1613
|
+
}
|
|
1402
1614
|
const scan = () => {
|
|
1403
1615
|
timer = null
|
|
1404
1616
|
try { for (const row of document.querySelectorAll(USER_ROW_SEL)) {
|
|
@@ -1416,7 +1628,7 @@ window.__ModuleLoader__.load({
|
|
|
1416
1628
|
// 已经装饰过的部分(隐藏的位置行 / 紧凑引用块)不再重复处理
|
|
1417
1629
|
const managed = parent && typeof parent.closest === 'function'
|
|
1418
1630
|
? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
|
|
1419
|
-
if (!managed &&
|
|
1631
|
+
if (!managed && QUOTE_HEADER_RE.test(n.nodeValue || '')) targets.push(n)
|
|
1420
1632
|
n = walker.nextNode()
|
|
1421
1633
|
}
|
|
1422
1634
|
if (targets.length === 0) continue
|
|
@@ -1424,6 +1636,7 @@ window.__ModuleLoader__.load({
|
|
|
1424
1636
|
for (const t of targets) { try { if (transformTextNode(t)) done = true } catch (e) { /* ignore */ } }
|
|
1425
1637
|
if (done && row.dataset) row.dataset.dshqSent = '1'
|
|
1426
1638
|
}
|
|
1639
|
+
decorateReplies()
|
|
1427
1640
|
} catch (e) { report('sent:scan-throw', String((e && e.message) || e)) }
|
|
1428
1641
|
}
|
|
1429
1642
|
const schedule = () => { if (timer === null) timer = setTimeout(scan, 300) }
|
|
@@ -1448,7 +1661,7 @@ window.__ModuleLoader__.load({
|
|
|
1448
1661
|
const parent = n.parentElement
|
|
1449
1662
|
const managed = parent && typeof parent.closest === 'function'
|
|
1450
1663
|
? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
|
|
1451
|
-
if (!managed &&
|
|
1664
|
+
if (!managed && QUOTE_HEADER_RE.test(n.nodeValue || '')) return true
|
|
1452
1665
|
n = walker.nextNode()
|
|
1453
1666
|
}
|
|
1454
1667
|
return false
|
|
@@ -1531,7 +1744,7 @@ window.__ModuleLoader__.load({
|
|
|
1531
1744
|
const t = String(el.textContent || '').trim()
|
|
1532
1745
|
if (!headerEl) {
|
|
1533
1746
|
// 位置行必须"整行且只有一行",避免误把整个编辑器当成头部
|
|
1534
|
-
if (isSingleLine(t) &&
|
|
1747
|
+
if (isSingleLine(t) && /^【(?:引用|批注)[^\n]*】$/.test(t)) headerEl = el
|
|
1535
1748
|
continue
|
|
1536
1749
|
}
|
|
1537
1750
|
if (t === '') break
|
|
@@ -1539,9 +1752,9 @@ window.__ModuleLoader__.load({
|
|
|
1539
1752
|
quoteEls.push(el)
|
|
1540
1753
|
}
|
|
1541
1754
|
if (!headerEl) {
|
|
1542
|
-
// 诊断:编辑器里出现了"
|
|
1755
|
+
// 诊断:编辑器里出现了"【引用/【批注"但没能匹配成"位置行 + 正文行"时,把结构打出来
|
|
1543
1756
|
const raw = String(ed.textContent || '')
|
|
1544
|
-
if (raw.indexOf('【引用') >= 0) {
|
|
1757
|
+
if (raw.indexOf('【引用') >= 0 || raw.indexOf('【批注') >= 0) {
|
|
1545
1758
|
const sig = raw.slice(0, 40) + '|' + kids.length
|
|
1546
1759
|
if (sig !== lastSig) {
|
|
1547
1760
|
lastSig = sig
|
|
@@ -1632,6 +1845,8 @@ window.__ModuleLoader__.load({
|
|
|
1632
1845
|
exports.name = 'dsh-quote-annotate-pro'
|
|
1633
1846
|
exports.inject = ['slots']
|
|
1634
1847
|
exports.apply = apply
|
|
1848
|
+
// 纯函数自检出口:tools/test-client.mjs 在 Node 里直接断言(宿主只读取 name/inject/apply)
|
|
1849
|
+
exports.__test = { roleText, anchorLabelOf, buildQuoteHeader, nextAnnotationNo, annotationNoOf, parseQuoteBlock, snippetOf }
|
|
1635
1850
|
return module.exports
|
|
1636
1851
|
},
|
|
1637
1852
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-quote-annotate-pro",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "划词批注与引用锚点:划词 →
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "划词批注与引用锚点:划词 → 批注(自动编号)→ 引用;引用块在输入框和已发送消息里始终可见(最多 3 行、位置行隐藏),点一下跳回原文并高亮;模型按「Annotation N:」逐条回应,回复里的标签可悬浮看原文与评语。纯客户端,锚点存本地 localStorage。",
|
|
5
5
|
"keywords": ["dsh", "dsh-plugin", "deepseek-harness", "quote", "annotate", "selection", "reference", "anchor"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"check": "node tools/check-manifest.mjs",
|
|
27
|
+
"test": "node tools/test-client.mjs",
|
|
27
28
|
"diag": "node tools/diag-sink.mjs"
|
|
28
29
|
},
|
|
29
30
|
"publishConfig": {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// 客户端 bundle 的纯函数自检(不依赖浏览器 DOM / 网络)
|
|
2
|
+
// 运行:npm test (node tools/test-client.mjs)
|
|
3
|
+
//
|
|
4
|
+
// 做法:给 client.js 提供最小化的 window/document/localStorage/fetch 桩,
|
|
5
|
+
// 捕获 window.__ModuleLoader__.load(...) 注册的工厂,再调用工厂拿到 exports,
|
|
6
|
+
// 然后断言 exports.__test 里的纯函数。这些函数是消息解析与编号的核心,回归风险最高。
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
const SOURCE = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
12
|
+
|
|
13
|
+
let captured = null
|
|
14
|
+
globalThis.window = {
|
|
15
|
+
__ModuleLoader__: { load: (spec) => { captured = spec } },
|
|
16
|
+
getSelection: () => null,
|
|
17
|
+
addEventListener() {},
|
|
18
|
+
removeEventListener() {},
|
|
19
|
+
innerWidth: 1280,
|
|
20
|
+
innerHeight: 800,
|
|
21
|
+
}
|
|
22
|
+
const fakeElement = () => ({
|
|
23
|
+
style: {},
|
|
24
|
+
className: '',
|
|
25
|
+
textContent: '',
|
|
26
|
+
dataset: {},
|
|
27
|
+
classList: { add() {}, remove() {}, toggle() {} },
|
|
28
|
+
setAttribute() {},
|
|
29
|
+
getAttribute() { return null },
|
|
30
|
+
appendChild() {},
|
|
31
|
+
addEventListener() {},
|
|
32
|
+
getBoundingClientRect() { return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } },
|
|
33
|
+
})
|
|
34
|
+
globalThis.document = {
|
|
35
|
+
createElement: fakeElement,
|
|
36
|
+
createDocumentFragment: () => ({ appendChild() {} }),
|
|
37
|
+
createTextNode: (t) => ({ nodeValue: t }),
|
|
38
|
+
createTreeWalker: () => ({ nextNode: () => null }),
|
|
39
|
+
head: { appendChild() {} },
|
|
40
|
+
body: { appendChild() {} },
|
|
41
|
+
querySelectorAll: () => [],
|
|
42
|
+
addEventListener() {},
|
|
43
|
+
removeEventListener() {},
|
|
44
|
+
}
|
|
45
|
+
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }
|
|
46
|
+
globalThis.fetch = () => Promise.reject(new Error('offline (test)'))
|
|
47
|
+
globalThis.requestAnimationFrame = () => 0
|
|
48
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
49
|
+
globalThis.MutationObserver = class { observe() {} disconnect() {} }
|
|
50
|
+
globalThis.NodeFilter = { SHOW_TEXT: 4 }
|
|
51
|
+
|
|
52
|
+
// 以 data: URL 作为 ESM 加载 —— 源文件是"注册工厂"的脚本,不导出任何东西
|
|
53
|
+
await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(SOURCE))
|
|
54
|
+
|
|
55
|
+
assert.ok(captured, 'client.js 应调用 window.__ModuleLoader__.load(...)')
|
|
56
|
+
assert.equal(captured.id, 'dsh-quote-annotate-pro')
|
|
57
|
+
const api = captured.factory((name) => (name === 'react' ? {} : {}))
|
|
58
|
+
const { buildQuoteHeader, nextAnnotationNo, annotationNoOf, parseQuoteBlock, snippetOf, anchorLabelOf, roleText } = api.__test
|
|
59
|
+
|
|
60
|
+
test('roleText / anchorLabelOf:轮次与角色文案', () => {
|
|
61
|
+
assert.equal(roleText('assistant-step'), 'DSH 回复')
|
|
62
|
+
assert.equal(roleText('user'), '你的消息')
|
|
63
|
+
assert.equal(roleText(null, 'tool-call'), '工具调用')
|
|
64
|
+
assert.equal(anchorLabelOf({ turn: 0, role: 'user' }), '第1轮 · 你的消息')
|
|
65
|
+
assert.equal(anchorLabelOf({ turn: 4, kind: 'assistant-step' }), '第5轮 · DSH 回复')
|
|
66
|
+
assert.equal(anchorLabelOf({}), '')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('buildQuoteHeader:带编号 / 旧格式 / 空', () => {
|
|
70
|
+
assert.equal(buildQuoteHeader({ turn: 4, role: 'assistant-step' }, 2), '【批注 2 · 第5轮 · DSH 回复】')
|
|
71
|
+
assert.equal(buildQuoteHeader({ turn: 0, role: 'user' }), '【引用 · 第1轮 · 你的消息】')
|
|
72
|
+
assert.equal(buildQuoteHeader({}, undefined), '')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('nextAnnotationNo:草稿内编号(空 → 1,取最大 + 1)', () => {
|
|
76
|
+
assert.equal(nextAnnotationNo(''), 1)
|
|
77
|
+
assert.equal(nextAnnotationNo('你好'), 1)
|
|
78
|
+
assert.equal(nextAnnotationNo('【批注 1 · 第3轮 · DSH 回复】\n原文'), 2)
|
|
79
|
+
assert.equal(nextAnnotationNo('【批注 1 · 第1轮 · 你的消息】\nA\n\n批注:x\n\n【批注 3 · 第2轮 · DSH 回复】\nB'), 4)
|
|
80
|
+
assert.equal(nextAnnotationNo('旧格式【引用 · 第1轮 · 你的消息】\n原文'), 1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('annotationNoOf:从位置行取编号(旧格式 → null)', () => {
|
|
84
|
+
assert.equal(annotationNoOf('【批注 7 · 第3轮 · DSH 回复】'), 7)
|
|
85
|
+
assert.equal(annotationNoOf('【批注 1 · 第1轮 · 你的消息(请按「Annotation 1:」格式回应)】'), 1)
|
|
86
|
+
assert.equal(annotationNoOf('【引用 · 第1轮 · 你的消息】'), null)
|
|
87
|
+
assert.equal(annotationNoOf(''), null)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('parseQuoteBlock:新格式(原文 + 批注)', () => {
|
|
91
|
+
const raw = '【批注 2 · 第5轮 · DSH 回复】\n选中的原文片段\n\n批注:这条不太对'
|
|
92
|
+
const r = parseQuoteBlock(raw)
|
|
93
|
+
assert.ok(r)
|
|
94
|
+
assert.equal(r.no, 2)
|
|
95
|
+
assert.equal(r.header, '【批注 2 · 第5轮 · DSH 回复】')
|
|
96
|
+
assert.equal(r.quote, '选中的原文片段')
|
|
97
|
+
assert.equal(r.comment, '这条不太对')
|
|
98
|
+
assert.equal(r.before, '')
|
|
99
|
+
assert.match(r.rest, /批注:这条不太对/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('parseQuoteBlock:多行原文 / 前置文本 / 旧格式', () => {
|
|
103
|
+
const raw = '开头的一句话【引用 · 第2轮 · 你的消息】\n第一行\n第二行\n\n批注:补充说明\n结尾'
|
|
104
|
+
const r = parseQuoteBlock(raw)
|
|
105
|
+
assert.ok(r)
|
|
106
|
+
assert.equal(r.no, null)
|
|
107
|
+
assert.equal(r.before, '开头的一句话')
|
|
108
|
+
assert.equal(r.quote, '第一行 第二行')
|
|
109
|
+
assert.equal(r.comment, '补充说明')
|
|
110
|
+
assert.match(r.rest, /结尾/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('parseQuoteBlock:解析不了时返回 null', () => {
|
|
114
|
+
assert.equal(parseQuoteBlock('没有位置行的一段文字\n原文'), null)
|
|
115
|
+
assert.equal(parseQuoteBlock('【批注 1 · 第1轮 · 你的消息】'), null)
|
|
116
|
+
assert.equal(parseQuoteBlock(''), null)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('snippetOf:输入框引用条只显示原文(不显示位置行/批注)', () => {
|
|
120
|
+
assert.equal(snippetOf('【批注 1 · 第1轮 · 你的消息】\n选中的原文\n\n批注:这条不对'), '选中的原文')
|
|
121
|
+
assert.equal(snippetOf('【引用 · 第2轮 · DSH 回复】\n> 旧格式原文'), '旧格式原文')
|
|
122
|
+
assert.equal(snippetOf(''), '')
|
|
123
|
+
})
|