dsh-quote-annotate-pro 0.2.11 → 0.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  本项目的所有重要变更都记在这里。版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## 0.3.0 — 编号批注 + 回复逐条对照
6
+
7
+ - **批注自动编号**:同一条消息里每次插入批注都会编号(`批注 1`、`批注 2`…,删掉中间某条不会重号,见 `nextAnnotationNo()`);
8
+ 输入框里 chip 变成 `批注1`,发送后的引用块首行变成 `【批注 2 · 第5轮 · DSH 回复】` —— 模型看到的是"第几处批注 + 哪一轮 + 谁说的"
9
+ - **评语写进正文**:评语不再作为普通文本追加在 chip 之后,而是作为 `批注:…` 跟在原文后面(空行分隔)——
10
+ 发送出去的整条消息自带"原文 + 评语",不再依赖草稿里散落的文本
11
+ - **回复逐条对照**:第一条批注的位置行里内嵌「请按『Annotation 1:』『Annotation 2:』…格式逐条回应我的批注」的指令
12
+ (位置行在界面上被隐藏,模型仍然看得到);模型回复里的 `Annotation N:` 会被渲染成 `批注 N` 芯片:
13
+ **悬浮显示该条批注的原文与评语,点击跳回原文并高亮**
14
+ - **完全客户端实现**:编号、评语、回复芯片的数据都从消息 DOM 里自解析(引用块带 `data-dshq-no` / `-quote` / `-comment`),
15
+ 不新增宿主半区代码、不改消息文本以外的任何语义;装饰逻辑仍遵守「不破坏用户选区 / 幂等 / 观察器内同步执行」三条约束
16
+ - 兼容旧消息:0.2.x 发出去的 `【引用 · …】` 仍按原样渲染成紧凑引用块(只是没有编号与回复芯片)
17
+ - 新增 `tools/test-client.mjs` 纯函数自检(编号 / 位置行解析 / 输入框摘要等 8 组断言,无需浏览器与网络):
18
+ `npm test` 与 CI 都会跑;`lib/client.js` 末尾导出 `__test`(宿主只读 `name` / `inject` / `apply`,不影响加载)
19
+
20
+ ## 0.2.12 — 定位改造:从"功能罗列"改成"设计取舍"(README 首屏)
21
+
22
+ - 首屏不再从"fork 了什么"讲起,改成一句话主张:**划词 → 批注 → 引用;发送之后引用块仍然看得见,点一下回到原文**
23
+ - 新增「设计取舍」小节:与"把引用收进 chip / 标签"的常见做法做对照(气泡里能不能看见引的是哪段、怎么回到原文、模型侧是否带轮次角色、代价是什么),
24
+ 明确本插件走的是**引用可见**这条路线 —— 依据是 2026-10 市场快照里同类插件约 20 个、多数采用折叠式设计
25
+ - 新增「三步上手」(选区气泡 → 输入框引用条 → 消息里的引用块)与收录去向说明(in-DSH dshmarket / DSH Market / dsh-plugin.org / dsh.pub / dsh-xray / HackSing / awesome-deepseek-harness)
26
+ - npm `description` 改为同一口径(强调"引用块始终可见 + 点回原文",去掉容易引起歧义的网络表述)
27
+ - README.en、package.json 同步;仅文档与元数据变更,代码行为不变
28
+
5
29
  ## 0.2.11 — 简介同步到当前行为(npm + GitHub)
6
30
 
7
31
  - npm 包的 `description` 还停留在 0.2.1 时代的表述("输入框**上方**只显示原文片段")—— 已改为如实描述当前行为:
package/README.en.md CHANGED
@@ -4,25 +4,51 @@
4
4
  [![ci](https://github.com/zhao-guang-ai/dsh-quote-annotate-pro/actions/workflows/ci.yml/badge.svg)](https://github.com/zhao-guang-ai/dsh-quote-annotate-pro/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
6
 
7
- **Select → annotate → quote, inside a DSH conversation.** The selected text enters the composer as an anchored quote reference; on send it serializes into a structured quote block, and a quote bar under the composer jumps back to the exact source message and highlights it. The model sees `【引用 · 第N轮 · DSH 回复】` — which turn, whose message — so it no longer has to guess what "that part above" means.
7
+ **Select → annotate → quote. After you send, the quote is still visible — and one click takes you back to the source.**
8
8
 
9
- > An enhanced fork of [`dsh-quote-annotate`](https://github.com/wangwei-wade/dsh-quote-annotate) (MIT, by wangwei-wade).
10
- > Upstream v0.1.1 **fails silently** on **DSH 0.2.0-rc.2** (loads without error, registers no UI at all). This fork fixes 4 compatibility
11
- > problems, adapts 2 further interface changes, and adds 3 capabilities. Still MIT — see [LICENSE](./LICENSE).
12
- > Full write-up: [SUMMARY.md](./SUMMARY.md) (Chinese).
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
+
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
+
13
+ ## The design choice: keep the quote *visible* instead of folding it into a chip
14
+
15
+ Most selection-quote/annotation plugins **hide the quote**: the bubble keeps only a `引用1` chip or a "批注 ×N" tag, and you open or hover it to see the text. This plugin takes the other road:
16
+
17
+ | | Fold into a chip / tag (common) | **This plugin: compact quote block** |
18
+ |---|---|---|
19
+ | Sent bubble | Just a chip/tag — you can't see what was quoted | **Shows the quoted snippet** (max 3 lines, truncated) |
20
+ | Comparing with the source | Hover or expand the tag | **Click the block → jump back + highlight** |
21
+ | Composer | Short chip label | Same quoted snippet as in the message |
22
+ | Model side | Implementation-dependent | Text still starts with `【引用 · 第N轮 · 角色】` — position preserved |
23
+ | Cost | Cleaner bubbles | One grey quote area (hence 3 lines + hidden turn/role line) |
24
+
25
+ In one sentence: **a quote is part of the conversation and should not be hidden.** If you prefer absolutely clean bubbles with quotes only on hover, other plugins do that better; if you keep asking "which sentence did I quote here?", that is why this plugin exists.
26
+
27
+ ## Quick start
28
+
29
+ 1. **Select text** in any message → a bubble appears above the selection → click 「批注」 → write a comment (optional) → `Enter`.
30
+ 2. A quote lands in the composer; a **quote strip above the composer** shows the same snippet and jumps back to the source on click.
31
+ 3. After sending, the bubble keeps the compact quote block — click it any time to return to the source and highlight it. Each finished turn also gets 「引用提问 / 引用回答」 to quote the whole turn.
13
32
 
14
33
  ## Features
15
34
 
16
35
  | Capability | Detail |
17
36
  | --- | --- |
18
37
  | Select & annotate | Select text in the transcript → a bubble appears above the selection → write a comment (optional) → `Enter` |
19
- | Quote chip | An atomic `引用1` chip (short label) lands in the composer; it expands to the full quote block on send |
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 |
20
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 |
21
- | **Compact quotes in the transcript** | A sent message shows only a **compact quote block** (up to 3 lines). The message text still starts with `【引用 · 第N轮 · 角色】` — hidden in the UI, so the model keeps the position context |
22
- | **Turn-accurate context** | The quote block starts with `【引用 · 第3轮 · 你的消息】` followed by `> quoted text`, so the model knows exactly which turn and whose message |
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 |
23
44
  | Turn-level quoting | Every finished turn gets `引用提问` / `引用回答` buttons that quote the whole question or answer |
24
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 |
25
46
 
47
+ > An enhanced fork of [`dsh-quote-annotate`](https://github.com/wangwei-wade/dsh-quote-annotate) (MIT, by wangwei-wade).
48
+ > Upstream v0.1.1 **fails silently** on **DSH 0.2.0-rc.2** (loads without error, registers no UI at all). This fork fixes 4 compatibility
49
+ > problems, adapts 2 further interface changes, and adds 3 capabilities. Still MIT — see [LICENSE](./LICENSE).
50
+ > Full write-up: [SUMMARY.md](./SUMMARY.md) (Chinese).
51
+
26
52
  ## Install
27
53
 
28
54
  ```sh
@@ -51,10 +77,12 @@ composer chip (an official Lexical decorator node)
51
77
  │ on send, this plugin's codec serializes the ref
52
78
  ▼
53
79
  【引用 · 第N轮 · 角色】
54
- > quoted text
80
+ quoted text
55
81
  (comment)
56
82
 
57
- jump back: quote bar / chip → [data-chat-anchor-key] row → scrollIntoView + highlight
83
+ On the UI side the plugin wraps the turn/role line and the quoted text at render time:
84
+ the line is hidden, the text becomes a compact block, and clicking the block scrolls
85
+ back to the anchor and highlights it (no visible affordance).
58
86
  ```
59
87
 
60
88
  Official surfaces used: `slots` (`shell.overlay`, `conversation.chat.turnTail`, `conversation.input.dock`),
package/README.md CHANGED
@@ -4,56 +4,53 @@
4
4
  [![ci](https://github.com/zhao-guang-ai/dsh-quote-annotate-pro/actions/workflows/ci.yml/badge.svg)](https://github.com/zhao-guang-ai/dsh-quote-annotate-pro/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
6
 
7
- **在 DSH 会话里划词 → 批注 → 引用**:选中的文字变成带锚点的引用进入输入框,发送时序列化为结构化引用块;输入框下方常驻一条**引用导航条**,点一下就能跳回原文并高亮。多轮对话里,模型看到的是 `【引用 · 第N轮 · 你的消息】`,不再需要猜"你说的是哪一处"。
7
+ **划词 → 批注 → 引用;发送之后,引用块仍然看得见,点一下就能回到原文。**
8
8
 
9
- > 这是 [`dsh-quote-annotate`](https://github.com/wangwei-wade/dsh-quote-annotate)(MIT,作者 wangwei-wade)的增强 fork。
10
- > 上游 v0.1.1 在 **DSH 0.2.0-rc.2** 上会**静默失效**(既不报错也不注册任何 UI),本 fork 修掉了 4 个兼容性问题,
11
- > 并补上了 3 项能力;另有 2 个接口变化(当前会话、chip DOM 定位)一并适配。许可仍为 MIT,见 [LICENSE](./LICENSE)。
12
- > 完整汇总见 [SUMMARY.md](./SUMMARY.md)。
9
+ 选中的文字变成带锚点的引用块:输入框里、已发送的消息里都只显示**紧凑的原文片段**(最多 3 行,轮次/角色那一行被隐藏),点它跳回原文并高亮;而发给模型的文本仍标明**这是第几处批注 + 哪一轮 + 谁说的**(`【批注 2 · 第5轮 · DSH 回复】`),并要求模型按 `Annotation 1:`、`Annotation 2:` 逐条回应 —— **回复里的每个 `Annotation N:` 都会渲染成 `批注 N` 芯片,悬浮看原文与评语,点击跳回原文。**
10
+
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
+
13
+ ## 设计取舍:引用块"看得见",而不是收进 chip
14
+
15
+ 划词引用/批注这类插件,市场上的主流做法是**把引用收起来**:气泡里只剩一颗 `引用1` chip 或一个「批注 ×N」标签,原文要点开或悬停才能看到。本插件走的是另一条路:
16
+
17
+ | | 收进 chip / 标签(常见做法) | **本插件:紧凑引用块** |
18
+ |---|---|---|
19
+ | 发送后的气泡 | 只剩 chip / 标签,看不到引的是哪段 | **直接显示原文片段**(最多 3 行,超长省略) |
20
+ | 想对照原文 | 悬停或点开标签 | **点引用块 → 跳回原文并高亮** |
21
+ | 输入框 | chip 短标签 | 同样显示原文片段,与消息里一致 |
22
+ | 模型侧 | 取决于实现 | 文本仍带 `【引用 · 第N轮 · 角色】`,位置不丢 |
23
+ | 代价 | 气泡更"干净" | 气泡会多一块灰底引用区(所以压到 3 行、隐藏位置行) |
24
+
25
+ 一句话:**引用是对话的一部分,不该被藏起来。** 如果你更希望气泡绝对干净、只在悬停时看引用,市面上有更合适的方案;如果你常要"这段是我引的哪一句"来回对照,这就是本插件存在的理由。
26
+
27
+ ## 三步上手
28
+
29
+ 1. 在任意消息里**选中一段文字** → 选区上方浮出小气泡 → 点「批注」→ 写评语(可留空)→ `Enter`;
30
+ 同一条消息里可以连续批注多处,插件会自动编号(`批注 1`、`批注 2`…);
31
+ 2. 输入框里出现带编号的引用;**输入框上沿**同时出现一条引用块,点它可跳回原文;
32
+ 3. 发送后气泡里仍是紧凑引用块,点它随时回到原文并高亮;模型回复里的 `Annotation 1:`、`Annotation 2:`
33
+ 会变成 `批注 N` 芯片 —— **悬浮看这条批注的原文与评语,点击跳回原文**。
13
34
 
14
35
  ## 功能
15
36
 
16
37
  | 能力 | 说明 |
17
38
  |---|---|
18
39
  | 划词批注 | 会话里选中任意文字 → 选区上方浮出气泡(预览 + 「批注」)→ 编辑框里写评语(可留空)→ `Enter` 插入 |
19
- | 引用 chip | 输入框里出现 `引用1` 的原子 chip(短标签);发送时展开为完整引用块 |
40
+ | **自动编号** | 同一条消息里多处批注依次编号(删掉中间某条不会重号);chip 显示 `批注1`,模型侧显示 `【批注 N · 第5轮 · 角色】` |
41
+ | 引用 chip | 输入框里出现 `批注1` 的原子 chip(短标签);发送时展开为完整引用块 |
20
42
  | **引用块(输入框内)** | 插入引用后输入框里只显示**原文片段**(位置行隐藏);**点一下即跳回原文并高亮** —— 不做任何可见的"跳转"提示 |
21
- | **对话页里的紧凑引用** | 已发送消息里的引用同样只显示**紧凑原文块**(最多 3 行)。**消息文本仍以 `【引用 · 第N轮 · 角色】` 开头** —— 界面隐藏、模型照旧知道出处 |
22
- | **模型侧精确定位** | 引用块首行是 `【引用 · 第5轮 · 你的消息】`,后接 `> 原文`,让模型明确知道**哪一轮、谁说的** |
43
+ | **对话页里的紧凑引用** | 已发送消息里的引用同样只显示**紧凑原文块**(最多 3 行),评语以 `批注:…` 跟在其后。**消息文本仍以位置行开头** —— 界面隐藏、模型照旧知道出处 |
44
+ | **回复逐条对照** | 协议指令内嵌在第一条批注的位置行里(界面隐藏);模型回复的 `Annotation N:` 渲染成 `批注 N` 芯片,悬浮显示原文 + 评语,点击跳回原文 |
45
+ | **模型侧精确定位** | 引用块首行是 `【批注 2 · 第5轮 · 你的消息】`,后接原文与评语,让模型明确知道**哪一处、哪一轮、谁说的** |
23
46
  | 回合级引用 | 每条已完成回合尾部有「引用提问 / 引用回答」,整段引用该轮 |
24
47
  | 锚点持久化 | `ref → 锚点` 映射写入 `localStorage`(按会话,最多 60 条),**刷新页面后引用块仍能跳回原文** |
25
48
  | 中英自适应 | 按钮文案跟随界面语言(沿用上游) |
26
49
 
27
- ## 与上游的差异
28
-
29
- ### 兼容性修复(否则整插件静默失效)
30
-
31
- 1. **缺 cordis 依赖声明** —— 上游 `apply(ctx)` 里 `const slots = ctx.get('slots')`,拿不到就 `return`。
32
- 没有声明 `inject: ['slots']` 时 `apply` 可能早于 slots 服务就绪执行,于是**无声退出,一个 UI 都不注册**。
33
- → 本 fork 声明 `exports.name` + `exports.inject = ['slots']`,并给每一步注册加 `try/catch` + 上报。
34
-
35
- 2. **`conversation.chat.turnTail` 已从 chain 变 list**(DSH 0.1.6-alpha.2 起)—— 上游仍用 `{ select, priority }` 注册,
36
- 会抛错;而它排在 4 个 overlay 注册**之前**,一崩全崩。→ 改为 `{ name, id, order }`。
37
-
38
- 3. **chip 插入 API 变了** —— 上游调用 `input.insertReference(...)`,该对象在本版本没有此方法,永远退化为纯文本。
39
- → 改走官方会话事件 `slash/input-insert-reference`(与 `dsh-better-sidebar` 同一条在本版本已验证的通路)。
40
-
41
- 4. **回合尾部按钮不出现(快照形状变了)** —— 上游用 `useSession` 的快照 + `Array.isArray(snapshot.nodes)` + 节点 `seq`
42
- 来取该轮文本;本版本真实形状是 **Chat 快照(`useChat`)**、**`nodes` 是 Map**、**节点身份是 `anchorSeq`**、
43
- 文本在 `user.data.content[{type:'text'}]` / `assistant-step.data.blocks[{kind:'text'}]`,
44
- assistant 节点 kind 也不是 `assistant` 而是 `assistant-step`。→ 全部按本版本形状重写取文与锚点解析。
45
-
46
- ### 能力增强
47
-
48
- 5. **当前会话解析** —— 上游读 `useSessions(s => s.current)`,而**本版本会话列表快照没有 `current` 字段**(恒为 `undefined`),
49
- 导致点「批注」只关掉气泡、什么都不发生。→ 改为由**会话作用域槽位**(`conversation.input.dock` 探针)回填 `sessionId`,
50
- 并去掉"必须先解析到会话才开编辑框"的门槛(解析失败也照常进入编辑,插入时再解析)。
51
-
52
- 6. **引用块带轮次/角色** —— 上游只发 `> 原文`。→ 首行加 `【引用 · 第N轮 · 角色】`(轮次取自 `data-chat-turn`,角色取自 `data-chat-flow-kind`)。
53
-
54
- 7. **输入框上方的引用块 + 锚点持久化** —— 本版本输入框 chip **没有 `data-*` 属性**(只有 CSS 类),上游"点 chip 跳转"不可能成立。
55
- → 改为读官方输入状态 `occurrences`(`source === 'quote-ref'`)渲染引用块,只显示**原文片段**(不显示标签/轮次,保持简洁);
56
- `ref → 锚点` 落 `localStorage`,刷新后仍可跳。(输入框 chip 本体也支持点击跳转:按 `title` = label 反查 ref,不依赖 CSS 类顺序。)
50
+ > 这是 [`dsh-quote-annotate`](https://github.com/wangwei-wade/dsh-quote-annotate)(MIT,作者 wangwei-wade)的增强 fork。
51
+ > 上游 v0.1.1 在 **DSH 0.2.0-rc.2** 上会**静默失效**(既不报错也不注册任何 UI),本 fork 修掉了 4 个兼容性问题,
52
+ > 并补上了 3 项能力;另有 2 个接口变化(当前会话、chip DOM 定位)一并适配。许可仍为 MIT,见 [LICENSE](./LICENSE)。
53
+ > 完整汇总见 [SUMMARY.md](./SUMMARY.md)。
57
54
 
58
55
  ## 安装
59
56
 
@@ -89,19 +86,55 @@ emit('slash/input-insert-reference', { reference, span }) ← 官方会话
89
86
  │ 发送
90
87
  ▼
91
88
  本插件 codec.serialize(ref) → 完整引用块
92
- 【引用 · 第5轮 · DSH 回复】
93
- > 原文……
94
- (你的评语)
89
+ 【批注 1 · 第5轮 · DSH 回复(请按「Annotation 1:」「Annotation 2:」…格式逐条回应)】
90
+ 原文……
91
+
92
+ 批注:你的评语
93
+ ▼
94
+ 模型收到"第几处批注 + 哪一轮 + 谁说的 + 原文 + 评语"
95
95
  ▼
96
- 模型收到"哪一轮 + 谁说的 + 原文"
96
+ 模型回复里出现 Annotation 1:… → 插件渲染成「批注 1」芯片
97
+ (悬浮看原文+评语,点击跳回原文)
97
98
 
98
- 跳回原文:引用块/锚点 → [data-chat-anchor-key] 行 → scrollIntoView + 高亮(无可见提示)
99
+ 界面侧:插件在渲染时把位置行与引用正文包成紧凑块(隐藏位置行、只留原文),
100
+ 点击引用块 → 按锚点 scrollIntoView + 高亮(无可见提示)。
99
101
  ```
100
102
 
101
103
  用到的官方接口:`slots`(`shell.overlay` / `conversation.chat.turnTail` / `conversation.input.dock`)、
102
104
  `conversation.input`(草稿 + occurrences)、`sessions.binding/scope`、`inputTriggers.registerSource` + codec、
103
105
  DOM 锚点 `data-chat-anchor-key` / `data-chat-turn`。
104
106
 
107
+ ## 与上游的差异
108
+
109
+ ### 兼容性修复(否则整插件静默失效)
110
+
111
+ 1. **缺 cordis 依赖声明** —— 上游 `apply(ctx)` 里 `const slots = ctx.get('slots')`,拿不到就 `return`。
112
+ 没有声明 `inject: ['slots']` 时 `apply` 可能早于 slots 服务就绪执行,于是**无声退出,一个 UI 都不注册**。
113
+ → 本 fork 声明 `exports.name` + `exports.inject = ['slots']`,并给每一步注册加 `try/catch` + 上报。
114
+
115
+ 2. **`conversation.chat.turnTail` 已从 chain 变 list**(DSH 0.1.6-alpha.2 起)—— 上游仍用 `{ select, priority }` 注册,
116
+ 会抛错;而它排在 4 个 overlay 注册**之前**,一崩全崩。→ 改为 `{ name, id, order }`。
117
+
118
+ 3. **chip 插入 API 变了** —— 上游调用 `input.insertReference(...)`,该对象在本版本没有此方法,永远退化为纯文本。
119
+ → 改走官方会话事件 `slash/input-insert-reference`(与 `dsh-better-sidebar` 同一条在本版本已验证的通路)。
120
+
121
+ 4. **回合尾部按钮不出现(快照形状变了)** —— 上游用 `useSession` 的快照 + `Array.isArray(snapshot.nodes)` + 节点 `seq`
122
+ 来取该轮文本;本版本真实形状是 **Chat 快照(`useChat`)**、**`nodes` 是 Map**、**节点身份是 `anchorSeq`**、
123
+ 文本在 `user.data.content[{type:'text'}]` / `assistant-step.data.blocks[{kind:'text'}]`,
124
+ assistant 节点 kind 也不是 `assistant` 而是 `assistant-step`。→ 全部按本版本形状重写取文与锚点解析。
125
+
126
+ ### 能力增强
127
+
128
+ 5. **当前会话解析** —— 上游读 `useSessions(s => s.current)`,而**本版本会话列表快照没有 `current` 字段**(恒为 `undefined`),
129
+ 导致点「批注」只关掉气泡、什么都不发生。→ 改为由**会话作用域槽位**(`conversation.input.dock` 探针)回填 `sessionId`,
130
+ 并去掉"必须先解析到会话才开编辑框"的门槛(解析失败也照常进入编辑,插入时再解析)。
131
+
132
+ 6. **引用块带轮次/角色** —— 上游只发原文。→ 首行加 `【引用 · 第N轮 · 角色】`(轮次取自 `data-chat-turn`,角色取自 `data-chat-flow-kind`)。
133
+
134
+ 7. **输入框上方的引用块 + 锚点持久化** —— 本版本输入框 chip **没有 `data-*` 属性**(只有 CSS 类),上游"点 chip 跳转"不可能成立。
135
+ → 改为读官方输入状态 `occurrences`(`source === 'quote-ref'`)渲染引用块,只显示**原文片段**(不显示标签/轮次,保持简洁);
136
+ `ref → 锚点` 落 `localStorage`,刷新后仍可跳。(输入框 chip 本体也支持点击跳转:按 `title` = label 反查 ref,不依赖 CSS 类顺序。)
137
+
105
138
  ## 开发
106
139
 
107
140
  ```
@@ -112,6 +145,7 @@ tools/check-manifest.mjs 清单一致性自检(CI 同款)
112
145
 
113
146
  ```sh
114
147
  npm run check # 校验 package.json / patch / bundle 注册 id 是否一致
148
+ npm test # 客户端纯函数自检(编号 / 位置行解析 / 输入框摘要,无需浏览器与网络)
115
149
  npm run diag # 起诊断接收端(默认 http://127.0.0.1:47821)
116
150
  ```
117
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
- return label === '' ? '' : '【引用 · ' + label + '】'
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 仍能跳回原文 ============
@@ -441,7 +503,7 @@ window.__ModuleLoader__.load({
441
503
  if (!anchorKey && info.body) {
442
504
  const probe = String(info.body)
443
505
  .split('\n')
444
- .map((line) => line.replace(/^【引用[^\n]*】\s*/, '').replace(/^> ?/, ''))
506
+ .map((line) => line.replace(/^【(?:引用|批注)[^\n]*】\s*/, '').replace(/^> ?/, ''))
445
507
  .join('\n')
446
508
  .trim()
447
509
  .slice(0, 24)
@@ -467,9 +529,13 @@ window.__ModuleLoader__.load({
467
529
  function refByLabel(label) {
468
530
  if (typeof label !== 'string' || label === '') return null
469
531
  const wanted = label.trim()
532
+ // 同名的 chip label 可能出现多次(每条消息的编号都从 1 开始)——
533
+ // refTargets 按插入顺序排列,取最后一个命中项(最近插入的那条)
534
+ let found = null
470
535
  for (const [ref, info] of refTargets) {
471
- if (info && typeof info.label === 'string' && info.label.trim() === wanted) return ref
536
+ if (info && typeof info.label === 'string' && info.label.trim() === wanted) found = ref
472
537
  }
538
+ if (found !== null) return found
473
539
  // 退化:只按 `引用#N` 前缀匹配(label 可能被截断显示)
474
540
  const short = wanted.split('·')[0].trim()
475
541
  if (short.startsWith('引用#')) {
@@ -799,9 +865,6 @@ window.__ModuleLoader__.load({
799
865
  if (!open || !item) return
800
866
  try {
801
867
  const c = (comment || '').trim()
802
- const header = buildQuoteHeader(item)
803
- // 引用正文不再加 "> " 前缀:输入框与消息里都由插件渲染成紧凑块(前缀会在编辑器里露出一截符号)
804
- const quoteBody = header === '' ? item.text : header + '\n' + item.text
805
868
  const conversation = rootCtx ? rootCtx.get('conversation') : undefined
806
869
  const sessions = rootCtx ? rootCtx.get('sessions') : undefined
807
870
  if (!conversation || !conversation.input || !sessions) {
@@ -822,11 +885,21 @@ window.__ModuleLoader__.load({
822
885
  }
823
886
  const input = conversation.input.for(binding.ctx)
824
887
  const st = input.state.getSnapshot()
888
+ // 编号:同一条消息内从 1 开始(删掉中间某条也不会重号)
889
+ const no = nextAnnotationNo(st.draft)
890
+ let header = buildQuoteHeader(item, no)
891
+ // 首条批注的头部内嵌"回复格式"指令:头部在界面上被隐藏,但模型看得到
892
+ if (no === 1 && header !== '') {
893
+ header = header.replace(/】$/, '(请按「Annotation 1:」「Annotation 2:」…格式逐条回应我的批注)】')
894
+ }
895
+ // 引用正文不加 "> " 前缀(前缀会在编辑器里露出一截符号);
896
+ // 评语写进正文(空行 + 「批注:」前缀):发送后整条消息自带"原文 + 评语",回复侧才能对上号
897
+ const commentLine = c === '' ? '' : '\n\n批注:' + c
898
+ const quoteBody = header === '' ? item.text + commentLine : header + '\n' + item.text + commentLine
825
899
  const base = (st.draft || '').replace(/[\s\u00A0]+$/, '')
826
- const commentTail = c === '' ? '' : '\n\n' + c
827
900
  const ref = 'q' + REF_RUN + (++refSeq)
828
- // 输入框里的 chip 文案只要短、可区分即可(轮次/角色写在发送后的引用块里,不在输入框展示)
829
- const chipLabel = '引用' + refSeq
901
+ // 输入框里的 chip 文案只要短、可区分即可(轮次/角色/评语写在发送后的引用块里,不在输入框展示)
902
+ const chipLabel = '批注' + no
830
903
  // 输入框与消息里显示的就是这段文本(实测:宿主消息 = 草稿文本,WYSIWYG);
831
904
  // 因此位置行(【引用 · 第N轮 · 角色】)保留在文本里给模型用,界面上由插件渲染时隐藏。
832
905
  refTargets.set(ref, { sessionId, anchorKey: item.anchorKey || null, body: quoteBody, label: chipLabel })
@@ -853,11 +926,6 @@ window.__ModuleLoader__.load({
853
926
  clipboardText: quoteBody,
854
927
  })
855
928
  if (applied) {
856
- // 把评论追加到 chip 之后
857
- if (commentTail !== '') {
858
- const st3 = input.state.getSnapshot()
859
- input.setDraft(st3.draft.replace(/[ \t]+$/, '') + commentTail)
860
- }
861
929
  try {
862
930
  const snap2 = input.state.getSnapshot()
863
931
  const occ2 = (snap2 && snap2.occurrences) || []
@@ -881,7 +949,7 @@ window.__ModuleLoader__.load({
881
949
  // 未生效:回退纯文本(内容不丢)
882
950
  refTargets.delete(ref)
883
951
  refCodecs.delete(ref)
884
- input.setDraft(base === '' ? quoteBody + commentTail : base + '\n\n' + quoteBody + commentTail)
952
+ input.setDraft(base === '' ? quoteBody : base + '\n\n' + quoteBody)
885
953
  report('insert:fallback-plaintext', { ref, header })
886
954
  setStatus({ ok: false, text: 'chip 插入未生效,已回退为纯文本引用' })
887
955
  editorStore.close()
@@ -957,7 +1025,7 @@ window.__ModuleLoader__.load({
957
1025
  const snippetOf = (body) => {
958
1026
  const raw = String(body || '')
959
1027
  .split('\n')
960
- .filter((line) => line.trim() !== '' && !/^【引用/.test(line.trim()))
1028
+ .filter((line) => line.trim() !== '' && !/^【(?:引用|批注)/.test(line.trim()) && !/^批注[::]/.test(line.trim()))
961
1029
  .map((line) => line.replace(/^>\s?/, ''))
962
1030
  .join(' ')
963
1031
  .replace(/\s+/g, ' ')
@@ -1081,6 +1149,9 @@ window.__ModuleLoader__.load({
1081
1149
  .dshq-cmp-quote{background:rgba(127,127,127,.10);border-radius:8px;padding:2px 10px;margin:2px 0}
1082
1150
  .dshq-cmp-quote-first{margin-top:4px}
1083
1151
  .dshq-cmp-quote-last{margin-bottom:4px}
1152
+ /* 回复里的「Annotation N」芯片:悬浮看原文+评语,点击跳回原文 */
1153
+ .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}
1154
+ .dshq-anno-chip:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}
1084
1155
  `
1085
1156
 
1086
1157
  // ============ 插件主体 ============
@@ -1322,7 +1393,6 @@ window.__ModuleLoader__.load({
1322
1393
  ctx.effect(() => {
1323
1394
  let timer = null
1324
1395
  let observer = null
1325
- const HEADER_RE = /【引用\s*·[^\n】]*】/
1326
1396
  const jumpByText = (text, pos) => {
1327
1397
  const probe = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 24)
1328
1398
  if (probe === '') return
@@ -1368,37 +1438,128 @@ window.__ModuleLoader__.load({
1368
1438
  } catch (e) { /* ignore */ }
1369
1439
  return false
1370
1440
  }
1371
- /** 把承载「【引用…】\n原文…」的文本节点拆成:隐藏头部 + 紧凑引用块 + 其余文本 */
1441
+ /** 把承载「位置行 + 原文(+ 批注:…)」的文本节点拆成:隐藏头部 + 紧凑引用块 + 其余文本 */
1372
1442
  const transformTextNode = (node) => {
1373
1443
  const raw = node.nodeValue || ''
1374
- const m = HEADER_RE.exec(raw)
1375
- if (!m) return false
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')
1444
+ const parsed = parseQuoteBlock(raw)
1445
+ if (!parsed) return false
1389
1446
  const frag = document.createDocumentFragment()
1390
- const before = raw.slice(0, m.index)
1391
- if (before !== '') frag.appendChild(document.createTextNode(before))
1447
+ if (parsed.before !== '') frag.appendChild(document.createTextNode(parsed.before))
1392
1448
  const hidden = document.createElement('span')
1393
1449
  hidden.className = 'dshq-sent-header'
1394
- hidden.textContent = m[0] // 位置信息仍留在 DOM(只是不显示)
1450
+ hidden.textContent = parsed.header // 位置信息仍留在 DOM(只是不显示)
1395
1451
  frag.appendChild(hidden)
1396
- frag.appendChild(buildBlock(quote.join(' ')))
1397
- if (rest.trim() !== '') frag.appendChild(document.createTextNode(rest))
1452
+ const block = buildBlock(parsed.quote)
1453
+ // 回复侧「Annotation N」芯片靠这几项 dataset 对上号
1454
+ if (parsed.no !== null) block.setAttribute('data-dshq-no', String(parsed.no))
1455
+ block.setAttribute('data-dshq-quote', parsed.quote)
1456
+ if (parsed.comment !== '') block.setAttribute('data-dshq-comment', parsed.comment)
1457
+ frag.appendChild(block)
1458
+ if (parsed.rest.trim() !== '') frag.appendChild(document.createTextNode(parsed.rest))
1398
1459
  node.parentNode.replaceChild(frag, node)
1399
- report('sent:decorated', { chars: quote.join(' ').length })
1460
+ report('sent:decorated', { chars: parsed.quote.length, no: parsed.no })
1400
1461
  return true
1401
1462
  }
1463
+ // ---- 回复侧「Annotation N:」→ 批注芯片(悬浮显示原文+评语,点击跳回原文) ----
1464
+ const ANNO_TOKEN_RE = /Annotation\s*(\d+)\s*[::]/g
1465
+ /** 取"离这条回复最近的那条用户消息"里的批注数据(编号 → 原文/评语) */
1466
+ const annotationMapBefore = (row) => {
1467
+ const map = new Map()
1468
+ try {
1469
+ const all = document.querySelectorAll('[data-chat-flow-kind]')
1470
+ const idx = Array.prototype.indexOf.call(all, row)
1471
+ for (let i = idx - 1; i >= 0; i--) {
1472
+ const r = all[i]
1473
+ if (r.getAttribute('data-chat-flow-kind') !== 'user') continue
1474
+ const blocks = r.querySelectorAll('.dshq-sent-quote[data-dshq-no]')
1475
+ for (const b of blocks) {
1476
+ const no = Number(b.getAttribute('data-dshq-no'))
1477
+ if (!Number.isFinite(no)) continue
1478
+ map.set(no, {
1479
+ quote: b.getAttribute('data-dshq-quote') || '',
1480
+ comment: b.getAttribute('data-dshq-comment') || '',
1481
+ })
1482
+ }
1483
+ break
1484
+ }
1485
+ } catch (e) { /* ignore */ }
1486
+ return map
1487
+ }
1488
+ const buildAnnoChip = (no, ann) => {
1489
+ const chip = document.createElement('span')
1490
+ chip.className = 'dshq-anno-chip'
1491
+ chip.setAttribute('role', 'button')
1492
+ chip.setAttribute('tabindex', '0')
1493
+ chip.textContent = '批注 ' + no
1494
+ chip.title = ann.comment !== '' ? '批注 ' + no + ':' + ann.comment : '批注 ' + no
1495
+ chip.addEventListener('click', (ev) => {
1496
+ const r = chip.getBoundingClientRect()
1497
+ jumpByText(ann.quote, { left: r.left + 16, top: r.top })
1498
+ if (ev && ev.stopPropagation) ev.stopPropagation()
1499
+ })
1500
+ chip.addEventListener('mouseenter', () => {
1501
+ const r = chip.getBoundingClientRect()
1502
+ const text = '原文:' + ann.quote + (ann.comment !== '' ? '\n\n批注:' + ann.comment : '')
1503
+ tooltipStore.show({ text, label: '批注 ' + no, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } })
1504
+ })
1505
+ chip.addEventListener('mouseleave', () => { tooltipStore.hide() })
1506
+ return chip
1507
+ }
1508
+ /** 把一个文本节点里的「Annotation N:」替换成芯片(同一节点可能有多处) */
1509
+ const transformReplyNode = (node, map) => {
1510
+ const raw = node.nodeValue || ''
1511
+ if (raw.indexOf('Annotation') < 0) return false
1512
+ ANNO_TOKEN_RE.lastIndex = 0
1513
+ const parts = []
1514
+ let last = 0
1515
+ let m
1516
+ let any = false
1517
+ while ((m = ANNO_TOKEN_RE.exec(raw)) !== null) {
1518
+ const no = Number(m[1])
1519
+ const ann = map.get(no)
1520
+ if (!ann || ann.quote === '') continue
1521
+ parts.push(raw.slice(last, m.index))
1522
+ parts.push(buildAnnoChip(no, ann))
1523
+ last = m.index + m[0].length
1524
+ any = true
1525
+ }
1526
+ if (!any) return false
1527
+ parts.push(raw.slice(last))
1528
+ const frag = document.createDocumentFragment()
1529
+ for (const p of parts) {
1530
+ if (typeof p === 'string') { if (p !== '') frag.appendChild(document.createTextNode(p)) }
1531
+ else frag.appendChild(p)
1532
+ }
1533
+ node.parentNode.replaceChild(frag, node)
1534
+ return true
1535
+ }
1536
+ const decorateReplies = () => {
1537
+ try {
1538
+ for (const row of document.querySelectorAll('[data-chat-flow-kind="assistant-step"], [data-chat-flow-kind="assistant"]')) {
1539
+ if (row.dataset && row.dataset.dshqAnnoDone === '1') continue
1540
+ // 流式输出中不动 DOM(还没定稿)
1541
+ if (row.hasAttribute('data-streaming') || row.querySelector('[data-streaming]')) continue
1542
+ if (selectionTouches(row)) continue
1543
+ const map = annotationMapBefore(row)
1544
+ if (map.size === 0) { if (row.dataset) row.dataset.dshqAnnoDone = '1'; continue }
1545
+ let walker
1546
+ try { walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null) } catch (e) { continue }
1547
+ const targets = []
1548
+ let n = walker.nextNode()
1549
+ while (n) {
1550
+ const parent = n.parentElement
1551
+ const managed = parent && typeof parent.closest === 'function'
1552
+ ? parent.closest('.dshq-anno-chip') : null
1553
+ if (!managed && (n.nodeValue || '').indexOf('Annotation') >= 0) targets.push(n)
1554
+ n = walker.nextNode()
1555
+ }
1556
+ let done = false
1557
+ for (const t of targets) { try { if (transformReplyNode(t, map)) done = true } catch (e) { /* ignore */ } }
1558
+ if (row.dataset) row.dataset.dshqAnnoDone = '1'
1559
+ if (done) report('reply:chips', { row: String(row.getAttribute('data-chat-anchor-key') || '').slice(0, 12) })
1560
+ }
1561
+ } catch (e) { report('reply:scan-throw', String((e && e.message) || e)) }
1562
+ }
1402
1563
  const scan = () => {
1403
1564
  timer = null
1404
1565
  try { for (const row of document.querySelectorAll(USER_ROW_SEL)) {
@@ -1416,7 +1577,7 @@ window.__ModuleLoader__.load({
1416
1577
  // 已经装饰过的部分(隐藏的位置行 / 紧凑引用块)不再重复处理
1417
1578
  const managed = parent && typeof parent.closest === 'function'
1418
1579
  ? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
1419
- if (!managed && HEADER_RE.test(n.nodeValue || '')) targets.push(n)
1580
+ if (!managed && QUOTE_HEADER_RE.test(n.nodeValue || '')) targets.push(n)
1420
1581
  n = walker.nextNode()
1421
1582
  }
1422
1583
  if (targets.length === 0) continue
@@ -1424,6 +1585,7 @@ window.__ModuleLoader__.load({
1424
1585
  for (const t of targets) { try { if (transformTextNode(t)) done = true } catch (e) { /* ignore */ } }
1425
1586
  if (done && row.dataset) row.dataset.dshqSent = '1'
1426
1587
  }
1588
+ decorateReplies()
1427
1589
  } catch (e) { report('sent:scan-throw', String((e && e.message) || e)) }
1428
1590
  }
1429
1591
  const schedule = () => { if (timer === null) timer = setTimeout(scan, 300) }
@@ -1448,7 +1610,7 @@ window.__ModuleLoader__.load({
1448
1610
  const parent = n.parentElement
1449
1611
  const managed = parent && typeof parent.closest === 'function'
1450
1612
  ? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
1451
- if (!managed && HEADER_RE.test(n.nodeValue || '')) return true
1613
+ if (!managed && QUOTE_HEADER_RE.test(n.nodeValue || '')) return true
1452
1614
  n = walker.nextNode()
1453
1615
  }
1454
1616
  return false
@@ -1531,7 +1693,7 @@ window.__ModuleLoader__.load({
1531
1693
  const t = String(el.textContent || '').trim()
1532
1694
  if (!headerEl) {
1533
1695
  // 位置行必须"整行且只有一行",避免误把整个编辑器当成头部
1534
- if (isSingleLine(t) && /^【引用\s*·[^\n]*】$/.test(t)) headerEl = el
1696
+ if (isSingleLine(t) && /^【(?:引用|批注)[^\n]*】$/.test(t)) headerEl = el
1535
1697
  continue
1536
1698
  }
1537
1699
  if (t === '') break
@@ -1539,9 +1701,9 @@ window.__ModuleLoader__.load({
1539
1701
  quoteEls.push(el)
1540
1702
  }
1541
1703
  if (!headerEl) {
1542
- // 诊断:编辑器里出现了"【引用"但没能匹配成"位置行 + 正文行"时,把结构打出来
1704
+ // 诊断:编辑器里出现了"【引用/【批注"但没能匹配成"位置行 + 正文行"时,把结构打出来
1543
1705
  const raw = String(ed.textContent || '')
1544
- if (raw.indexOf('【引用') >= 0) {
1706
+ if (raw.indexOf('【引用') >= 0 || raw.indexOf('【批注') >= 0) {
1545
1707
  const sig = raw.slice(0, 40) + '|' + kids.length
1546
1708
  if (sig !== lastSig) {
1547
1709
  lastSig = sig
@@ -1632,6 +1794,8 @@ window.__ModuleLoader__.load({
1632
1794
  exports.name = 'dsh-quote-annotate-pro'
1633
1795
  exports.inject = ['slots']
1634
1796
  exports.apply = apply
1797
+ // 纯函数自检出口:tools/test-client.mjs 在 Node 里直接断言(宿主只读取 name/inject/apply)
1798
+ exports.__test = { roleText, anchorLabelOf, buildQuoteHeader, nextAnnotationNo, annotationNoOf, parseQuoteBlock, snippetOf }
1635
1799
  return module.exports
1636
1800
  },
1637
1801
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-quote-annotate-pro",
3
- "version": "0.2.11",
4
- "description": "划词批注与引用锚点:划词 → 批注 → 引用;输入框与消息里只显示紧凑的原文引用块(轮次/角色行隐藏),点一下跳回原文并高亮;发给模型的引用块仍标明轮次与角色,多轮对话不再猜。纯客户端,不访问外部服务。",
3
+ "version": "0.3.0",
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
+ })