dsh-dupguard 1.3.0 → 1.4.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 +39 -0
- package/README.md +127 -31
- package/lib/client.js +56 -19
- package/lib/index.js +248 -18
- package/package.json +3 -2
- package/plugin/host.js +179 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
本文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-09-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **围栏代码块内放宽检测**(`skipCodeBlocks` 默认开启 + `codeBlockMultiplier` 默认 `3`,均可在设置页调整):
|
|
10
|
+
代码块内的重复串(生成的测试夹具、表格、ASCII 图、内嵌数据)改用「阈值 × 倍数」判定,
|
|
11
|
+
不再被误判为复读;块内的失控复读仍会被兜住(默认连续 30 次即截停)。倍数范围 1–100,
|
|
12
|
+
设为 1 等价于不放宽;关闭开关则块内与块外同样严格;
|
|
13
|
+
- 围栏识别按 CommonMark:起始行为行首 ≤3 个空格 + 连续 ≥3 个 `` ` `` 或 `~`(其后为 info string),
|
|
14
|
+
结束行需同字符且不短于起始长度、其后仅空白;未闭合围栏延续到该块结束;
|
|
15
|
+
围栏标记被切进多个增量(如 `"``"` + ``"`js"``)时仍能识别;行内代码与缩进代码块不受影响;
|
|
16
|
+
- 截停日志新增 `code-block=true(放宽阈值命中)` 标记,便于区分命中来源。
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- 窗口缺口提示与宿主告警改为按「阈值 × 代码块倍数 × 最大单元长度」计算(关闭放宽时倍数按 1 计),
|
|
21
|
+
设置页提示文本同步显示倍数。
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- 长围栏不再被短围栏提前关闭:围栏长度取起始行的**完整** run,```` ```` ```` 开启的代码块不会被
|
|
26
|
+
内部的 ``` 行关闭(此前一见 3 个反引号就进入并记长度为 3)。
|
|
27
|
+
|
|
28
|
+
## [1.3.1] - 2026-09-05
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **白名单按「单个字符」匹配的语义两端不一致**:设置页会把整串输入(如 `ab`)当成一个条目,
|
|
33
|
+
而检测按单个字符比对,该条目永远不会生效。现在设置页把一次输入拆成单个字符逐个加入
|
|
34
|
+
(忽略其中的空白),宿主对存量或手改的多字符、空、非字符串条目在运行时丢弃并打印告警;
|
|
35
|
+
白名单比对同时改为按 Unicode 码点进行,emoji 等代理对字符可作为单个条目正常生效。
|
|
36
|
+
- **未改动的数值输入在失焦时也会写入**,把默认值写进用户层(`settings.yaml` 里因此出现
|
|
37
|
+
`threshold: 10`、`maxUnitLength: 80`、`detectionWindow: 8192` 这类无意义条目)。现在值与
|
|
38
|
+
宿主一致时跳过写入。
|
|
39
|
+
- **跨字段约束只在「最大单元」一行提示**:从「最小单元」一侧提交违规值时只能拿到宿主拒绝后的
|
|
40
|
+
泛化「保存失败」。现在两行都给出具体错误并在本地拦截写入。
|
|
41
|
+
- 截停收尾遇到未知块类型(协议新增 `ContentBlock` 类型)时会静默按 tool-call 兜底,
|
|
42
|
+
现补充一次性宿主告警,便于定位协议漂移。
|
|
43
|
+
|
|
5
44
|
## [1.3.0] - 2026-09-05
|
|
6
45
|
|
|
7
46
|
### Added
|
package/README.md
CHANGED
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
[](https://github.com/zqh260619/dsh-dupguard/actions/workflows/ci.yml)
|
|
10
10
|
[](https://github.com/topics/dsh-plugin)
|
|
11
11
|
|
|
12
|
+
**兼容性 / Compatibility**
|
|
13
|
+
|
|
14
|
+
- **DSH**:宿主 API 自 `0.1.1-rc.1` 起可用;当前版本在 **0.1.5-rc.2 / 0.1.6-alpha.2** 上实测通过,
|
|
15
|
+
更早版本在 `0.1.1-rc.1` / `0.1.2-rc.1` 上实测通过。下面「一条命令的 bundle 安装」需要
|
|
16
|
+
**DSH ≥ 0.1.2-rc.1**(bundle 自动纳管与 `dsh.bundle` 约定从该版本起提供)。
|
|
17
|
+
- **Node**:≥ 20(与 DSH 一致,不支持 Node 18)。
|
|
18
|
+
|
|
19
|
+
Host APIs work since DSH `0.1.1-rc.1`; the current version is verified on **0.1.5-rc.2 / 0.1.6-alpha.2**
|
|
20
|
+
(earlier releases were verified on `0.1.1-rc.1` / `0.1.2-rc.1`). The one-command bundle install below
|
|
21
|
+
needs **DSH ≥ 0.1.2-rc.1**. Node ≥ 20 (matching DSH; Node 18 is unsupported).
|
|
22
|
+
|
|
12
23
|
触发后,已生成的内容会**正常提交为助手消息**,本轮对话干净结束——不会报错、不会丢弃输出、不会污染会话日志。
|
|
13
24
|
|
|
14
25
|
When triggered, the already-generated text is committed as a normal assistant message and the turn ends cleanly — no errors, no lost output, no session-log pollution.
|
|
@@ -27,10 +38,12 @@ When triggered, the already-generated text is committed as a normal assistant me
|
|
|
27
38
|
「重复守卫」分节,可视化编辑白名单与全部检测参数(阈值、最小/最大单元长度、检测窗口、空白与
|
|
28
39
|
reasoning、工具参数开关)并持久化(`dsh-dupguard` 设置命名空间),修改即时生效;窗口小于
|
|
29
40
|
阈值 × 最大单元长度时给出「窗口长度需要提高」提示。
|
|
41
|
+
- **代码块内放宽**:围栏代码块(``` / ~~~)内改用「阈值 × 倍数」(默认 3×)判定,生成的代码、
|
|
42
|
+
测试夹具、表格、ASCII 图不会被误判为复读;块内的失控复读仍会被兜住(默认连续 30 次即截停)。
|
|
30
43
|
- **零配置开箱即用**:默认配置即可用;全部检测参数均可在设置页按需调整。
|
|
31
44
|
- **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js` + `lib/client.js`),行为一致、CI 防漂移。
|
|
32
45
|
- **内置 DSH 兼容补丁**(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
|
|
33
|
-
修复 DSH ≤ 0.1.
|
|
46
|
+
修复 DSH ≤ 0.1.6-alpha.2 仍未修复的 preset standing-mount 多代并存冲突(见下文"已知限制")。
|
|
34
47
|
|
|
35
48
|
---
|
|
36
49
|
|
|
@@ -63,7 +76,9 @@ dsh plugin --profile web add dsh-dupguard
|
|
|
63
76
|
`{ id: dupguard, name: dsh-dupguard }`。**无需手改任何 YAML。**
|
|
64
77
|
|
|
65
78
|
- 验证:`dsh --profile web --dump-config` 末尾应出现 `dupguard` 行;loader 日志出现
|
|
66
|
-
`apply plugin dupguard
|
|
79
|
+
`apply plugin dupguard`;宿主日志另有本插件的自检行
|
|
80
|
+
`[dupguard] 常驻插件 apply 开始` 与 `[dupguard] 已注册设置命名空间 dsh-dupguard(检测参数可在设置页动态调整)`,
|
|
81
|
+
出现后设置面板的「重复守卫」分节即可编辑白名单与全部参数。
|
|
67
82
|
- 升级:`dsh plugin --profile web update dsh-dupguard`;
|
|
68
83
|
卸载:`dsh plugin --profile web remove dsh-dupguard`(依赖与 bundles 层一并移除)。
|
|
69
84
|
|
|
@@ -83,6 +98,42 @@ dsh plugin --profile web add dsh-dupguard
|
|
|
83
98
|
⚠️ 两种方式**不要同时使用**:loader 对重复 entry id 直接抛
|
|
84
99
|
`duplicate loader entry id: dupguard`。从手工方式切换到 bundle 方式时,请删除手工 `insert` 项。
|
|
85
100
|
|
|
101
|
+
**3. 临时禁用(不卸载)**:在 profile 的用户补丁层里按 id 关掉该行即可:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
# $DSH_HOME/profiles/web/cordis.patch.yml
|
|
105
|
+
- id: dupguard
|
|
106
|
+
name: dsh-dupguard
|
|
107
|
+
disabled: true
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
用户补丁层在 bundle 层之后应用,profile 的 `patchReload` 默认为 `live`——**保存即卸载该插件行,无需重启**;
|
|
111
|
+
改回 `disabled: false`(或删除该条)即恢复。这条 id 定位的补丁对两种安装方式都适用(bundle 层插入的行、
|
|
112
|
+
或你自己 insert 的行都会被它覆盖)。禁用后宿主半边(截停与 `dsh-dupguard` 设置命名空间)一并卸载,
|
|
113
|
+
全部检测参数回落到代码默认值(含白名单;`settings.yaml` 中的取值保留),重复输出不再被截停;由于 DSH 的
|
|
114
|
+
客户端模块图按**活动 loader 行**生成,**刷新页面**后设置面板里的「重复守卫」分节会消失。注意进程内的
|
|
115
|
+
standing-mount 兼容补丁不会随禁用撤销,要彻底干净需重启 DSH。彻底卸载请用上面的
|
|
116
|
+
`dsh plugin --profile web remove dsh-dupguard`(依赖与 bundles 条目一并移除)。
|
|
117
|
+
|
|
118
|
+
**Temporarily disable (without uninstalling)**: turn the row off by id in the profile's user patch layer:
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
# $DSH_HOME/profiles/web/cordis.patch.yml
|
|
122
|
+
- id: dupguard
|
|
123
|
+
name: dsh-dupguard
|
|
124
|
+
disabled: true
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The user layer applies after every bundle layer and the profile's `patchReload` defaults to `live`, so
|
|
128
|
+
saving unloads the row **without a restart**; set it back to `false` (or delete the entry) to re-enable.
|
|
129
|
+
The id-targeted patch works for both install styles (a row inserted by the bundle layer or by your own
|
|
130
|
+
`insert` entry). The host half (guard + the `dsh-dupguard` settings namespace) unloads with it — every
|
|
131
|
+
detection parameter falls back to the code default (whitelist included) while `settings.yaml` keeps your
|
|
132
|
+
values, and nothing is stopped any more. DSH builds its Client module graph from **live loader rows**, so
|
|
133
|
+
the "Dupguard" settings section disappears after a page refresh. The process-resident standing-mount
|
|
134
|
+
patch is not undone by disabling; restart DSH if you need a completely clean process. To remove it
|
|
135
|
+
entirely, use `dsh plugin --profile web remove dsh-dupguard`.
|
|
136
|
+
|
|
86
137
|
**本地开发**:未发布/调试时,`name` 也可直接用 `file:` URL 指向仓库内的
|
|
87
138
|
[`lib/index.js`](lib/index.js)(CJS 导出 `{ name, apply }`,与 loader 的
|
|
88
139
|
`unwrapExports` 兼容,零构建)。
|
|
@@ -126,21 +177,23 @@ and persisted to `settings.yaml`; the dynamic build uses the constants.
|
|
|
126
177
|
| `threshold` | `10` | 触发阈值:同一字符串连续重复 ≥ 该值时停止 / stop when the same string repeats ≥ this many times |
|
|
127
178
|
| `minUnitLength` | `1` | 最小重复单元长度 / minimum repeating-unit length (`1` also catches single-char loops like `aaaaaaaaaa`) |
|
|
128
179
|
| `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
|
|
129
|
-
| `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后),需 ≥ 阈值 × 最大单元长度 / rolling detection window in chars (after whitespace removal); must be ≥ threshold × max unit length |
|
|
180
|
+
| `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后),需 ≥ 阈值 × 代码块倍数 × 最大单元长度 / rolling detection window in chars (after whitespace removal); must be ≥ threshold × code-block multiplier × max unit length |
|
|
181
|
+
| `skipCodeBlocks` | `true` | 围栏代码块(``` / ~~~)内放宽检测:块内改用「阈值 × 代码块倍数」判定,避免生成的代码/夹具/表格被误杀;置 `false` 则块内与块外同样严格 / relax detection inside fenced code blocks; set `false` to apply the normal threshold inside them too |
|
|
182
|
+
| `codeBlockMultiplier` | `3` | 代码块内的阈值倍数,范围 1–100;越大越少误杀正常代码,但也越晚兜住代码块里的失控复读(1 = 不放宽)/ threshold multiplier inside code blocks, range 1–100 (1 = no relaxation) |
|
|
130
183
|
| `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
|
|
131
|
-
| `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown
|
|
184
|
+
| `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown 表格分隔行(连字符与竖线)不参与重复统计。条目必须**是单个字符**(按 Unicode 码点匹配,emoji 也算一个);设置页一次输入多个字符会逐个加入,多字符/空条目在运行时被丢弃并告警 / whitelist of characters ignored during detection, so Markdown table separators don't count. Entries must be a **single character** (matched per Unicode code point); the settings page splits multi-character input into individual entries, and invalid entries are dropped at runtime with a warning |
|
|
132
185
|
| `monitorReasoning` | `true` | 是否检测思考文本(思考中的复读同样消耗 token,默认截停;只检测可见输出时置 `false`)/ also guard reasoning (thinking) text — on by default; set `false` to guard visible output only |
|
|
133
186
|
| `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
|
|
134
|
-
| `fixStandingMountConflict` | `true` | DSH ≤ 0.1.
|
|
187
|
+
| `fixStandingMountConflict` | `true` | DSH ≤ 0.1.6-alpha.2 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突(仅代码常量)/ idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.6-alpha.2 standing-mount conflict (code constant only) |
|
|
135
188
|
|
|
136
|
-
**窗口约束 / Window constraint**:`detectionWindow` 必须 ≥ `threshold × maxUnitLength
|
|
137
|
-
`floor(detectionWindow /
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
189
|
+
**窗口约束 / Window constraint**:`detectionWindow` 必须 ≥ `threshold × codeBlockMultiplier × maxUnitLength`
|
|
190
|
+
(关闭代码块放宽时倍数按 1 计);否则长度超过 `floor(detectionWindow / 最严格阈值)` 的重复单元凑不满重复
|
|
191
|
+
次数,无法被识别(例如窗口 80、阈值 10、倍数 3 时,超过 2 字符的单元不再触发)。设置页在该约束被违反时
|
|
192
|
+
显示「⚠ 检测窗口长度需要提高:至少 N(当前 M = 阈值 × 倍数 × 最大单元)」的提示,宿主日志同时打印一条
|
|
193
|
+
告警;该约束**只提示不拒绝写入**,便于按需权衡内存占用与可识别单元长度。
|
|
141
194
|
|
|
142
195
|
The settings page shows a "detection window is too small" hint (and the host logs a matching warning)
|
|
143
|
-
whenever `detectionWindow < threshold × maxUnitLength`; the write is still accepted.
|
|
196
|
+
whenever `detectionWindow < threshold × codeBlockMultiplier × maxUnitLength`; the write is still accepted.
|
|
144
197
|
|
|
145
198
|
---
|
|
146
199
|
|
|
@@ -156,9 +209,9 @@ Listens to the `llm/stream` waterfall (wraps every streaming model call) and ret
|
|
|
156
209
|
### 2. 检测算法 / Detection
|
|
157
210
|
|
|
158
211
|
- 按块索引(`chunk.index`)分别累积文本,多块交替输出互不干扰;
|
|
159
|
-
- 去空白后做**尾部连续重复检测**:文本以某个单元(长度 1..80
|
|
160
|
-
|
|
161
|
-
|
|
212
|
+
- 去空白后做**尾部连续重复检测**:文本以某个单元(长度 `minUnitLength`..`maxUnitLength`,默认 1..80)
|
|
213
|
+
连续重复 ≥ `threshold` 次结尾即触发。模型一旦复读,重复必然在尾部,因此尾部检测即可实时捕获所有
|
|
214
|
+
循环,同时避免全窗口词频的误报(如正常中文里高频的"的")。
|
|
162
215
|
|
|
163
216
|
Tails-only consecutive-run detection on the whitespace-stripped buffer: catches every loop in real time
|
|
164
217
|
without the false positives of whole-window frequency counting.
|
|
@@ -211,10 +264,15 @@ default), Markdown table separator rows and horizontal rules (whitelisted by def
|
|
|
211
264
|
│ ├── index.js # npm/组合常驻形式(package.json main 入口,含设置集成)
|
|
212
265
|
│ └── client.js # 浏览器端设置页(ModuleLoader 格式,dsh.client 入口)
|
|
213
266
|
├── tests/
|
|
214
|
-
│ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings
|
|
215
|
-
│ ├── client.test.js # 设置页组件测试:最小 React/DSH
|
|
216
|
-
│
|
|
217
|
-
├── .
|
|
267
|
+
│ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings 集成(74 项)
|
|
268
|
+
│ ├── client.test.js # 设置页组件测试:最小 React/DSH 桩驱动写路径(16 项)
|
|
269
|
+
│ ├── stress-host-adversarial.js # 压力:边界/协议交错/热更新 churn/畸形输入
|
|
270
|
+
│ ├── stress-host-throughput.js # 压力:吞吐/内存/200 路并发/参数极值(METRIC 指标)
|
|
271
|
+
│ ├── stress-client-ui.js # 压力:设置页高频交互、乱序应答、挂载泄漏
|
|
272
|
+
│ ├── stress-real-invariant.mjs # 压力:真实 DSH llm-invariant + BlockAssembler 端到端校验
|
|
273
|
+
│ ├── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
|
|
274
|
+
│ └── experiment-inspect-patch.mjs # 诊断实验:standing-mount 补丁的多代并存行为
|
|
275
|
+
├── .github/workflows/ # ci.yml(Node 20/22/24 跑 npm test)+ publish.yml(v* 标签发布 npm)
|
|
218
276
|
├── cordis.patch.yml # bundle 补丁层(dsh.bundle.patch:插入宿主行)
|
|
219
277
|
├── package.json
|
|
220
278
|
├── CHANGELOG.md
|
|
@@ -225,20 +283,49 @@ default), Markdown table separator rows and horizontal rules (whitelisted by def
|
|
|
225
283
|
## 测试 / Tests
|
|
226
284
|
|
|
227
285
|
```bash
|
|
228
|
-
npm test #
|
|
229
|
-
node tests/detector.test.js # 检测端到端(
|
|
230
|
-
node tests/client.test.js # 设置页组件(
|
|
286
|
+
npm test # 功能测试(两个文件)
|
|
287
|
+
node tests/detector.test.js # 检测端到端(74 项)
|
|
288
|
+
node tests/client.test.js # 设置页组件(16 项)
|
|
231
289
|
```
|
|
232
290
|
|
|
233
|
-
|
|
291
|
+
同一套用例分别驱动两个入口(`plugin/host.js` 经 `new Function` 求值、`lib/index.js` 经
|
|
234
292
|
`require` 加载),覆盖:透传完整性、各类复读形态、阈值边界、协议闭合、上游 `return()` 调用、
|
|
235
|
-
默认不检测 reasoning
|
|
236
|
-
用最小 React 与 DSH 客户端桩驱动设置页组件,断言写入走 `settingsScope` 控制器
|
|
293
|
+
默认不检测 reasoning/工具参数、未闭合工具调用块的闭合、多次调用状态隔离、设置 schema 与热更新等。
|
|
294
|
+
`client.test.js` 用最小 React 与 DSH 客户端桩驱动设置页组件,断言写入走 `settingsScope` 控制器
|
|
237
295
|
(`set`/`unset`)而非已移除的 `connection.api`。CI 在 Node 20/22/24 上运行
|
|
238
296
|
(与 DSH 一致,不支持 Node 18)。
|
|
239
297
|
|
|
240
|
-
|
|
241
|
-
|
|
298
|
+
### 压力测试 / Stress suites
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
npm run stress # 依次运行下列四套
|
|
302
|
+
node tests/stress-host-adversarial.js # 边界/周期重叠/分块不变性/协议交错/围栏代码块/设置 churn/畸形输入
|
|
303
|
+
node tests/stress-host-throughput.js # 吞吐、最坏情况扫描、命中延迟、内存、200 路并发、参数极值
|
|
304
|
+
node tests/stress-client-ui.js # 设置页 500 条白名单、1000 次混合操作、写应答乱序、churn、挂载泄漏
|
|
305
|
+
node tests/stress-real-invariant.mjs # 用真实 DSH 的 llm-invariant 与 BlockAssembler 校验截停收尾
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
后一套使用本机安装的 `@deepseek-ai/dsh-llm`(依次探测 `$DSH_LLM_DIR`、`$DSH_INSTALL`、
|
|
309
|
+
`$DSH_HOME`、全局 npm 安装),找不到时打印 SKIP 并跳过,因此可安全地在任意环境运行。
|
|
310
|
+
|
|
311
|
+
实测参考(Windows / Node 24,默认参数除注明外):
|
|
312
|
+
|
|
313
|
+
| 指标 | 实测 |
|
|
314
|
+
| --- | --- |
|
|
315
|
+
| 增量吞吐(窗口 8192) | 1 字符增量 1.9 µs/增量(533 chars/ms);1KB 增量 11 µs/增量(92k chars/ms) |
|
|
316
|
+
| 增量吞吐(窗口 1 MiB) | 4 字符增量 33 µs/增量(118 chars/ms)——每增量重写整个窗口,大窗口显著变慢 |
|
|
317
|
+
| 最坏情况扫描(近失配周期文本) | 2.7 µs/增量 |
|
|
318
|
+
| 命中延迟 | 阈值 10 时消费 12 字符即截停(尾部即时判定) |
|
|
319
|
+
| 5,000,000 字符长流 | 堆增长约 32 MB(完整文本 + 检测窗口),无额外无界增长 |
|
|
320
|
+
| 200 路并发 | 136 ms;1 路复读被截停,其余 199 路完整透传 |
|
|
321
|
+
| 设置页 500 条白名单 | 渲染 0.3 ms;逐个删除 500 次 = 500 次写入 |
|
|
322
|
+
| 设置页 1000 次混合操作 | 24 ms、887 次写入,终态与控制器快照一致 |
|
|
323
|
+
| 500 次挂载/卸载 | subscribe 1000 / dispose 999(余 1 为当前挂载),无监听器泄漏 |
|
|
324
|
+
|
|
325
|
+
The same suite drives both entries. Stress suites cover host throughput/memory/concurrency, adversarial
|
|
326
|
+
protocol interleavings, client UI churn, and an end-to-end check against the installed DSH
|
|
327
|
+
`llm-invariant` validator plus the real `BlockAssembler`; `tests/stress-real-invariant.mjs` skips itself
|
|
328
|
+
when DSH is not installed. CI runs on Node 20/22/24 (matching DSH; Node 18 is not supported).
|
|
242
329
|
|
|
243
330
|
---
|
|
244
331
|
|
|
@@ -247,8 +334,17 @@ Node 20/22/24 (matching DSH; Node 18 is not supported).
|
|
|
247
334
|
- 停止时若恰有未闭合的工具调用块(顺序输出块的适配器几乎不可能),该块会按已累积参数闭合并可能被执行。
|
|
248
335
|
- 服务端停止依赖适配器在流关闭时中止底层请求的语义(已验证 `dsh-llm-deepseek`;自定义适配器需自查)。
|
|
249
336
|
- 阈值语义为 `>= threshold`:第 10 次重复出现时即停止。
|
|
250
|
-
|
|
251
|
-
|
|
337
|
+
- **代码块放宽只覆盖围栏代码块**:行内代码(`` `x` ``)与缩进代码块(4 空格)仍按普通阈值判定;
|
|
338
|
+
模型忘记闭合围栏时,其后内容一律按代码块(放宽)处理,且**代码块内的失控复读要到「阈值 × 倍数」
|
|
339
|
+
才会被截停**(默认 30 次),这是「避免误杀生成的代码」的代价——把倍数调小可获得更早的兜底。
|
|
340
|
+
- 检测窗口上限 1,048,576 字符:每个增量都要重写一次缓冲,成本随窗口线性增长——缓冲填满后
|
|
341
|
+
1 MiB 窗口约 0.13 ms/增量,实测 4 字符增量的平均值为 33 µs/增量(含缓冲填充期)。默认 8192 无感
|
|
342
|
+
(1.9 µs/增量,模型侧毫秒级的 token 间隔下可忽略)。
|
|
343
|
+
- 若手工编辑 `settings.yaml` 写入非法值(如 `minUnitLength > maxUnitLength`),DSH 会在注册时拒绝该
|
|
344
|
+
命名空间,本插件捕获后仅打印错误日志并整体回落到代码默认值(检测功能不受影响,设置页显示默认值)。
|
|
345
|
+
- 协议新增 `ContentBlock` 类型时,截停收尾对未知块类型只能按 tool-call 兜底并打印一次性告警。
|
|
346
|
+
|
|
347
|
+
### DSH 运行期间编辑 preset 后的 standing-mount 冲突(DSH ≤ 0.1.6-alpha.2 缺陷,本插件已内置补丁)
|
|
252
348
|
|
|
253
349
|
**现象**:对某个会话执行模型选择等操作时报
|
|
254
350
|
`resume failed ... preset ... failed to mount ... Host Cordis inspect provider "Service" is already registered`,
|
|
@@ -264,8 +360,8 @@ while the process lives")。`tool-cordis` 在每次挂载时向**进程全局*
|
|
|
264
360
|
**本插件的修复(默认开启)**:`apply` 时把 `cordisInspect.register` **幂等化**——同 id 已有注册时
|
|
265
361
|
共享既有注册并返回 no-op disposer,多代并存不再冲突。补丁进程内常驻(卸载本插件后仍生效,
|
|
266
362
|
重启后由本插件重新安装;HMR 重载不会叠加)。依赖 `cordisInspect.providers` 为可读 Map
|
|
267
|
-
(rc.6 / rc.7 / 0.1.1-rc.1 / 0.1.2-rc.1
|
|
268
|
-
"reclaim the superseded generation" 的 TODO,缺陷未修复,故默认开启。
|
|
363
|
+
(rc.6 / rc.7 / 0.1.1-rc.1 / 0.1.2-rc.1 / 0.1.5-rc.2 / 0.1.6-alpha.2 实测如此);0.1.6-alpha.2 上游
|
|
364
|
+
仍留有 "reclaim the superseded generation" 的 TODO,缺陷未修复,故默认开启。
|
|
269
365
|
DSH 升级修复后可将 `CONFIG.fixStandingMountConflict` 置为 `false` 关闭。
|
|
270
366
|
|
|
271
367
|
**仍建议的操作纪律**:运行期间编辑已挂载 preset 后重启 DSH(补丁消除的是报错,旧代残留的
|
|
@@ -279,7 +375,7 @@ its process-global Host inspect providers (`Service` …) twice and every retry
|
|
|
279
375
|
patches it by default**: `cordisInspect.register` is made idempotent (a same-id registration shares
|
|
280
376
|
the existing one and gets a no-op disposer), so coexisting generations no longer collide. The patch
|
|
281
377
|
is process-resident (survives plugin unload, reinstalled on restart; HMR reload does not stack it);
|
|
282
|
-
set `CONFIG.fixStandingMountConflict` to `false` once a fixed DSH ships. Still open in 0.1.
|
|
378
|
+
set `CONFIG.fixStandingMountConflict` to `false` once a fixed DSH ships. Still open in 0.1.6-alpha.2:
|
|
283
379
|
upstream carries a TODO to reclaim superseded generations.
|
|
284
380
|
|
|
285
381
|
## License
|
package/lib/client.js
CHANGED
|
@@ -24,9 +24,9 @@ window.__ModuleLoader__.load({
|
|
|
24
24
|
nav: '重复守卫',
|
|
25
25
|
title: '重复输出守卫',
|
|
26
26
|
intro: '模型输出中同一字符串连续重复达到阈值次数时自动截停。以下参数修改后即时生效并持久化(settings.yaml)。',
|
|
27
|
-
list: '
|
|
27
|
+
list: '忽略字符(白名单,按单个字符匹配)',
|
|
28
28
|
empty: '白名单为空:所有字符都参与重复统计。',
|
|
29
|
-
addPlaceholder: '
|
|
29
|
+
addPlaceholder: '输入要忽略的字符(可多个)',
|
|
30
30
|
add: '添加',
|
|
31
31
|
params: '检测参数',
|
|
32
32
|
threshold: '触发阈值(连续重复次数)',
|
|
@@ -36,9 +36,13 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
maxUnitLength: '最大重复单元长度',
|
|
37
37
|
maxUnitLengthHint: '可识别的最长重复单元(清洗后字符数,空白与白名单字符不计)。范围 {min}–{max}。',
|
|
38
38
|
detectionWindow: '检测窗口(字符)',
|
|
39
|
-
detectionWindowHint: '检测缓冲保留的字符数。范围 {min}–{max};需 ≥ 阈值 × 最大单元长度,否则长单元凑不满重复次数。',
|
|
39
|
+
detectionWindowHint: '检测缓冲保留的字符数。范围 {min}–{max};需 ≥ 阈值 × 代码块倍数 × 最大单元长度,否则长单元凑不满重复次数。',
|
|
40
|
+
codeBlockMultiplier: '代码块内阈值倍数',
|
|
41
|
+
codeBlockMultiplierHint: '围栏代码块(``` / ~~~)内的重复按「阈值 × 本倍数」判定。范围 {min}–{max};越大越不容易误杀正常代码,但也越晚兜住代码块里的失控复读(1 = 不放宽)。',
|
|
40
42
|
stripWhitespace: '忽略空白字符',
|
|
41
43
|
stripWhitespaceHint: '检测前移除所有空白(含换行),使「重复 重复」「重复\\n重复」这类带分隔的复读也能识别。',
|
|
44
|
+
skipCodeBlocks: '放宽代码块内的检测',
|
|
45
|
+
skipCodeBlocksHint: '开启后围栏代码块(``` / ~~~)内改用放宽阈值,避免生成的代码、测试夹具、表格、ASCII 图被误判为复读;行内代码与缩进代码块不受影响。',
|
|
42
46
|
monitorReasoning: '检测思考内容(reasoning)',
|
|
43
47
|
monitorReasoningHint: '思考中的复读同样消耗 token,默认一并检测。',
|
|
44
48
|
monitorToolArguments: '检测工具调用参数',
|
|
@@ -51,7 +55,8 @@ window.__ModuleLoader__.load({
|
|
|
51
55
|
errInteger: '请输入整数。',
|
|
52
56
|
errRange: '取值范围 {min}–{max}。',
|
|
53
57
|
errCross: '不能小于最小重复单元长度(当前 {min})。',
|
|
54
|
-
|
|
58
|
+
errCrossMin: '不能大于最大重复单元长度(当前 {max})。',
|
|
59
|
+
windowWarn: '⚠ 检测窗口长度需要提高:至少 {need}(当前 {current} = 阈值 {threshold} × 代码块倍数 {multiplier} × 最大单元 {maxUnit})。当前窗口下超过 {effective} 字符的重复单元无法识别。',
|
|
55
60
|
loading: '加载中…',
|
|
56
61
|
remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,检测参数将使用默认值。',
|
|
57
62
|
unavailable: '设置服务不可用:未读取到「重复守卫」的设置数据。请确认 DSH 为 npm 常驻版挂载(宿主日志应包含「已注册设置命名空间 dsh-dupguard」),并刷新页面重试。',
|
|
@@ -60,9 +65,9 @@ window.__ModuleLoader__.load({
|
|
|
60
65
|
nav: 'Dupguard',
|
|
61
66
|
title: 'Repetition Guard',
|
|
62
67
|
intro: 'Generation stops when the same string repeats consecutively up to the threshold. Changes below take effect immediately and persist to settings.yaml.',
|
|
63
|
-
list: 'Ignored characters (whitelist)',
|
|
68
|
+
list: 'Ignored characters (whitelist, matched per character)',
|
|
64
69
|
empty: 'Whitelist is empty: every character counts.',
|
|
65
|
-
addPlaceholder: '
|
|
70
|
+
addPlaceholder: 'Characters to ignore (one or more)',
|
|
66
71
|
add: 'Add',
|
|
67
72
|
params: 'Detection parameters',
|
|
68
73
|
threshold: 'Threshold (consecutive repeats)',
|
|
@@ -72,9 +77,13 @@ window.__ModuleLoader__.load({
|
|
|
72
77
|
maxUnitLength: 'Maximum repeating-unit length',
|
|
73
78
|
maxUnitLengthHint: 'Longest repeating unit recognized, in characters after cleaning (whitespace and whitelisted characters are removed). Range {min}–{max}.',
|
|
74
79
|
detectionWindow: 'Detection window (characters)',
|
|
75
|
-
detectionWindowHint: 'Characters retained in the detection buffer. Range {min}–{max}; must be >= threshold x max unit length, otherwise long units never reach the repeat count.',
|
|
80
|
+
detectionWindowHint: 'Characters retained in the detection buffer. Range {min}–{max}; must be >= threshold x code-block multiplier x max unit length, otherwise long units never reach the repeat count.',
|
|
81
|
+
codeBlockMultiplier: 'Code-block threshold multiplier',
|
|
82
|
+
codeBlockMultiplierHint: 'Inside fenced code blocks (``` / ~~~) a repeat must reach threshold x this multiplier. Range {min}–{max}; higher means fewer false stops on generated code, but runaway loops inside code are caught later (1 = no relaxation).',
|
|
76
83
|
stripWhitespace: 'Ignore whitespace',
|
|
77
84
|
stripWhitespaceHint: 'Remove all whitespace (newlines included) before detection, so separated repeats are still recognized.',
|
|
85
|
+
skipCodeBlocks: 'Relax detection inside code blocks',
|
|
86
|
+
skipCodeBlocksHint: 'With this on, fenced code blocks (``` / ~~~) use the relaxed threshold, so generated code, fixtures, tables and ASCII art are not mistaken for repetition loops. Inline code and indented code blocks are unaffected.',
|
|
78
87
|
monitorReasoning: 'Monitor reasoning text',
|
|
79
88
|
monitorReasoningHint: 'Repeats inside reasoning burn tokens too, so they are monitored by default.',
|
|
80
89
|
monitorToolArguments: 'Monitor tool-call arguments',
|
|
@@ -87,7 +96,8 @@ window.__ModuleLoader__.load({
|
|
|
87
96
|
errInteger: 'Enter an integer.',
|
|
88
97
|
errRange: 'Allowed range {min}–{max}.',
|
|
89
98
|
errCross: 'Must not be smaller than the minimum unit length (currently {min}).',
|
|
90
|
-
|
|
99
|
+
errCrossMin: 'Must not be larger than the maximum unit length (currently {max}).',
|
|
100
|
+
windowWarn: '⚠ Detection window is too small: needs at least {need} (currently {current} = threshold {threshold} x code-block multiplier {multiplier} x max unit {maxUnit}). Units longer than {effective} characters cannot be detected with the current window.',
|
|
91
101
|
loading: 'Loading…',
|
|
92
102
|
remoteHint: 'Settings editing requires a local connection: open the DSH page through the local address (127.0.0.1) and retry. Remote browsers have the settings channel disabled and detection keeps its defaults.',
|
|
93
103
|
unavailable: 'Settings unavailable: no data for the Dupguard section was received. Verify the npm build is mounted (the host log should contain "已注册设置命名空间 dsh-dupguard") and refresh the page.',
|
|
@@ -140,18 +150,21 @@ window.__ModuleLoader__.load({
|
|
|
140
150
|
// ---------------------------------------------------------------------
|
|
141
151
|
const NUMERIC_FIELDS = [
|
|
142
152
|
{ key: 'threshold', min: 2, max: 1000 },
|
|
153
|
+
{ key: 'codeBlockMultiplier', min: 1, max: 100 },
|
|
143
154
|
{ key: 'minUnitLength', min: 1, max: 4096 },
|
|
144
155
|
{ key: 'maxUnitLength', min: 1, max: 8192 },
|
|
145
156
|
{ key: 'detectionWindow', min: 64, max: 1048576 },
|
|
146
157
|
]
|
|
147
|
-
const BOOL_FIELDS = ['stripWhitespace', 'monitorReasoning', 'monitorToolArguments']
|
|
158
|
+
const BOOL_FIELDS = ['stripWhitespace', 'skipCodeBlocks', 'monitorReasoning', 'monitorToolArguments']
|
|
148
159
|
const DEFAULTS = {
|
|
149
160
|
ignoredChars: ['-', '|'],
|
|
150
161
|
threshold: 10,
|
|
162
|
+
codeBlockMultiplier: 3,
|
|
151
163
|
minUnitLength: 1,
|
|
152
164
|
maxUnitLength: 80,
|
|
153
165
|
detectionWindow: 8192,
|
|
154
166
|
stripWhitespace: true,
|
|
167
|
+
skipCodeBlocks: true,
|
|
155
168
|
monitorReasoning: true,
|
|
156
169
|
monitorToolArguments: false,
|
|
157
170
|
}
|
|
@@ -281,18 +294,26 @@ window.__ModuleLoader__.load({
|
|
|
281
294
|
const value = parsed[field.key]
|
|
282
295
|
if (!Number.isInteger(value)) return t('errInteger')
|
|
283
296
|
if (value < field.min || value > field.max) return fmt(t('errRange'), { min: field.min, max: field.max })
|
|
284
|
-
|
|
297
|
+
// 跨字段约束在两侧都提示:否则从「最小单元」一侧提交违规值时,
|
|
298
|
+
// 只能拿到宿主拒绝后的泛化「保存失败」。
|
|
299
|
+
if (crossInvalid && field.key === 'maxUnitLength') return fmt(t('errCross'), { min: parsed.minUnitLength })
|
|
300
|
+
if (crossInvalid && field.key === 'minUnitLength') return fmt(t('errCrossMin'), { max: parsed.maxUnitLength })
|
|
285
301
|
return null
|
|
286
302
|
}
|
|
287
303
|
|
|
288
|
-
// 窗口缺口提示:窗口 <
|
|
289
|
-
|
|
304
|
+
// 窗口缺口提示:窗口 < 最严格阈值 × 最大单元长度时,更长的单元无法凑满重复次数。
|
|
305
|
+
// 代码块内使用放宽阈值(阈值 × 倍数),因此窗口需按更严格的一侧计算。
|
|
306
|
+
const strictThreshold = shown.skipCodeBlocks === true
|
|
307
|
+
? parsed.threshold * parsed.codeBlockMultiplier
|
|
308
|
+
: parsed.threshold
|
|
309
|
+
const shortfall = numericValid && !crossInvalid && parsed.detectionWindow < strictThreshold * parsed.maxUnitLength
|
|
290
310
|
? {
|
|
291
|
-
need:
|
|
311
|
+
need: strictThreshold * parsed.maxUnitLength,
|
|
292
312
|
current: parsed.detectionWindow,
|
|
293
313
|
threshold: parsed.threshold,
|
|
314
|
+
multiplier: shown.skipCodeBlocks === true ? parsed.codeBlockMultiplier : 1,
|
|
294
315
|
maxUnit: parsed.maxUnitLength,
|
|
295
|
-
effective: Math.floor(parsed.detectionWindow /
|
|
316
|
+
effective: Math.floor(parsed.detectionWindow / strictThreshold),
|
|
296
317
|
}
|
|
297
318
|
: null
|
|
298
319
|
|
|
@@ -326,8 +347,15 @@ window.__ModuleLoader__.load({
|
|
|
326
347
|
return
|
|
327
348
|
}
|
|
328
349
|
const value = parsed[field.key]
|
|
329
|
-
dirty.current[field.key] = true
|
|
330
350
|
setErrors((prev) => ({ ...prev, [field.key]: null }))
|
|
351
|
+
// 值未变化时不写入:避免仅仅聚焦/失焦就把默认值写进用户层,
|
|
352
|
+
// 污染 settings.yaml 并让「恢复默认」失去意义。
|
|
353
|
+
if (snapshotValue()[field.key] === value) {
|
|
354
|
+
dirty.current[field.key] = false
|
|
355
|
+
setForm({ ...currentForm(), [field.key]: String(value) })
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
dirty.current[field.key] = true
|
|
331
359
|
setForm({ ...currentForm(), [field.key]: String(value) })
|
|
332
360
|
runWrite(() => controller.set(field.key, value), () => snapshotValue()[field.key] === value)
|
|
333
361
|
}
|
|
@@ -353,11 +381,20 @@ window.__ModuleLoader__.load({
|
|
|
353
381
|
}
|
|
354
382
|
|
|
355
383
|
const add = () => {
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
384
|
+
const text = draft.trim()
|
|
385
|
+
if (text.length === 0) return
|
|
358
386
|
setDraft('')
|
|
359
|
-
|
|
360
|
-
|
|
387
|
+
// 白名单按单个字符匹配:一次输入多个字符时逐个加入(忽略输入中的空白),
|
|
388
|
+
// 否则整串会被当成一个条目,永远不会命中。
|
|
389
|
+
const next = [...shown.ignoredChars]
|
|
390
|
+
let changed = false
|
|
391
|
+
for (const ch of text) {
|
|
392
|
+
if (/\s/.test(ch)) continue
|
|
393
|
+
if (next.indexOf(ch) !== -1) continue
|
|
394
|
+
next.push(ch)
|
|
395
|
+
changed = true
|
|
396
|
+
}
|
|
397
|
+
if (changed) commitList(next)
|
|
361
398
|
}
|
|
362
399
|
const remove = (ch) => commitList(shown.ignoredChars.filter((item) => item !== ch))
|
|
363
400
|
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,7 @@ const LIMITS = {
|
|
|
31
31
|
minUnitLength: { min: 1, max: 4096 },
|
|
32
32
|
maxUnitLength: { min: 1, max: 8192 },
|
|
33
33
|
detectionWindow: { min: 64, max: 1048576 },
|
|
34
|
+
codeBlockMultiplier: { min: 1, max: 100 },
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -52,7 +53,9 @@ function loadSettingsSchema() {
|
|
|
52
53
|
minUnitLength: z.number().min(LIMITS.minUnitLength.min).max(LIMITS.minUnitLength.max).step(1).default(CONFIG.minUnitLength),
|
|
53
54
|
maxUnitLength: z.number().min(LIMITS.maxUnitLength.min).max(LIMITS.maxUnitLength.max).step(1).default(CONFIG.maxUnitLength),
|
|
54
55
|
detectionWindow: z.number().min(LIMITS.detectionWindow.min).max(LIMITS.detectionWindow.max).step(1).default(CONFIG.detectionWindow),
|
|
56
|
+
codeBlockMultiplier: z.number().min(LIMITS.codeBlockMultiplier.min).max(LIMITS.codeBlockMultiplier.max).step(1).default(CONFIG.codeBlockMultiplier),
|
|
55
57
|
stripWhitespace: z.boolean().default(CONFIG.stripWhitespace),
|
|
58
|
+
skipCodeBlocks: z.boolean().default(CONFIG.skipCodeBlocks),
|
|
56
59
|
monitorReasoning: z.boolean().default(CONFIG.monitorReasoning),
|
|
57
60
|
monitorToolArguments: z.boolean().default(CONFIG.monitorToolArguments),
|
|
58
61
|
})
|
|
@@ -78,7 +81,17 @@ const CONFIG = {
|
|
|
78
81
|
// 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成
|
|
79
82
|
// (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
|
|
80
83
|
// 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
|
|
84
|
+
// 条目必须是单个字符(按 Unicode 码点匹配,emoji 也算一个);
|
|
85
|
+
// 多字符条目永远不会命中,运行时会丢弃并告警。
|
|
81
86
|
ignoredChars: ['-', '|'],
|
|
87
|
+
// 围栏代码块(``` / ~~~)内的重复检测放宽:代码里的重复串(生成的测试夹具、表格、
|
|
88
|
+
// ASCII 图、内嵌数据等)大多属于正常内容,按普通阈值会误杀。开启后块内改用
|
|
89
|
+
// threshold × codeBlockMultiplier 判定:既能放过正常代码,又能兜住真正的失控复读。
|
|
90
|
+
// 置 false 则关闭该行为(块内与块外同样严格);倍数置 1 等价于不放宽。
|
|
91
|
+
// 局限:只识别围栏代码块,行内代码(`x`)与缩进代码块(4 空格)仍按普通阈值判定;
|
|
92
|
+
// 模型忘记闭合围栏时,其后内容都按代码块(放宽)处理。
|
|
93
|
+
skipCodeBlocks: true,
|
|
94
|
+
codeBlockMultiplier: 3,
|
|
82
95
|
// 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
|
|
83
96
|
// 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
|
|
84
97
|
// 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
|
|
@@ -129,12 +142,11 @@ function stripWhitespace(text) {
|
|
|
129
142
|
return text.replace(/\s+/g, '')
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
/** 移除白名单字符(与 CONFIG.ignoredChars
|
|
145
|
+
/** 移除白名单字符(与 CONFIG.ignoredChars 配合)。按 Unicode 码点逐个比对。 */
|
|
133
146
|
function stripIgnoredChars(text, ignored) {
|
|
134
147
|
if (ignored.length === 0) return text
|
|
135
148
|
let out = ''
|
|
136
|
-
for (
|
|
137
|
-
const ch = text[i]
|
|
149
|
+
for (const ch of text) {
|
|
138
150
|
if (ignored.indexOf(ch) === -1) out += ch
|
|
139
151
|
}
|
|
140
152
|
return out
|
|
@@ -147,6 +159,194 @@ function sanitizeDelta(delta, config) {
|
|
|
147
159
|
return piece
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
/**
|
|
163
|
+
* 流式围栏代码块过滤器(每个文本块一份状态)。
|
|
164
|
+
*
|
|
165
|
+
* 目标:把增量切成「普通文本 / 代码块内文本」两段,供检测按不同阈值判定——
|
|
166
|
+
* 代码块内的重复大多是正常内容(生成的测试夹具、表格、ASCII 图、内嵌数据),
|
|
167
|
+
* 按普通阈值会误杀,故块内使用放宽阈值。
|
|
168
|
+
*
|
|
169
|
+
* 规则按 CommonMark 的围栏代码块:
|
|
170
|
+
* - 起始行:行首最多 3 个空格 + 连续 ≥3 个 ` 或 ~(其后为 info string);
|
|
171
|
+
* - 结束行:行首最多 3 个空格 + 同字符且不短于起始长度的连续 run,其后仅允许空白;
|
|
172
|
+
* - 未闭合的围栏一直延续到该块结束。
|
|
173
|
+
* 增量切分安全:围栏标记被切进多个 delta(如 "``" + "`js")时靠行首缓冲继续判定。
|
|
174
|
+
* 局限:不识别行内代码(`x`)与缩进代码块(4 空格),它们按普通文本处理。
|
|
175
|
+
*/
|
|
176
|
+
function createFenceFilter() {
|
|
177
|
+
// 围栏字符 run 的长度上限:超过即按上限记录并进入代码块,避免无界缓冲。
|
|
178
|
+
const MAX_FENCE_RUN = 64
|
|
179
|
+
let atLineStart = true
|
|
180
|
+
let head = '' // 行首缓冲:最多 3 个空格 + 可能的围栏字符 run
|
|
181
|
+
let headSpaces = 0
|
|
182
|
+
let headChar = ''
|
|
183
|
+
let fence = null // { char, len }:已进入代码块
|
|
184
|
+
let closeLine = '' // 代码块内当前行(用于判定结束行)
|
|
185
|
+
let closeTooLong = false
|
|
186
|
+
|
|
187
|
+
const runLength = () => head.length - headSpaces
|
|
188
|
+
const resetHead = () => {
|
|
189
|
+
head = ''
|
|
190
|
+
headSpaces = 0
|
|
191
|
+
headChar = ''
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 当前行是否为结束行(同字符、不短于起始长度、其后仅空白)。 */
|
|
195
|
+
function isClosingLine(line) {
|
|
196
|
+
const body = line.replace(/[ \t]+$/, '')
|
|
197
|
+
const indent = body.length - body.replace(/^ {0,3}/, '').length
|
|
198
|
+
const run = body.slice(indent)
|
|
199
|
+
if (run.length < fence.len) return false
|
|
200
|
+
for (let i = 0; i < run.length; i++) {
|
|
201
|
+
if (run[i] !== fence.char) return false
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
/** 当前是否处于围栏代码块内(诊断/测试用)。 */
|
|
208
|
+
inside: () => fence !== null,
|
|
209
|
+
/**
|
|
210
|
+
* 消费一个增量,返回按「是否位于代码块内」切分的片段序列。
|
|
211
|
+
* 片段按原始顺序首尾相接即为完整增量,调用方据此分别用普通/放宽阈值检测。
|
|
212
|
+
* @param delta - 文本增量。
|
|
213
|
+
* @returns {Array<{ text: string, code: boolean }>} 片段序列(可能为空数组)。
|
|
214
|
+
*/
|
|
215
|
+
push(delta) {
|
|
216
|
+
// 快路径:不含围栏字符与换行的增量不可能改变围栏状态。
|
|
217
|
+
if (fence !== null && delta.indexOf(fence.char) === -1 && delta.indexOf('\n') === -1) {
|
|
218
|
+
return [{ text: delta, code: true }]
|
|
219
|
+
}
|
|
220
|
+
if (fence === null && !atLineStart &&
|
|
221
|
+
delta.indexOf('`') === -1 && delta.indexOf('~') === -1 && delta.indexOf('\n') === -1) {
|
|
222
|
+
return [{ text: delta, code: false }]
|
|
223
|
+
}
|
|
224
|
+
const runs = []
|
|
225
|
+
let buffer = ''
|
|
226
|
+
let bufferCode = fence !== null
|
|
227
|
+
const emit = (text, code) => {
|
|
228
|
+
if (text.length === 0) return
|
|
229
|
+
if (buffer.length > 0 && code !== bufferCode) {
|
|
230
|
+
runs.push({ text: buffer, code: bufferCode })
|
|
231
|
+
buffer = ''
|
|
232
|
+
}
|
|
233
|
+
bufferCode = code
|
|
234
|
+
buffer += text
|
|
235
|
+
}
|
|
236
|
+
for (let i = 0; i < delta.length; i++) {
|
|
237
|
+
const ch = delta[i]
|
|
238
|
+
if (fence !== null) {
|
|
239
|
+
// 代码块内:只判定结束行,内容整体按代码发射。
|
|
240
|
+
if (ch === '\n') {
|
|
241
|
+
const closing = isClosingLine(closeLine)
|
|
242
|
+
emit(ch, true)
|
|
243
|
+
closeLine = ''
|
|
244
|
+
closeTooLong = false
|
|
245
|
+
if (closing) {
|
|
246
|
+
fence = null
|
|
247
|
+
atLineStart = true
|
|
248
|
+
resetHead()
|
|
249
|
+
bufferCode = false
|
|
250
|
+
}
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (!closeTooLong) {
|
|
254
|
+
closeLine += ch
|
|
255
|
+
if (closeLine.length > fence.len + 8) closeTooLong = true
|
|
256
|
+
}
|
|
257
|
+
emit(ch, true)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (atLineStart) {
|
|
261
|
+
if (ch === ' ' && headChar === '' && headSpaces < 3) {
|
|
262
|
+
head += ch
|
|
263
|
+
headSpaces++
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if ((ch === '`' || ch === '~') && (headChar === '' || headChar === ch)) {
|
|
267
|
+
headChar = ch
|
|
268
|
+
head += ch
|
|
269
|
+
// 不能一见 3 个就进入:起始行的完整 run 才是围栏长度(```` 不能被 ``` 关闭)。
|
|
270
|
+
if (runLength() >= MAX_FENCE_RUN) {
|
|
271
|
+
emit(head, true)
|
|
272
|
+
fence = { char: ch, len: runLength() }
|
|
273
|
+
closeLine = ''
|
|
274
|
+
closeTooLong = false
|
|
275
|
+
atLineStart = false
|
|
276
|
+
resetHead()
|
|
277
|
+
}
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
if (headChar !== '' && runLength() >= 3) {
|
|
281
|
+
// 起始行(含行首空格与围栏符)整体属于代码块,长度取完整 run。
|
|
282
|
+
emit(head, true)
|
|
283
|
+
fence = { char: headChar, len: runLength() }
|
|
284
|
+
closeLine = ''
|
|
285
|
+
closeTooLong = false
|
|
286
|
+
atLineStart = false
|
|
287
|
+
resetHead()
|
|
288
|
+
emit(ch, true)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
// 判定不是围栏起始:行首缓冲按普通文本交还。
|
|
292
|
+
emit(head, false)
|
|
293
|
+
resetHead()
|
|
294
|
+
atLineStart = ch === '\n'
|
|
295
|
+
emit(ch, false)
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
emit(ch, false)
|
|
299
|
+
if (ch === '\n') {
|
|
300
|
+
atLineStart = true
|
|
301
|
+
resetHead()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (buffer.length > 0) runs.push({ text: buffer, code: bufferCode })
|
|
305
|
+
return runs
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 未知块类型只会在 DSH 协议新增 ContentBlock 类型时出现:此时没有合法的合成
|
|
312
|
+
* block-end,只能按 tool-call 兜底并告警一次,便于定位协议漂移。
|
|
313
|
+
*/
|
|
314
|
+
let warnedUnknownBlockType = false
|
|
315
|
+
function warnUnknownBlockType(blockType) {
|
|
316
|
+
if (warnedUnknownBlockType) return
|
|
317
|
+
warnedUnknownBlockType = true
|
|
318
|
+
console.warn(
|
|
319
|
+
'[dupguard] 遇到未知块类型 ' + JSON.stringify(blockType) +
|
|
320
|
+
':截停收尾只能按 tool-call 兜底,请升级插件以匹配新的 DSH 协议。'
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 白名单按「单个字符」(Unicode 码点)匹配:多字符或空条目永远不会命中,
|
|
326
|
+
* 属于无效配置。此处丢弃并告警,避免设置页显示了一个永远不生效的条目。
|
|
327
|
+
*/
|
|
328
|
+
function normalizeIgnoredChars(raw, fallback) {
|
|
329
|
+
if (!Array.isArray(raw)) return [...fallback]
|
|
330
|
+
const kept = []
|
|
331
|
+
const dropped = []
|
|
332
|
+
for (const entry of raw) {
|
|
333
|
+
if (typeof entry !== 'string') {
|
|
334
|
+
dropped.push(String(entry))
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
// [...entry] 按码点拆分:emoji 等代理对字符算一个字符。
|
|
338
|
+
if ([...entry].length === 1) kept.push(entry)
|
|
339
|
+
else dropped.push(entry)
|
|
340
|
+
}
|
|
341
|
+
if (dropped.length > 0) {
|
|
342
|
+
console.warn(
|
|
343
|
+
'[dupguard] 白名单条目必须是单个字符,已忽略无效条目:' + JSON.stringify(dropped) +
|
|
344
|
+
'(白名单按字符匹配,多字符条目不会生效)。'
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
return kept
|
|
348
|
+
}
|
|
349
|
+
|
|
150
350
|
/**
|
|
151
351
|
* 为一次 llm/stream 调用创建守卫。
|
|
152
352
|
* 每次模型调用都会新建一份状态,互不干扰。
|
|
@@ -168,6 +368,7 @@ function createStreamGuard(options, runtime) {
|
|
|
168
368
|
blockType,
|
|
169
369
|
text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口
|
|
170
370
|
stripped: '', // 去空白后的滚动窗口:仅用于检测
|
|
371
|
+
fence: createFenceFilter(), // 围栏代码块状态(skipCodeBlocks 开启时使用)
|
|
171
372
|
toolCallId: undefined,
|
|
172
373
|
toolCallName: undefined,
|
|
173
374
|
toolCallArguments: '',
|
|
@@ -177,12 +378,25 @@ function createStreamGuard(options, runtime) {
|
|
|
177
378
|
return b
|
|
178
379
|
}
|
|
179
380
|
|
|
180
|
-
/**
|
|
381
|
+
/**
|
|
382
|
+
* 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。
|
|
383
|
+
*
|
|
384
|
+
* 增量会先按围栏代码块切成片段:块外按 threshold 判定,块内按
|
|
385
|
+
* threshold × codeBlockMultiplier 判定(放宽,避免误杀正常代码)。
|
|
386
|
+
*/
|
|
181
387
|
function feedText(b, delta) {
|
|
182
388
|
b.text += delta
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
389
|
+
const codeThreshold = Math.max(runtime.threshold, runtime.threshold * runtime.codeBlockMultiplier)
|
|
390
|
+
const runs = runtime.skipCodeBlocks ? b.fence.push(delta) : [{ text: delta, code: false }]
|
|
391
|
+
for (const run of runs) {
|
|
392
|
+
const piece = sanitizeDelta(run.text, runtime)
|
|
393
|
+
if (piece.length === 0) continue
|
|
394
|
+
b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
|
|
395
|
+
const threshold = run.code ? codeThreshold : runtime.threshold
|
|
396
|
+
const hit = findRepeatedTail(b.stripped, threshold, runtime.minUnitLength, runtime.maxUnitLength)
|
|
397
|
+
if (hit !== null) return { unit: hit.unit, count: hit.count, span: hit.span, code: run.code }
|
|
398
|
+
}
|
|
399
|
+
return null
|
|
186
400
|
}
|
|
187
401
|
|
|
188
402
|
/** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
|
|
@@ -241,6 +455,7 @@ function createStreamGuard(options, runtime) {
|
|
|
241
455
|
} else if (b.blockType === 'reasoning') {
|
|
242
456
|
block = { type: 'reasoning', text: b.text }
|
|
243
457
|
} else {
|
|
458
|
+
if (b.blockType !== 'tool-call') warnUnknownBlockType(b.blockType)
|
|
244
459
|
block = {
|
|
245
460
|
type: 'tool-call',
|
|
246
461
|
id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index,
|
|
@@ -273,7 +488,8 @@ function createStreamGuard(options, runtime) {
|
|
|
273
488
|
'[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model +
|
|
274
489
|
' unit=' + JSON.stringify(stopped.unit) +
|
|
275
490
|
' repeat>=' + String(stopped.count) +
|
|
276
|
-
' span=' + String(stopped.span) + 'chars'
|
|
491
|
+
' span=' + String(stopped.span) + 'chars' +
|
|
492
|
+
(stopped.code === true ? ' code-block=true(放宽阈值命中)' : '')
|
|
277
493
|
)
|
|
278
494
|
yield* closingChunks()
|
|
279
495
|
// 提前 return:for-await 会调用上游 iterator.return(),
|
|
@@ -331,7 +547,9 @@ function settingsBase() {
|
|
|
331
547
|
minUnitLength: CONFIG.minUnitLength,
|
|
332
548
|
maxUnitLength: CONFIG.maxUnitLength,
|
|
333
549
|
detectionWindow: CONFIG.detectionWindow,
|
|
550
|
+
codeBlockMultiplier: CONFIG.codeBlockMultiplier,
|
|
334
551
|
stripWhitespace: CONFIG.stripWhitespace,
|
|
552
|
+
skipCodeBlocks: CONFIG.skipCodeBlocks,
|
|
335
553
|
monitorReasoning: CONFIG.monitorReasoning,
|
|
336
554
|
monitorToolArguments: CONFIG.monitorToolArguments,
|
|
337
555
|
}
|
|
@@ -342,20 +560,29 @@ function createRuntime() {
|
|
|
342
560
|
return settingsBase()
|
|
343
561
|
}
|
|
344
562
|
|
|
563
|
+
/** 代码块内实际使用的最严格阈值(未开启代码块放宽时等于 threshold)。 */
|
|
564
|
+
function effectiveCodeThreshold(runtime) {
|
|
565
|
+
if (runtime.skipCodeBlocks !== true) return runtime.threshold
|
|
566
|
+
return runtime.threshold * runtime.codeBlockMultiplier
|
|
567
|
+
}
|
|
568
|
+
|
|
345
569
|
/**
|
|
346
|
-
*
|
|
570
|
+
* 检测窗口是否小于「最严格阈值 × 最大单元长度」。
|
|
347
571
|
*
|
|
348
572
|
* 窗口是清洗后保留的字符数:一个长度 p 的单元需要 p × threshold 个字符才可能
|
|
349
|
-
* 凑满阈值,因此 p 超过 floor(窗口 / 阈值)
|
|
350
|
-
*
|
|
573
|
+
* 凑满阈值,因此 p 超过 floor(窗口 / 阈值) 的重复单元无法被识别。代码块内使用
|
|
574
|
+
* 放宽阈值(threshold × codeBlockMultiplier),窗口需按该更严格的一侧计算。
|
|
575
|
+
* 返回缺口描述(含所需窗口)或 null。
|
|
351
576
|
*/
|
|
352
577
|
function windowShortfall(runtime) {
|
|
353
|
-
const
|
|
578
|
+
const threshold = Math.max(runtime.threshold, effectiveCodeThreshold(runtime))
|
|
579
|
+
const needed = threshold * runtime.maxUnitLength
|
|
354
580
|
if (runtime.detectionWindow >= needed) return null
|
|
355
581
|
return {
|
|
356
582
|
needed: needed,
|
|
357
583
|
current: runtime.detectionWindow,
|
|
358
|
-
|
|
584
|
+
threshold: threshold,
|
|
585
|
+
effectiveMaxUnit: Math.floor(runtime.detectionWindow / threshold),
|
|
359
586
|
}
|
|
360
587
|
}
|
|
361
588
|
|
|
@@ -363,11 +590,14 @@ function windowShortfall(runtime) {
|
|
|
363
590
|
function warnShortWindow(runtime) {
|
|
364
591
|
const short = windowShortfall(runtime)
|
|
365
592
|
if (short === null) return
|
|
593
|
+
const relaxed = short.threshold !== runtime.threshold
|
|
366
594
|
console.warn(
|
|
367
595
|
'[dupguard] 检测窗口长度需要提高:至少 ' + String(short.needed) +
|
|
368
|
-
'(= 阈值 ' + String(runtime.threshold) +
|
|
596
|
+
'(= 阈值 ' + String(runtime.threshold) +
|
|
597
|
+
(relaxed ? ' × 代码块倍数 ' + String(runtime.codeBlockMultiplier) : '') +
|
|
598
|
+
' × 最大单元长度 ' + String(runtime.maxUnitLength) +
|
|
369
599
|
'),当前 ' + String(short.current) + ';超过 ' + String(short.effectiveMaxUnit) +
|
|
370
|
-
'
|
|
600
|
+
' 字符的重复单元将无法识别。请在设置页提高「检测窗口」,或降低阈值/代码块倍数/最大单元长度。'
|
|
371
601
|
)
|
|
372
602
|
}
|
|
373
603
|
|
|
@@ -386,14 +616,14 @@ function applySettingsToRuntime(runtime, value) {
|
|
|
386
616
|
return fallback[key]
|
|
387
617
|
}
|
|
388
618
|
const pickBool = (key) => (typeof source[key] === 'boolean' ? source[key] : fallback[key])
|
|
389
|
-
runtime.ignoredChars =
|
|
390
|
-
? source.ignoredChars.filter((ch) => typeof ch === 'string')
|
|
391
|
-
: [...fallback.ignoredChars]
|
|
619
|
+
runtime.ignoredChars = normalizeIgnoredChars(source.ignoredChars, fallback.ignoredChars)
|
|
392
620
|
runtime.threshold = pickInt('threshold')
|
|
393
621
|
runtime.minUnitLength = pickInt('minUnitLength')
|
|
394
622
|
runtime.maxUnitLength = pickInt('maxUnitLength')
|
|
395
623
|
runtime.detectionWindow = pickInt('detectionWindow')
|
|
624
|
+
runtime.codeBlockMultiplier = pickInt('codeBlockMultiplier')
|
|
396
625
|
runtime.stripWhitespace = pickBool('stripWhitespace')
|
|
626
|
+
runtime.skipCodeBlocks = pickBool('skipCodeBlocks')
|
|
397
627
|
runtime.monitorReasoning = pickBool('monitorReasoning')
|
|
398
628
|
runtime.monitorToolArguments = pickBool('monitorToolArguments')
|
|
399
629
|
warnShortWindow(runtime)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dupguard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Real-time repetition guard for DeepSeek Harness (DSH): stops model generation when the same string repeats >=10 times in the streamed output. 实时检测 DSH 大模型流式输出中的重复内容,同一字符串重复十次以上立即停止生成。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"node": ">=20"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
-
"test": "node tests/detector.test.js && node tests/client.test.js"
|
|
48
|
+
"test": "node tests/detector.test.js && node tests/client.test.js",
|
|
49
|
+
"stress": "node tests/stress-host-adversarial.js && node tests/stress-host-throughput.js && node tests/stress-client-ui.js && node tests/stress-real-invariant.mjs"
|
|
49
50
|
},
|
|
50
51
|
"publishConfig": {
|
|
51
52
|
"access": "public"
|
package/plugin/host.js
CHANGED
|
@@ -35,6 +35,15 @@ const CONFIG = {
|
|
|
35
35
|
// (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
|
|
36
36
|
// 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
|
|
37
37
|
ignoredChars: ['-', '|'],
|
|
38
|
+
// 围栏代码块(``` / ~~~)内的重复检测放宽:代码里的重复串(生成的测试夹具、表格、
|
|
39
|
+
// ASCII 图、内嵌数据等)大多属于正常内容,按普通阈值会误杀。开启后块内改用
|
|
40
|
+
// threshold × codeBlockMultiplier 判定:既能放过正常代码,又能兜住真正的失控复读。
|
|
41
|
+
// 置 false 则关闭该行为(块内与块外同样严格);倍数置 1 等价于不放宽。
|
|
42
|
+
// 局限:只识别围栏代码块,行内代码(`x`)与缩进代码块(4 空格)仍按普通阈值判定;
|
|
43
|
+
// 模型忘记闭合围栏时,其后内容都按代码块(放宽)处理。
|
|
44
|
+
// 动态版无设置页,此处为代码常量;npm 常驻版可在设置页动态调整同名参数。
|
|
45
|
+
skipCodeBlocks: true,
|
|
46
|
+
codeBlockMultiplier: 3,
|
|
38
47
|
// 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
|
|
39
48
|
// 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
|
|
40
49
|
// 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
|
|
@@ -103,6 +112,156 @@ function sanitizeDelta(delta, config) {
|
|
|
103
112
|
return piece
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
/**
|
|
116
|
+
* 流式围栏代码块过滤器(每个文本块一份状态)。
|
|
117
|
+
*
|
|
118
|
+
* 目标:把增量切成「普通文本 / 代码块内文本」两段,供检测按不同阈值判定——
|
|
119
|
+
* 代码块内的重复大多是正常内容(生成的测试夹具、表格、ASCII 图、内嵌数据),
|
|
120
|
+
* 按普通阈值会误杀,故块内使用放宽阈值。
|
|
121
|
+
*
|
|
122
|
+
* 规则按 CommonMark 的围栏代码块:
|
|
123
|
+
* - 起始行:行首最多 3 个空格 + 连续 ≥3 个 ` 或 ~(其后为 info string);
|
|
124
|
+
* - 结束行:行首最多 3 个空格 + 同字符且不短于起始长度的连续 run,其后仅允许空白;
|
|
125
|
+
* - 未闭合的围栏一直延续到该块结束。
|
|
126
|
+
* 增量切分安全:围栏标记被切进多个 delta(如 "``" + "`js")时靠行首缓冲继续判定。
|
|
127
|
+
* 局限:不识别行内代码(`x`)与缩进代码块(4 空格),它们按普通文本处理。
|
|
128
|
+
*
|
|
129
|
+
* 与 lib/index.js 中的同名实现保持一致(两个入口行为必须相同)。
|
|
130
|
+
*/
|
|
131
|
+
function createFenceFilter() {
|
|
132
|
+
// 围栏字符 run 的长度上限:超过即按上限记录并进入代码块,避免无界缓冲。
|
|
133
|
+
const MAX_FENCE_RUN = 64
|
|
134
|
+
let atLineStart = true
|
|
135
|
+
let head = '' // 行首缓冲:最多 3 个空格 + 可能的围栏字符 run
|
|
136
|
+
let headSpaces = 0
|
|
137
|
+
let headChar = ''
|
|
138
|
+
let fence = null // { char, len }:已进入代码块
|
|
139
|
+
let closeLine = '' // 代码块内当前行(用于判定结束行)
|
|
140
|
+
let closeTooLong = false
|
|
141
|
+
|
|
142
|
+
const runLength = () => head.length - headSpaces
|
|
143
|
+
const resetHead = () => {
|
|
144
|
+
head = ''
|
|
145
|
+
headSpaces = 0
|
|
146
|
+
headChar = ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 当前行是否为结束行(同字符、不短于起始长度、其后仅空白)。 */
|
|
150
|
+
function isClosingLine(line) {
|
|
151
|
+
const body = line.replace(/[ \t]+$/, '')
|
|
152
|
+
const indent = body.length - body.replace(/^ {0,3}/, '').length
|
|
153
|
+
const run = body.slice(indent)
|
|
154
|
+
if (run.length < fence.len) return false
|
|
155
|
+
for (let i = 0; i < run.length; i++) {
|
|
156
|
+
if (run[i] !== fence.char) return false
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
/** 当前是否处于围栏代码块内(诊断/测试用)。 */
|
|
163
|
+
inside: () => fence !== null,
|
|
164
|
+
/**
|
|
165
|
+
* 消费一个增量,返回按「是否位于代码块内」切分的片段序列。
|
|
166
|
+
* 片段按原始顺序首尾相接即为完整增量,调用方据此分别用普通/放宽阈值检测。
|
|
167
|
+
* @param delta - 文本增量。
|
|
168
|
+
* @returns {Array<{ text: string, code: boolean }>} 片段序列(可能为空数组)。
|
|
169
|
+
*/
|
|
170
|
+
push(delta) {
|
|
171
|
+
// 快路径:不含围栏字符与换行的增量不可能改变围栏状态。
|
|
172
|
+
if (fence !== null && delta.indexOf(fence.char) === -1 && delta.indexOf('\n') === -1) {
|
|
173
|
+
return [{ text: delta, code: true }]
|
|
174
|
+
}
|
|
175
|
+
if (fence === null && !atLineStart &&
|
|
176
|
+
delta.indexOf('`') === -1 && delta.indexOf('~') === -1 && delta.indexOf('\n') === -1) {
|
|
177
|
+
return [{ text: delta, code: false }]
|
|
178
|
+
}
|
|
179
|
+
const runs = []
|
|
180
|
+
let buffer = ''
|
|
181
|
+
let bufferCode = fence !== null
|
|
182
|
+
const emit = (text, code) => {
|
|
183
|
+
if (text.length === 0) return
|
|
184
|
+
if (buffer.length > 0 && code !== bufferCode) {
|
|
185
|
+
runs.push({ text: buffer, code: bufferCode })
|
|
186
|
+
buffer = ''
|
|
187
|
+
}
|
|
188
|
+
bufferCode = code
|
|
189
|
+
buffer += text
|
|
190
|
+
}
|
|
191
|
+
for (let i = 0; i < delta.length; i++) {
|
|
192
|
+
const ch = delta[i]
|
|
193
|
+
if (fence !== null) {
|
|
194
|
+
// 代码块内:只判定结束行,内容整体按代码发射。
|
|
195
|
+
if (ch === '\n') {
|
|
196
|
+
const closing = isClosingLine(closeLine)
|
|
197
|
+
emit(ch, true)
|
|
198
|
+
closeLine = ''
|
|
199
|
+
closeTooLong = false
|
|
200
|
+
if (closing) {
|
|
201
|
+
fence = null
|
|
202
|
+
atLineStart = true
|
|
203
|
+
resetHead()
|
|
204
|
+
bufferCode = false
|
|
205
|
+
}
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (!closeTooLong) {
|
|
209
|
+
closeLine += ch
|
|
210
|
+
if (closeLine.length > fence.len + 8) closeTooLong = true
|
|
211
|
+
}
|
|
212
|
+
emit(ch, true)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (atLineStart) {
|
|
216
|
+
if (ch === ' ' && headChar === '' && headSpaces < 3) {
|
|
217
|
+
head += ch
|
|
218
|
+
headSpaces++
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if ((ch === '`' || ch === '~') && (headChar === '' || headChar === ch)) {
|
|
222
|
+
headChar = ch
|
|
223
|
+
head += ch
|
|
224
|
+
// 不能一见 3 个就进入:起始行的完整 run 才是围栏长度(```` 不能被 ``` 关闭)。
|
|
225
|
+
if (runLength() >= MAX_FENCE_RUN) {
|
|
226
|
+
emit(head, true)
|
|
227
|
+
fence = { char: ch, len: runLength() }
|
|
228
|
+
closeLine = ''
|
|
229
|
+
closeTooLong = false
|
|
230
|
+
atLineStart = false
|
|
231
|
+
resetHead()
|
|
232
|
+
}
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
if (headChar !== '' && runLength() >= 3) {
|
|
236
|
+
// 起始行(含行首空格与围栏符)整体属于代码块,长度取完整 run。
|
|
237
|
+
emit(head, true)
|
|
238
|
+
fence = { char: headChar, len: runLength() }
|
|
239
|
+
closeLine = ''
|
|
240
|
+
closeTooLong = false
|
|
241
|
+
atLineStart = false
|
|
242
|
+
resetHead()
|
|
243
|
+
emit(ch, true)
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
// 判定不是围栏起始:行首缓冲按普通文本交还。
|
|
247
|
+
emit(head, false)
|
|
248
|
+
resetHead()
|
|
249
|
+
atLineStart = ch === '\n'
|
|
250
|
+
emit(ch, false)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
emit(ch, false)
|
|
254
|
+
if (ch === '\n') {
|
|
255
|
+
atLineStart = true
|
|
256
|
+
resetHead()
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (buffer.length > 0) runs.push({ text: buffer, code: bufferCode })
|
|
260
|
+
return runs
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
106
265
|
/**
|
|
107
266
|
* 为一次 llm/stream 调用创建守卫。
|
|
108
267
|
* 每次模型调用都会新建一份状态,互不干扰。
|
|
@@ -122,6 +281,7 @@ function createStreamGuard(options) {
|
|
|
122
281
|
blockType,
|
|
123
282
|
text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口
|
|
124
283
|
stripped: '', // 去空白后的滚动窗口:仅用于检测
|
|
284
|
+
fence: createFenceFilter(), // 围栏代码块状态(skipCodeBlocks 开启时使用)
|
|
125
285
|
toolCallId: undefined,
|
|
126
286
|
toolCallName: undefined,
|
|
127
287
|
toolCallArguments: '',
|
|
@@ -131,12 +291,25 @@ function createStreamGuard(options) {
|
|
|
131
291
|
return b
|
|
132
292
|
}
|
|
133
293
|
|
|
134
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。
|
|
296
|
+
*
|
|
297
|
+
* 增量会先按围栏代码块切成片段:块外按 CONFIG.threshold 判定,块内按
|
|
298
|
+
* CONFIG.threshold × CONFIG.codeBlockMultiplier 判定(放宽,避免误杀正常代码)。
|
|
299
|
+
*/
|
|
135
300
|
function feedText(b, delta) {
|
|
136
301
|
b.text += delta
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
302
|
+
const codeThreshold = Math.max(CONFIG.threshold, CONFIG.threshold * CONFIG.codeBlockMultiplier)
|
|
303
|
+
const runs = CONFIG.skipCodeBlocks ? b.fence.push(delta) : [{ text: delta, code: false }]
|
|
304
|
+
for (const run of runs) {
|
|
305
|
+
const piece = sanitizeDelta(run.text, CONFIG)
|
|
306
|
+
if (piece.length === 0) continue
|
|
307
|
+
b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
|
|
308
|
+
const threshold = run.code ? codeThreshold : CONFIG.threshold
|
|
309
|
+
const hit = findRepeatedTail(b.stripped, threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
|
|
310
|
+
if (hit !== null) return { unit: hit.unit, count: hit.count, span: hit.span, code: run.code }
|
|
311
|
+
}
|
|
312
|
+
return null
|
|
140
313
|
}
|
|
141
314
|
|
|
142
315
|
/** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
|
|
@@ -227,7 +400,8 @@ function createStreamGuard(options) {
|
|
|
227
400
|
'[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model +
|
|
228
401
|
' unit=' + JSON.stringify(stopped.unit) +
|
|
229
402
|
' repeat>=' + String(stopped.count) +
|
|
230
|
-
' span=' + String(stopped.span) + 'chars'
|
|
403
|
+
' span=' + String(stopped.span) + 'chars' +
|
|
404
|
+
(stopped.code === true ? ' code-block=true(放宽阈值命中)' : '')
|
|
231
405
|
)
|
|
232
406
|
yield* closingChunks()
|
|
233
407
|
// 提前 return:for-await 会调用上游 iterator.return(),
|