dsh-dupguard 1.2.0 → 1.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 +40 -0
- package/README.md +31 -11
- package/lib/client.js +320 -117
- package/lib/index.js +109 -12
- package/package.json +2 -2
- package/plugin/host.js +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
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.3.0] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 全部检测参数可在设置页动态调整(此前仅白名单可改):`threshold`、`minUnitLength`、
|
|
10
|
+
`maxUnitLength`、`detectionWindow`、`stripWhitespace`、`monitorReasoning`、
|
|
11
|
+
`monitorToolArguments` 均注册进 `dsh-dupguard` 设置 schema(含上下界与非整数拒绝),
|
|
12
|
+
改动即时热生效并持久化到 `settings.yaml`;
|
|
13
|
+
- 设置页参数表单:数值输入(失焦或回车提交、范围与整数校验、跨字段校验)与原生风格开关,
|
|
14
|
+
写入仍走 `settingsScope` 控制器并回读快照校验;
|
|
15
|
+
- **窗口缺口提示**:`detectionWindow < threshold × maxUnitLength` 时,设置页显示
|
|
16
|
+
「⚠ 检测窗口长度需要提高:至少 N(当前 M = 阈值 × 最大单元),超过 K 字符的重复单元无法识别」,
|
|
17
|
+
宿主日志同时打印一条同义告警(只提示不拒绝写入);
|
|
18
|
+
- 「恢复默认」改为清空本页全部用户设置(逐字段 unset),回到代码默认值。
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- 宿主 `settings.register` 增加 `validate` 跨字段约束(最大单元长度不得小于最小单元长度),
|
|
23
|
+
违规写入被拒绝而非静默存入;
|
|
24
|
+
- 运行时设置同步改为逐字段归一化(类型/范围兜底,绝不抛错),非法外部编辑回落到代码默认值;
|
|
25
|
+
- 客户端组件改为全部 hook 前置,消除「加载中 → 就绪」状态切换时的 hook 数量变化风险。
|
|
26
|
+
|
|
27
|
+
## [1.2.1] - 2026-09-02
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- 设置页写入在 DSH 0.1.2 上完全失效:0.1.2 的客户端 `connection` 服务不再暴露
|
|
32
|
+
`api`(wire 面改为 `ctx.remote.<namespace>`),此前的直连 RPC 写法在 0.1.2 上
|
|
33
|
+
同步抛错——表现为点击「添加」后列表乐观更新、底部停在「保存中…」,写入从未落盘、
|
|
34
|
+
关闭重开不显示、白名单也不生效。现改为经 `settingsScope` 控制器写
|
|
35
|
+
(`set` / `unset`:自动携带最新 revision、串行化并发写、把宿主应答折叠回共享镜像),
|
|
36
|
+
写完回读快照校验后显示「已保存」或失败原因;该控制器接口在 0.1.1 与 0.1.2 一致,
|
|
37
|
+
因此同时兼容两版。
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `tests/client.test.js`:用最小 React / DSH 客户端桩直接驱动设置页组件,覆盖写路径
|
|
42
|
+
(控制器 `set`/`unset`)、「保存中…→已保存」回显、重开显示持久化白名单,并断言组件
|
|
43
|
+
不再依赖 `connection.api`;`npm test` 与 CI 改为同时运行两个测试文件。
|
|
44
|
+
|
|
5
45
|
## [1.2.0] - 2026-09-02
|
|
6
46
|
|
|
7
47
|
### Added
|
package/README.md
CHANGED
|
@@ -24,11 +24,13 @@ When triggered, the already-generated text is committed as a normal assistant me
|
|
|
24
24
|
- **安全停止**:绝不 `abort()` agent 步骤信号;补发协议合规的 `block-end` + `finish(stop)`,消息正常提交。
|
|
25
25
|
- **Markdown 表格友好**:默认忽略连字符与竖线(`ignoredChars` 白名单),表格分隔行与长分隔线不会被误判为复读。
|
|
26
26
|
- **图形化设置页**(npm 常驻版):在 DSH 设置面板注册与「通用设置 / 模型 / 插件 / Agent 预设」并列的
|
|
27
|
-
|
|
28
|
-
-
|
|
27
|
+
「重复守卫」分节,可视化编辑白名单与全部检测参数(阈值、最小/最大单元长度、检测窗口、空白与
|
|
28
|
+
reasoning、工具参数开关)并持久化(`dsh-dupguard` 设置命名空间),修改即时生效;窗口小于
|
|
29
|
+
阈值 × 最大单元长度时给出「窗口长度需要提高」提示。
|
|
30
|
+
- **零配置开箱即用**:默认配置即可用;全部检测参数均可在设置页按需调整。
|
|
29
31
|
- **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js` + `lib/client.js`),行为一致、CI 防漂移。
|
|
30
32
|
- **内置 DSH 兼容补丁**(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
|
|
31
|
-
修复 DSH ≤ 0.1.
|
|
33
|
+
修复 DSH ≤ 0.1.2-rc.1 的 preset standing-mount 多代并存冲突(见下文"已知限制")。
|
|
32
34
|
|
|
33
35
|
---
|
|
34
36
|
|
|
@@ -111,21 +113,34 @@ loader's `unwrapExports`, no build step).
|
|
|
111
113
|
|
|
112
114
|
## 配置 / Configuration
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
检测参数的默认值定义在 `plugin/host.js` 与 `lib/index.js` 顶部的 `CONFIG`(两个入口需保持同步,
|
|
117
|
+
CI 会校验行为一致性)。**npm 常驻版**:下表参数除 `fixStandingMountConflict` 外全部可在设置页
|
|
118
|
+
「重复守卫」分节中动态调整并持久化到 `settings.yaml`,改动即时生效(动态版固定取常量)。
|
|
115
119
|
|
|
116
|
-
|
|
120
|
+
Defaults live in the `CONFIG` block of both entries (CI verifies behavioral parity). In the npm build
|
|
121
|
+
every option below except `fixStandingMountConflict` is editable from the "Dupguard" settings section
|
|
122
|
+
and persisted to `settings.yaml`; the dynamic build uses the constants.
|
|
117
123
|
|
|
118
124
|
| 配置项 / Option | 默认 / Default | 说明 / Description |
|
|
119
125
|
| --- | --- | --- |
|
|
120
126
|
| `threshold` | `10` | 触发阈值:同一字符串连续重复 ≥ 该值时停止 / stop when the same string repeats ≥ this many times |
|
|
121
127
|
| `minUnitLength` | `1` | 最小重复单元长度 / minimum repeating-unit length (`1` also catches single-char loops like `aaaaaaaaaa`) |
|
|
122
128
|
| `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
|
|
123
|
-
| `detectionWindow` | `8192` |
|
|
129
|
+
| `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后),需 ≥ 阈值 × 最大单元长度 / rolling detection window in chars (after whitespace removal); must be ≥ threshold × max unit length |
|
|
124
130
|
| `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
|
|
125
|
-
| `ignoredChars` | `['-', '\|']` |
|
|
131
|
+
| `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown 表格分隔行(连字符与竖线)不参与重复统计 / whitelist of characters ignored during detection, so Markdown table separators don't count |
|
|
126
132
|
| `monitorReasoning` | `true` | 是否检测思考文本(思考中的复读同样消耗 token,默认截停;只检测可见输出时置 `false`)/ also guard reasoning (thinking) text — on by default; set `false` to guard visible output only |
|
|
127
133
|
| `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
|
|
128
|
-
| `fixStandingMountConflict` | `true` | DSH ≤ 0.1.
|
|
134
|
+
| `fixStandingMountConflict` | `true` | DSH ≤ 0.1.2-rc.1 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突(仅代码常量)/ idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.2-rc.1 standing-mount conflict (code constant only) |
|
|
135
|
+
|
|
136
|
+
**窗口约束 / Window constraint**:`detectionWindow` 必须 ≥ `threshold × maxUnitLength`;否则长度超过
|
|
137
|
+
`floor(detectionWindow / threshold)` 的重复单元凑不满重复次数,无法被识别(例如窗口 80、阈值 10 时,
|
|
138
|
+
超过 8 字符的单元不再触发)。设置页在该约束被违反时显示
|
|
139
|
+
「⚠ 检测窗口长度需要提高:至少 N(当前 M = 阈值 × 最大单元)」的提示,宿主日志同时打印一条告警;
|
|
140
|
+
该约束**只提示不拒绝写入**,便于按需权衡内存占用与可识别单元长度。
|
|
141
|
+
|
|
142
|
+
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.
|
|
129
144
|
|
|
130
145
|
---
|
|
131
146
|
|
|
@@ -197,6 +212,7 @@ default), Markdown table separator rows and horizontal rules (whitelisted by def
|
|
|
197
212
|
│ └── client.js # 浏览器端设置页(ModuleLoader 格式,dsh.client 入口)
|
|
198
213
|
├── tests/
|
|
199
214
|
│ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings 集成
|
|
215
|
+
│ ├── client.test.js # 设置页组件测试:最小 React/DSH 桩驱动写路径
|
|
200
216
|
│ └── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
|
|
201
217
|
├── .github/workflows/ci.yml # GitHub Actions:Node 20/22/24
|
|
202
218
|
├── cordis.patch.yml # bundle 补丁层(dsh.bundle.patch:插入宿主行)
|
|
@@ -209,13 +225,17 @@ default), Markdown table separator rows and horizontal rules (whitelisted by def
|
|
|
209
225
|
## 测试 / Tests
|
|
210
226
|
|
|
211
227
|
```bash
|
|
212
|
-
|
|
228
|
+
npm test # 两个测试文件
|
|
229
|
+
node tests/detector.test.js # 检测端到端(48 项)
|
|
230
|
+
node tests/client.test.js # 设置页组件(6 项)
|
|
213
231
|
```
|
|
214
232
|
|
|
215
233
|
同一套 15 项用例分别驱动两个入口(`plugin/host.js` 经 `new Function` 求值、`lib/index.js` 经
|
|
216
234
|
`require` 加载),覆盖:透传完整性、各类复读形态、阈值边界、协议闭合、上游 `return()` 调用、
|
|
217
|
-
默认不检测 reasoning
|
|
218
|
-
|
|
235
|
+
默认不检测 reasoning/工具参数、未闭合工具调用块的闭合、多次调用状态隔离等。`client.test.js`
|
|
236
|
+
用最小 React 与 DSH 客户端桩驱动设置页组件,断言写入走 `settingsScope` 控制器
|
|
237
|
+
(`set`/`unset`)而非已移除的 `connection.api`。CI 在 Node 20/22/24 上运行
|
|
238
|
+
(与 DSH 一致,不支持 Node 18)。
|
|
219
239
|
|
|
220
240
|
The same 15-test suite drives both entries, guarding against drift between the two forms. CI runs on
|
|
221
241
|
Node 20/22/24 (matching DSH; Node 18 is not supported).
|
package/lib/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* dupguard 浏览器端 bundle(单文件,经 window.__ModuleLoader__ 加载)。
|
|
3
3
|
*
|
|
4
4
|
* 在 DSH 设置面板注册一个与「通用设置 / 模型 / 插件 / Agent 预设」并列的
|
|
5
|
-
*
|
|
5
|
+
* 分节「重复守卫」:白名单(检测时忽略的字符)与全部检测参数的可视化编辑界面。
|
|
6
6
|
* 数据经 settingsScope 绑定宿主 settings 服务的 "dsh-dupguard" 命名空间,
|
|
7
7
|
* 修改即时生效并持久化(settings.yaml)。
|
|
8
8
|
*
|
|
@@ -23,29 +23,73 @@ window.__ModuleLoader__.load({
|
|
|
23
23
|
const zh = {
|
|
24
24
|
nav: '重复守卫',
|
|
25
25
|
title: '重复输出守卫',
|
|
26
|
-
intro: '
|
|
27
|
-
list: '
|
|
26
|
+
intro: '模型输出中同一字符串连续重复达到阈值次数时自动截停。以下参数修改后即时生效并持久化(settings.yaml)。',
|
|
27
|
+
list: '忽略字符(白名单)',
|
|
28
28
|
empty: '白名单为空:所有字符都参与重复统计。',
|
|
29
29
|
addPlaceholder: '输入要忽略的字符',
|
|
30
30
|
add: '添加',
|
|
31
|
+
params: '检测参数',
|
|
32
|
+
threshold: '触发阈值(连续重复次数)',
|
|
33
|
+
thresholdHint: '同一字符串连续重复达到该次数即截停(≥ 语义)。范围 {min}–{max}。',
|
|
34
|
+
minUnitLength: '最小重复单元长度',
|
|
35
|
+
minUnitLengthHint: '参与检测的重复单元最小字符数。范围 {min}–{max};设为 1 可捕获单字符循环。',
|
|
36
|
+
maxUnitLength: '最大重复单元长度',
|
|
37
|
+
maxUnitLengthHint: '可识别的最长重复单元(清洗后字符数,空白与白名单字符不计)。范围 {min}–{max}。',
|
|
38
|
+
detectionWindow: '检测窗口(字符)',
|
|
39
|
+
detectionWindowHint: '检测缓冲保留的字符数。范围 {min}–{max};需 ≥ 阈值 × 最大单元长度,否则长单元凑不满重复次数。',
|
|
40
|
+
stripWhitespace: '忽略空白字符',
|
|
41
|
+
stripWhitespaceHint: '检测前移除所有空白(含换行),使「重复 重复」「重复\\n重复」这类带分隔的复读也能识别。',
|
|
42
|
+
monitorReasoning: '检测思考内容(reasoning)',
|
|
43
|
+
monitorReasoningHint: '思考中的复读同样消耗 token,默认一并检测。',
|
|
44
|
+
monitorToolArguments: '检测工具调用参数',
|
|
45
|
+
monitorToolArgumentsHint: '默认关闭:JSON / base64 参数中重复字符很常见。',
|
|
31
46
|
reset: '恢复默认',
|
|
47
|
+
note: '「恢复默认」清空本页全部用户设置,回到代码默认值。',
|
|
48
|
+
saving: '保存中…',
|
|
49
|
+
saved: '已保存',
|
|
50
|
+
saveFailed: '保存失败:宿主未接受该值,请检查取值或查看 DSH 宿主日志。',
|
|
51
|
+
errInteger: '请输入整数。',
|
|
52
|
+
errRange: '取值范围 {min}–{max}。',
|
|
53
|
+
errCross: '不能小于最小重复单元长度(当前 {min})。',
|
|
54
|
+
windowWarn: '⚠ 检测窗口长度需要提高:至少 {need}(当前 {current} = 阈值 {threshold} × 最大单元 {maxUnit})。当前窗口下超过 {effective} 字符的重复单元无法识别。',
|
|
32
55
|
loading: '加载中…',
|
|
33
|
-
|
|
34
|
-
remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,白名单将使用默认值(连字符与竖线)。',
|
|
56
|
+
remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,检测参数将使用默认值。',
|
|
35
57
|
unavailable: '设置服务不可用:未读取到「重复守卫」的设置数据。请确认 DSH 为 npm 常驻版挂载(宿主日志应包含「已注册设置命名空间 dsh-dupguard」),并刷新页面重试。',
|
|
36
58
|
}
|
|
37
59
|
const en = {
|
|
38
60
|
nav: 'Dupguard',
|
|
39
61
|
title: 'Repetition Guard',
|
|
40
|
-
intro: 'Generation stops when the same string repeats
|
|
41
|
-
list: '
|
|
62
|
+
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)',
|
|
42
64
|
empty: 'Whitelist is empty: every character counts.',
|
|
43
65
|
addPlaceholder: 'Character to ignore',
|
|
44
66
|
add: 'Add',
|
|
67
|
+
params: 'Detection parameters',
|
|
68
|
+
threshold: 'Threshold (consecutive repeats)',
|
|
69
|
+
thresholdHint: 'Stop once a string repeats this many times in a row (>= semantics). Range {min}–{max}.',
|
|
70
|
+
minUnitLength: 'Minimum repeating-unit length',
|
|
71
|
+
minUnitLengthHint: 'Shortest repeating unit considered, in characters. Range {min}–{max}; 1 catches single-character loops.',
|
|
72
|
+
maxUnitLength: 'Maximum repeating-unit length',
|
|
73
|
+
maxUnitLengthHint: 'Longest repeating unit recognized, in characters after cleaning (whitespace and whitelisted characters are removed). Range {min}–{max}.',
|
|
74
|
+
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.',
|
|
76
|
+
stripWhitespace: 'Ignore whitespace',
|
|
77
|
+
stripWhitespaceHint: 'Remove all whitespace (newlines included) before detection, so separated repeats are still recognized.',
|
|
78
|
+
monitorReasoning: 'Monitor reasoning text',
|
|
79
|
+
monitorReasoningHint: 'Repeats inside reasoning burn tokens too, so they are monitored by default.',
|
|
80
|
+
monitorToolArguments: 'Monitor tool-call arguments',
|
|
81
|
+
monitorToolArgumentsHint: 'Off by default: repeated characters are common in JSON / base64 arguments.',
|
|
45
82
|
reset: 'Reset to defaults',
|
|
83
|
+
note: '"Reset to defaults" clears every user setting on this page and restores the code defaults.',
|
|
84
|
+
saving: 'Saving…',
|
|
85
|
+
saved: 'Saved',
|
|
86
|
+
saveFailed: 'Save failed: the host did not accept the value; check the input or the DSH host log.',
|
|
87
|
+
errInteger: 'Enter an integer.',
|
|
88
|
+
errRange: 'Allowed range {min}–{max}.',
|
|
89
|
+
errCross: 'Must not be smaller than the minimum unit length (currently {min}).',
|
|
90
|
+
windowWarn: '⚠ Detection window is too small: needs at least {need} (currently {current} = threshold {threshold} x max unit {maxUnit}). Units longer than {effective} characters cannot be detected with the current window.',
|
|
46
91
|
loading: 'Loading…',
|
|
47
|
-
|
|
48
|
-
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 the whitelist stays at its defaults (hyphen and pipe).',
|
|
92
|
+
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.',
|
|
49
93
|
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.',
|
|
50
94
|
}
|
|
51
95
|
|
|
@@ -64,6 +108,21 @@ window.__ModuleLoader__.load({
|
|
|
64
108
|
'.dg-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
65
109
|
'.dg-note{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}',
|
|
66
110
|
'.dg-empty{color:var(--dsw-alias-label-tertiary);margin:0}',
|
|
111
|
+
'.dg-fields{display:flex;flex-direction:column;gap:12px}',
|
|
112
|
+
'.dg-field{display:flex;flex-direction:column;gap:4px}',
|
|
113
|
+
'.dg-field-head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
|
|
114
|
+
'.dg-field-label{color:var(--dsw-alias-label-primary);font-size:13px}',
|
|
115
|
+
'.dg-field-hint{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}',
|
|
116
|
+
'.dg-field-error{color:var(--dsw-alias-state-error-primary);font-size:12px;margin:0}',
|
|
117
|
+
'.dg-num{box-sizing:border-box;width:110px;height:32px;padding:0 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit;text-align:right;outline:none}',
|
|
118
|
+
'.dg-num:focus{border-color:var(--dsw-alias-brand-primary)}',
|
|
119
|
+
'.dg-switch{box-sizing:border-box;width:36px;height:20px;flex:none;padding:2px;border:0;border-radius:10px;background:var(--dsw-alias-border-l2);cursor:pointer}',
|
|
120
|
+
'.dg-switch-on{background:var(--dsw-alias-brand-primary)}',
|
|
121
|
+
'.dg-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .12s}',
|
|
122
|
+
'.dg-switch-on .dg-switch-thumb{transform:translateX(16px)}',
|
|
123
|
+
'.dg-warn{color:var(--dsw-alias-state-warn-primary);font-size:12px;margin:0}',
|
|
124
|
+
'.dg-error{color:var(--dsw-alias-state-error-primary)}',
|
|
125
|
+
'.dg-footer{display:flex;align-items:center;gap:12px;flex-wrap:wrap}',
|
|
67
126
|
].join('\n')
|
|
68
127
|
|
|
69
128
|
if (typeof document !== 'undefined') {
|
|
@@ -76,18 +135,117 @@ window.__ModuleLoader__.load({
|
|
|
76
135
|
|
|
77
136
|
const inject = ['slots', 'locale', 'connection', 'settingsScope']
|
|
78
137
|
|
|
138
|
+
// ---------------------------------------------------------------------
|
|
139
|
+
// 字段元数据:与宿主 lib/index.js 的 LIMITS / CONFIG 必须保持一致。
|
|
140
|
+
// ---------------------------------------------------------------------
|
|
141
|
+
const NUMERIC_FIELDS = [
|
|
142
|
+
{ key: 'threshold', min: 2, max: 1000 },
|
|
143
|
+
{ key: 'minUnitLength', min: 1, max: 4096 },
|
|
144
|
+
{ key: 'maxUnitLength', min: 1, max: 8192 },
|
|
145
|
+
{ key: 'detectionWindow', min: 64, max: 1048576 },
|
|
146
|
+
]
|
|
147
|
+
const BOOL_FIELDS = ['stripWhitespace', 'monitorReasoning', 'monitorToolArguments']
|
|
148
|
+
const DEFAULTS = {
|
|
149
|
+
ignoredChars: ['-', '|'],
|
|
150
|
+
threshold: 10,
|
|
151
|
+
minUnitLength: 1,
|
|
152
|
+
maxUnitLength: 80,
|
|
153
|
+
detectionWindow: 8192,
|
|
154
|
+
stripWhitespace: true,
|
|
155
|
+
monitorReasoning: true,
|
|
156
|
+
monitorToolArguments: false,
|
|
157
|
+
}
|
|
158
|
+
const ALL_FIELDS = ['ignoredChars'].concat(NUMERIC_FIELDS.map((field) => field.key), BOOL_FIELDS)
|
|
159
|
+
|
|
160
|
+
/** 模板占位符替换:{name} → vars.name。 */
|
|
161
|
+
const fmt = (text, vars) =>
|
|
162
|
+
String(text).replace(/\{(\w+)\}/g, (match, key) => (vars[key] === undefined ? match : String(vars[key])))
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 把宿主设置值归一化为完整形态:缺失或越界的字段回落默认,
|
|
166
|
+
* 保证任何异常形状到达视图时仍是 ready 且可渲染。
|
|
167
|
+
*/
|
|
168
|
+
function normalizeValue(raw) {
|
|
169
|
+
const out = { ignoredChars: [...DEFAULTS.ignoredChars] }
|
|
170
|
+
for (const field of NUMERIC_FIELDS) out[field.key] = DEFAULTS[field.key]
|
|
171
|
+
for (const key of BOOL_FIELDS) out[key] = DEFAULTS[key]
|
|
172
|
+
try {
|
|
173
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
174
|
+
if (Array.isArray(raw.ignoredChars)) {
|
|
175
|
+
out.ignoredChars = raw.ignoredChars.filter((ch) => typeof ch === 'string')
|
|
176
|
+
}
|
|
177
|
+
for (const field of NUMERIC_FIELDS) {
|
|
178
|
+
const value = raw[field.key]
|
|
179
|
+
if (Number.isSafeInteger(value) && value >= field.min && value <= field.max) out[field.key] = value
|
|
180
|
+
}
|
|
181
|
+
for (const key of BOOL_FIELDS) {
|
|
182
|
+
if (typeof raw[key] === 'boolean') out[key] = raw[key]
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (_normalizeError) {}
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 归一化值为编辑表单:数值字段存字符串,便于输入中途的状态。 */
|
|
190
|
+
function toForm(value) {
|
|
191
|
+
const normalized = normalizeValue(value)
|
|
192
|
+
const form = { ignoredChars: normalized.ignoredChars }
|
|
193
|
+
for (const field of NUMERIC_FIELDS) form[field.key] = String(normalized[field.key])
|
|
194
|
+
for (const key of BOOL_FIELDS) form[key] = normalized[key]
|
|
195
|
+
return form
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 从输入文本解析整数(仅接受纯整数,避免 1e3 / 3.5 这类写法)。 */
|
|
199
|
+
function parseInt10(text) {
|
|
200
|
+
const value = typeof text === 'string' ? text.trim() : String(text)
|
|
201
|
+
return /^-?\d+$/.test(value) ? Number(value) : NaN
|
|
202
|
+
}
|
|
203
|
+
|
|
79
204
|
function DupguardSection(props) {
|
|
80
205
|
const controller = props.controller
|
|
81
206
|
const t = props.t
|
|
207
|
+
|
|
208
|
+
// 全部 hook 在早退之前调用,保证 loading → ready 的状态切换不改变 hook 数量。
|
|
82
209
|
const snap = React.useSyncExternalStore(
|
|
83
210
|
(cb) => controller.subscribe(cb),
|
|
84
211
|
() => controller.getSnapshot(),
|
|
85
212
|
)
|
|
86
|
-
const [draft, setDraft] = React.useState('')
|
|
87
213
|
const mirrorSnap = React.useSyncExternalStore(
|
|
88
214
|
(cb) => props.mirror.subscribe(cb),
|
|
89
215
|
() => props.mirror.getSnapshot(),
|
|
90
216
|
)
|
|
217
|
+
const [draft, setDraft] = React.useState('')
|
|
218
|
+
const [form, setForm] = React.useState(null)
|
|
219
|
+
const [writeState, setWriteState] = React.useState(null)
|
|
220
|
+
const [errors, setErrors] = React.useState({})
|
|
221
|
+
const dirty = React.useRef({})
|
|
222
|
+
const lastRemote = React.useRef(null)
|
|
223
|
+
|
|
224
|
+
// 每次打开本页都强制重拉镜像:保证显示的是宿主持久化的最新值。
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
if (props.mirror && typeof props.mirror.load === 'function') {
|
|
227
|
+
props.mirror.load()
|
|
228
|
+
}
|
|
229
|
+
}, [])
|
|
230
|
+
|
|
231
|
+
// 同步模型:手动修改过的字段本地自治(免疫旧值回传),其余字段跟随镜像快照。
|
|
232
|
+
const ready = snap.status === 'ready' && snap.value !== undefined
|
|
233
|
+
const remoteForm = ready ? toForm(snap.value) : null
|
|
234
|
+
if (ready) {
|
|
235
|
+
const signature = JSON.stringify(remoteForm)
|
|
236
|
+
if (form === null) {
|
|
237
|
+
lastRemote.current = signature
|
|
238
|
+
setForm(remoteForm)
|
|
239
|
+
} else if (lastRemote.current !== signature) {
|
|
240
|
+
lastRemote.current = signature
|
|
241
|
+
const next = {}
|
|
242
|
+
for (const key of ALL_FIELDS) {
|
|
243
|
+
next[key] = dirty.current[key] === true ? form[key] : remoteForm[key]
|
|
244
|
+
}
|
|
245
|
+
setForm(next)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const shown = form === null ? remoteForm : form
|
|
91
249
|
|
|
92
250
|
if (props.isLoopback === false) {
|
|
93
251
|
return React.createElement('div', { className: 'dg-section' },
|
|
@@ -99,7 +257,7 @@ window.__ModuleLoader__.load({
|
|
|
99
257
|
React.createElement('h2', { className: 'dg-title' }, t('title')),
|
|
100
258
|
React.createElement('p', { className: 'dg-intro' }, t('unavailable')))
|
|
101
259
|
}
|
|
102
|
-
if (
|
|
260
|
+
if (!ready || shown === null) {
|
|
103
261
|
const mirrorError = mirrorSnap && mirrorSnap.error ? String(mirrorSnap.error) : null
|
|
104
262
|
const mirrorStatus = mirrorSnap ? String(mirrorSnap.status) : 'unknown'
|
|
105
263
|
return React.createElement('div', { className: 'dg-section' },
|
|
@@ -108,108 +266,153 @@ window.__ModuleLoader__.load({
|
|
|
108
266
|
'mirror=' + mirrorStatus + (mirrorError === null ? '' : ' error=' + mirrorError)))
|
|
109
267
|
}
|
|
110
268
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
lastRemote.current = remoteList
|
|
119
|
-
setList(remoteList)
|
|
269
|
+
// ---- 取值校验:逐字段范围 + 跨字段(最大单元 ≥ 最小单元) ----
|
|
270
|
+
const parsed = {}
|
|
271
|
+
let numericValid = true
|
|
272
|
+
for (const field of NUMERIC_FIELDS) {
|
|
273
|
+
const value = parseInt10(shown[field.key])
|
|
274
|
+
parsed[field.key] = value
|
|
275
|
+
if (!Number.isInteger(value) || value < field.min || value > field.max) numericValid = false
|
|
120
276
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
277
|
+
const crossInvalid = Number.isInteger(parsed.minUnitLength) && Number.isInteger(parsed.maxUnitLength) &&
|
|
278
|
+
parsed.maxUnitLength < parsed.minUnitLength
|
|
123
279
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
280
|
+
const fieldError = (field) => {
|
|
281
|
+
const value = parsed[field.key]
|
|
282
|
+
if (!Number.isInteger(value)) return t('errInteger')
|
|
283
|
+
if (value < field.min || value > field.max) return fmt(t('errRange'), { min: field.min, max: field.max })
|
|
284
|
+
if (field.key === 'maxUnitLength' && crossInvalid) return fmt(t('errCross'), { min: parsed.minUnitLength })
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
130
287
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
...(revision === undefined ? {} : { expectedRevision: revision }),
|
|
140
|
-
}).then((response) => {
|
|
141
|
-
if (!response || !response.result || !response.result.ok) {
|
|
142
|
-
const message = response && response.result && response.result.error ? response.result.error.message : 'unknown'
|
|
143
|
-
setWriteState('error:' + message)
|
|
144
|
-
} else {
|
|
145
|
-
setWriteState('saved')
|
|
146
|
-
// 写成功后重拉 describe 刷新镜像,保证重开设置页显示持久化值。
|
|
147
|
-
if (props.mirror && typeof props.mirror.load === 'function') {
|
|
148
|
-
props.mirror.load()
|
|
149
|
-
}
|
|
288
|
+
// 窗口缺口提示:窗口 < 阈值 × 最大单元长度时,更长的单元无法凑满重复次数。
|
|
289
|
+
const shortfall = numericValid && !crossInvalid && parsed.detectionWindow < parsed.threshold * parsed.maxUnitLength
|
|
290
|
+
? {
|
|
291
|
+
need: parsed.threshold * parsed.maxUnitLength,
|
|
292
|
+
current: parsed.detectionWindow,
|
|
293
|
+
threshold: parsed.threshold,
|
|
294
|
+
maxUnit: parsed.maxUnitLength,
|
|
295
|
+
effective: Math.floor(parsed.detectionWindow / parsed.threshold),
|
|
150
296
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
297
|
+
: null
|
|
298
|
+
|
|
299
|
+
// ---- 写路径:settingsScope 控制器(DSH 自己的设置写通道,自动携带 revision、
|
|
300
|
+
// 串行化并发写、把宿主应答折叠回镜像)。DSH 0.1.2 起客户端不再暴露
|
|
301
|
+
// connection.api,控制器接口自 0.1.1 起稳定,故不直接触碰 wire 面。 ----
|
|
302
|
+
const snapshotValue = () => {
|
|
303
|
+
const current = controller.getSnapshot()
|
|
304
|
+
return current !== undefined && current.value !== undefined ? current.value : {}
|
|
154
305
|
}
|
|
155
|
-
const
|
|
306
|
+
const sameList = (a, b) => Array.isArray(a) && Array.isArray(b) &&
|
|
307
|
+
a.length === b.length && a.every((item, index) => item === b[index])
|
|
308
|
+
const currentForm = () => (form === null ? remoteForm : form)
|
|
309
|
+
const runWrite = (operation, verify) => {
|
|
156
310
|
setWriteState('saving')
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}).then((response) => {
|
|
163
|
-
if (!response || !response.result || !response.result.ok) {
|
|
164
|
-
const message = response && response.result && response.result.error ? response.result.error.message : 'unknown'
|
|
165
|
-
setWriteState('error:' + message)
|
|
166
|
-
} else {
|
|
167
|
-
setWriteState('saved')
|
|
168
|
-
if (props.mirror && typeof props.mirror.load === 'function') {
|
|
169
|
-
props.mirror.load()
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}, (error) => {
|
|
173
|
-
setWriteState('error:' + String(error && error.message || error))
|
|
174
|
-
})
|
|
311
|
+
Promise.resolve()
|
|
312
|
+
.then(() => operation())
|
|
313
|
+
.then(() => setWriteState(verify() ? 'saved' : 'error:' + t('saveFailed')), (error) => {
|
|
314
|
+
setWriteState('error:' + String((error && error.message) || error))
|
|
315
|
+
})
|
|
175
316
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
317
|
+
const commitList = (next) => {
|
|
318
|
+
dirty.current.ignoredChars = true
|
|
319
|
+
setForm({ ...currentForm(), ignoredChars: next })
|
|
320
|
+
runWrite(() => controller.set('ignoredChars', next), () => sameList(snapshotValue().ignoredChars, next))
|
|
321
|
+
}
|
|
322
|
+
const commitNumber = (field) => {
|
|
323
|
+
const message = fieldError(field)
|
|
324
|
+
if (message !== null) {
|
|
325
|
+
setErrors((prev) => ({ ...prev, [field.key]: message }))
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
const value = parsed[field.key]
|
|
329
|
+
dirty.current[field.key] = true
|
|
330
|
+
setErrors((prev) => ({ ...prev, [field.key]: null }))
|
|
331
|
+
setForm({ ...currentForm(), [field.key]: String(value) })
|
|
332
|
+
runWrite(() => controller.set(field.key, value), () => snapshotValue()[field.key] === value)
|
|
333
|
+
}
|
|
334
|
+
const commitBool = (key) => {
|
|
335
|
+
const value = shown[key] !== true
|
|
336
|
+
dirty.current[key] = true
|
|
337
|
+
setForm({ ...currentForm(), [key]: value })
|
|
338
|
+
runWrite(() => controller.set(key, value), () => snapshotValue()[key] === value)
|
|
182
339
|
}
|
|
340
|
+
const resetAll = () => {
|
|
341
|
+
dirty.current = {}
|
|
342
|
+
setErrors({})
|
|
343
|
+
runWrite(
|
|
344
|
+
() => Promise.all(ALL_FIELDS.map((key) => controller.unset(key))),
|
|
345
|
+
() => {
|
|
346
|
+
setForm(toForm(snapshotValue()))
|
|
347
|
+
const current = controller.getSnapshot()
|
|
348
|
+
const user = current !== undefined ? current.user : undefined
|
|
349
|
+
if (user === undefined || user === null) return true
|
|
350
|
+
return ALL_FIELDS.every((key) => user[key] === undefined)
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
183
355
|
const add = () => {
|
|
184
356
|
const ch = draft.trim()
|
|
185
357
|
if (ch.length === 0) return
|
|
186
|
-
if (shown.indexOf(ch) !== -1) {
|
|
187
|
-
setDraft('')
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
const next = [...shown, ch]
|
|
191
|
-
touched.current = true
|
|
192
|
-
setList(next)
|
|
193
|
-
persist(next)
|
|
194
358
|
setDraft('')
|
|
359
|
+
if (shown.ignoredChars.indexOf(ch) !== -1) return
|
|
360
|
+
commitList([...shown.ignoredChars, ch])
|
|
195
361
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
362
|
+
const remove = (ch) => commitList(shown.ignoredChars.filter((item) => item !== ch))
|
|
363
|
+
|
|
364
|
+
const fieldRow = (field) => {
|
|
365
|
+
const message = errors[field.key] !== undefined && errors[field.key] !== null
|
|
366
|
+
? errors[field.key]
|
|
367
|
+
: fieldError(field)
|
|
368
|
+
return React.createElement('div', { className: 'dg-field', key: field.key },
|
|
369
|
+
React.createElement('div', { className: 'dg-field-head' },
|
|
370
|
+
React.createElement('label', { className: 'dg-field-label', htmlFor: 'dg-' + field.key }, t(field.key)),
|
|
371
|
+
React.createElement('input', {
|
|
372
|
+
id: 'dg-' + field.key,
|
|
373
|
+
className: 'dg-num',
|
|
374
|
+
type: 'number',
|
|
375
|
+
min: field.min,
|
|
376
|
+
max: field.max,
|
|
377
|
+
step: 1,
|
|
378
|
+
value: shown[field.key],
|
|
379
|
+
onChange: (event) => {
|
|
380
|
+
const value = event.target.value
|
|
381
|
+
setForm({ ...currentForm(), [field.key]: value })
|
|
382
|
+
setErrors((prev) => ({ ...prev, [field.key]: null }))
|
|
383
|
+
},
|
|
384
|
+
onBlur: () => commitNumber(field),
|
|
385
|
+
onKeyDown: (event) => {
|
|
386
|
+
if (event.key === 'Enter') commitNumber(field)
|
|
387
|
+
},
|
|
388
|
+
})),
|
|
389
|
+
React.createElement('p', { className: 'dg-field-hint' },
|
|
390
|
+
fmt(t(field.key + 'Hint'), { min: field.min, max: field.max })),
|
|
391
|
+
message === null ? null : React.createElement('p', { className: 'dg-field-error' }, message),
|
|
392
|
+
)
|
|
203
393
|
}
|
|
394
|
+
const switchRow = (key) => React.createElement('div', { className: 'dg-field', key: key },
|
|
395
|
+
React.createElement('div', { className: 'dg-field-head' },
|
|
396
|
+
React.createElement('span', { className: 'dg-field-label' }, t(key)),
|
|
397
|
+
React.createElement('button', {
|
|
398
|
+
className: 'dg-switch' + (shown[key] === true ? ' dg-switch-on' : ''),
|
|
399
|
+
type: 'button',
|
|
400
|
+
role: 'switch',
|
|
401
|
+
'aria-checked': shown[key] === true,
|
|
402
|
+
onClick: () => commitBool(key),
|
|
403
|
+
}, React.createElement('span', { className: 'dg-switch-thumb' }))),
|
|
404
|
+
React.createElement('p', { className: 'dg-field-hint' }, t(key + 'Hint')),
|
|
405
|
+
)
|
|
204
406
|
|
|
205
407
|
return React.createElement('div', { className: 'dg-section' },
|
|
206
408
|
React.createElement('h2', { className: 'dg-title' }, t('title')),
|
|
207
409
|
React.createElement('p', { className: 'dg-intro' }, t('intro')),
|
|
410
|
+
|
|
208
411
|
React.createElement('p', { className: 'dg-note' }, t('list')),
|
|
209
|
-
shown.length === 0
|
|
412
|
+
shown.ignoredChars.length === 0
|
|
210
413
|
? React.createElement('p', { className: 'dg-empty' }, t('empty'))
|
|
211
414
|
: React.createElement('div', { className: 'dg-chips' },
|
|
212
|
-
shown.map((ch) => React.createElement('span', { className: 'dg-chip', key: ch },
|
|
415
|
+
shown.ignoredChars.map((ch) => React.createElement('span', { className: 'dg-chip', key: ch },
|
|
213
416
|
ch,
|
|
214
417
|
React.createElement('button', {
|
|
215
418
|
className: 'dg-chip-remove',
|
|
@@ -222,19 +425,31 @@ window.__ModuleLoader__.load({
|
|
|
222
425
|
className: 'dg-input',
|
|
223
426
|
placeholder: t('addPlaceholder'),
|
|
224
427
|
value: draft,
|
|
225
|
-
onChange: (
|
|
226
|
-
onKeyDown:
|
|
428
|
+
onChange: (event) => setDraft(event.target.value),
|
|
429
|
+
onKeyDown: (event) => {
|
|
430
|
+
if (event.key === 'Enter') add()
|
|
431
|
+
},
|
|
227
432
|
}),
|
|
228
433
|
React.createElement('button', { className: 'dg-btn', type: 'button', onClick: add }, t('add')),
|
|
229
|
-
React.createElement('button', { className: 'dg-btn', type: 'button', onClick: reset }, t('reset')),
|
|
230
434
|
),
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
435
|
+
|
|
436
|
+
React.createElement('p', { className: 'dg-note' }, t('params')),
|
|
437
|
+
React.createElement('div', { className: 'dg-fields' },
|
|
438
|
+
NUMERIC_FIELDS.map((field) => fieldRow(field)),
|
|
439
|
+
BOOL_FIELDS.map((key) => switchRow(key)),
|
|
440
|
+
),
|
|
441
|
+
shortfall === null ? null : React.createElement('p', { className: 'dg-warn' }, fmt(t('windowWarn'), shortfall)),
|
|
442
|
+
|
|
443
|
+
React.createElement('div', { className: 'dg-footer' },
|
|
444
|
+
React.createElement('button', { className: 'dg-btn', type: 'button', onClick: resetAll }, t('reset')),
|
|
445
|
+
writeState === null
|
|
446
|
+
? React.createElement('p', { className: 'dg-note' }, t('note'))
|
|
447
|
+
: writeState === 'saving'
|
|
448
|
+
? React.createElement('p', { className: 'dg-note' }, t('saving'))
|
|
449
|
+
: writeState === 'saved'
|
|
450
|
+
? React.createElement('p', { className: 'dg-note' }, t('saved'))
|
|
451
|
+
: React.createElement('p', { className: 'dg-note dg-error' }, writeState),
|
|
452
|
+
),
|
|
238
453
|
)
|
|
239
454
|
}
|
|
240
455
|
|
|
@@ -245,28 +460,16 @@ window.__ModuleLoader__.load({
|
|
|
245
460
|
const isLoopback = connection !== undefined && connection.isLoopback === true
|
|
246
461
|
// 自定义 decode:绕开客户端 schema 复水化与校验。DSH 的 SettingsScopeController
|
|
247
462
|
// 以 spec.decode(view.value) 调用,即直接传入本命名空间的设置值
|
|
248
|
-
// ({ignoredChars
|
|
463
|
+
// ({ignoredChars, threshold, ...}),而非 wire 视图。
|
|
249
464
|
const controller = ctx.settingsScope.bind({
|
|
250
465
|
namespace: SETTINGS_NS,
|
|
251
|
-
decode: (value) =>
|
|
252
|
-
// 尽力解读;任何异常形状回退到默认白名单,保证视图到达即进入 ready。
|
|
253
|
-
try {
|
|
254
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
255
|
-
const list = value.ignoredChars
|
|
256
|
-
if (Array.isArray(list)) {
|
|
257
|
-
const strings = list.filter((ch) => typeof ch === 'string')
|
|
258
|
-
return { ignoredChars: strings }
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
} catch (_decodeError) {}
|
|
262
|
-
return { ignoredChars: ['-', '|'] }
|
|
263
|
-
},
|
|
466
|
+
decode: (value) => normalizeValue(value),
|
|
264
467
|
})
|
|
265
468
|
const mirror = ctx.settingsScope.describe()
|
|
266
469
|
ctx.effect(() => () => {
|
|
267
470
|
controller.dispose()
|
|
268
471
|
}, 'dupguard: settings scope')
|
|
269
|
-
const injected = () => ({ controller, t, isLoopback, mirror
|
|
472
|
+
const injected = () => ({ controller, t, isLoopback, mirror })
|
|
270
473
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
271
474
|
name: 'settings.section',
|
|
272
475
|
id: 'dupguard',
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,18 @@ const name = 'dupguard'
|
|
|
21
21
|
/** 设置命名空间:小写 kebab-case(dsh-settings 的命名规范)。 */
|
|
22
22
|
const SETTINGS_NS = 'dsh-dupguard'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 可在设置页动态调整的数值参数及其边界。
|
|
26
|
+
* 客户端 UI 会镜像这些边界与默认值(lib/client.js 的 NUMERIC_FIELDS / DEFAULTS),
|
|
27
|
+
* 两处必须保持一致;范围外的写入由 schema 直接拒绝。
|
|
28
|
+
*/
|
|
29
|
+
const LIMITS = {
|
|
30
|
+
threshold: { min: 2, max: 1000 },
|
|
31
|
+
minUnitLength: { min: 1, max: 4096 },
|
|
32
|
+
maxUnitLength: { min: 1, max: 8192 },
|
|
33
|
+
detectionWindow: { min: 64, max: 1048576 },
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
37
|
* 惰性加载 schemastery 并构建设置 schema。
|
|
26
38
|
*
|
|
@@ -33,9 +45,16 @@ let settingsSchemaPromise
|
|
|
33
45
|
function loadSettingsSchema() {
|
|
34
46
|
if (settingsSchemaPromise === undefined) {
|
|
35
47
|
settingsSchemaPromise = import('@deepseek-ai/schemastery').then(({ default: z }) =>
|
|
36
|
-
// 设置 schema
|
|
48
|
+
// 设置 schema:全部检测参数均可在设置页动态调整(默认值取自 CONFIG)。
|
|
37
49
|
z.object({
|
|
38
|
-
ignoredChars: z.array(z.string()).default([
|
|
50
|
+
ignoredChars: z.array(z.string()).default([...CONFIG.ignoredChars]),
|
|
51
|
+
threshold: z.number().min(LIMITS.threshold.min).max(LIMITS.threshold.max).step(1).default(CONFIG.threshold),
|
|
52
|
+
minUnitLength: z.number().min(LIMITS.minUnitLength.min).max(LIMITS.minUnitLength.max).step(1).default(CONFIG.minUnitLength),
|
|
53
|
+
maxUnitLength: z.number().min(LIMITS.maxUnitLength.min).max(LIMITS.maxUnitLength.max).step(1).default(CONFIG.maxUnitLength),
|
|
54
|
+
detectionWindow: z.number().min(LIMITS.detectionWindow.min).max(LIMITS.detectionWindow.max).step(1).default(CONFIG.detectionWindow),
|
|
55
|
+
stripWhitespace: z.boolean().default(CONFIG.stripWhitespace),
|
|
56
|
+
monitorReasoning: z.boolean().default(CONFIG.monitorReasoning),
|
|
57
|
+
monitorToolArguments: z.boolean().default(CONFIG.monitorToolArguments),
|
|
39
58
|
})
|
|
40
59
|
)
|
|
41
60
|
}
|
|
@@ -302,18 +321,86 @@ function installStandingMountPatch(ctx) {
|
|
|
302
321
|
}
|
|
303
322
|
|
|
304
323
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
324
|
+
* 组合层默认值(= 代码 CONFIG)。设置页的用户层被清空(unset)时回落到这里,
|
|
325
|
+
* 因此「恢复默认」等价于回到 CONFIG。
|
|
307
326
|
*/
|
|
308
|
-
function
|
|
327
|
+
function settingsBase() {
|
|
309
328
|
return {
|
|
310
|
-
...CONFIG,
|
|
311
329
|
ignoredChars: [...CONFIG.ignoredChars],
|
|
330
|
+
threshold: CONFIG.threshold,
|
|
331
|
+
minUnitLength: CONFIG.minUnitLength,
|
|
332
|
+
maxUnitLength: CONFIG.maxUnitLength,
|
|
333
|
+
detectionWindow: CONFIG.detectionWindow,
|
|
334
|
+
stripWhitespace: CONFIG.stripWhitespace,
|
|
335
|
+
monitorReasoning: CONFIG.monitorReasoning,
|
|
336
|
+
monitorToolArguments: CONFIG.monitorToolArguments,
|
|
312
337
|
}
|
|
313
338
|
}
|
|
314
339
|
|
|
340
|
+
/** 每次模型调用使用的运行时配置副本(字段均可被设置页热更新)。 */
|
|
341
|
+
function createRuntime() {
|
|
342
|
+
return settingsBase()
|
|
343
|
+
}
|
|
344
|
+
|
|
315
345
|
/**
|
|
316
|
-
*
|
|
346
|
+
* 检测窗口是否小于「阈值 × 最大单元长度」。
|
|
347
|
+
*
|
|
348
|
+
* 窗口是清洗后保留的字符数:一个长度 p 的单元需要 p × threshold 个字符才可能
|
|
349
|
+
* 凑满阈值,因此 p 超过 floor(窗口 / 阈值) 的重复单元无法被识别。返回缺口描述
|
|
350
|
+
* (含所需窗口)或 null。
|
|
351
|
+
*/
|
|
352
|
+
function windowShortfall(runtime) {
|
|
353
|
+
const needed = runtime.threshold * runtime.maxUnitLength
|
|
354
|
+
if (runtime.detectionWindow >= needed) return null
|
|
355
|
+
return {
|
|
356
|
+
needed: needed,
|
|
357
|
+
current: runtime.detectionWindow,
|
|
358
|
+
effectiveMaxUnit: Math.floor(runtime.detectionWindow / runtime.threshold),
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 窗口偏小时打一条宿主日志(设置页另有同样的提示)。 */
|
|
363
|
+
function warnShortWindow(runtime) {
|
|
364
|
+
const short = windowShortfall(runtime)
|
|
365
|
+
if (short === null) return
|
|
366
|
+
console.warn(
|
|
367
|
+
'[dupguard] 检测窗口长度需要提高:至少 ' + String(short.needed) +
|
|
368
|
+
'(= 阈值 ' + String(runtime.threshold) + ' × 最大单元长度 ' + String(runtime.maxUnitLength) +
|
|
369
|
+
'),当前 ' + String(short.current) + ';超过 ' + String(short.effectiveMaxUnit) +
|
|
370
|
+
' 字符的重复单元将无法识别。请在设置页提高「检测窗口」,或降低阈值/最大单元长度。'
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 把设置值归一化进 runtime。外部编辑的设置文档可能带来非法值(schema 校验失败
|
|
376
|
+
* 会保留上一份好值,但归一化仍以防万一),因此逐字段做类型与范围兜底,且绝不抛出:
|
|
377
|
+
* 该方法运行在每次设置提交与插件注册路径上,不能影响检测主体。
|
|
378
|
+
*/
|
|
379
|
+
function applySettingsToRuntime(runtime, value) {
|
|
380
|
+
const fallback = settingsBase()
|
|
381
|
+
const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
382
|
+
const pickInt = (key) => {
|
|
383
|
+
const raw = source[key]
|
|
384
|
+
const limit = LIMITS[key]
|
|
385
|
+
if (Number.isSafeInteger(raw) && raw >= limit.min && raw <= limit.max) return raw
|
|
386
|
+
return fallback[key]
|
|
387
|
+
}
|
|
388
|
+
const pickBool = (key) => (typeof source[key] === 'boolean' ? source[key] : fallback[key])
|
|
389
|
+
runtime.ignoredChars = Array.isArray(source.ignoredChars)
|
|
390
|
+
? source.ignoredChars.filter((ch) => typeof ch === 'string')
|
|
391
|
+
: [...fallback.ignoredChars]
|
|
392
|
+
runtime.threshold = pickInt('threshold')
|
|
393
|
+
runtime.minUnitLength = pickInt('minUnitLength')
|
|
394
|
+
runtime.maxUnitLength = pickInt('maxUnitLength')
|
|
395
|
+
runtime.detectionWindow = pickInt('detectionWindow')
|
|
396
|
+
runtime.stripWhitespace = pickBool('stripWhitespace')
|
|
397
|
+
runtime.monitorReasoning = pickBool('monitorReasoning')
|
|
398
|
+
runtime.monitorToolArguments = pickBool('monitorToolArguments')
|
|
399
|
+
warnShortWindow(runtime)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 从 DSH 的 settings 服务注册检测参数(仅常驻版);设置页可动态调整全部字段。
|
|
317
404
|
* 返回注销器;settings 服务缺失(无头环境)时不做任何事。
|
|
318
405
|
*/
|
|
319
406
|
async function installSettings(ctx, runtime) {
|
|
@@ -322,18 +409,28 @@ async function installSettings(ctx, runtime) {
|
|
|
322
409
|
try {
|
|
323
410
|
const schema = await loadSettingsSchema()
|
|
324
411
|
const scope = settings.register(SETTINGS_NS, schema, {
|
|
325
|
-
base:
|
|
412
|
+
base: settingsBase(),
|
|
413
|
+
// schema 表达不了的跨字段约束:maxUnitLength 不得小于 minUnitLength,
|
|
414
|
+
// 违规的写入在此被拒绝(调用方收到错误),不会存入文档。
|
|
415
|
+
validate: (value) => {
|
|
416
|
+
if (value.maxUnitLength < value.minUnitLength) {
|
|
417
|
+
throw new TypeError(
|
|
418
|
+
'最大重复单元长度(' + String(value.maxUnitLength) +
|
|
419
|
+
')不能小于最小重复单元长度(' + String(value.minUnitLength) + ')'
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
},
|
|
326
423
|
})
|
|
327
424
|
const sync = () => {
|
|
328
|
-
runtime
|
|
425
|
+
applySettingsToRuntime(runtime, scope.get())
|
|
329
426
|
}
|
|
330
427
|
sync()
|
|
331
428
|
const stop = scope.watch(sync)
|
|
332
429
|
ctx.effect(() => () => stop(), 'dupguard: settings watch')
|
|
333
|
-
console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '
|
|
430
|
+
console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '(检测参数可在设置页动态调整)')
|
|
334
431
|
} catch (error) {
|
|
335
|
-
//
|
|
336
|
-
console.error('[dupguard]
|
|
432
|
+
// 设置注册失败不得破坏检测主体:参数保持代码常量,仅损失设置页能力。
|
|
433
|
+
console.error('[dupguard] 设置命名空间注册失败(检测参数将使用代码默认值):' + (error && error.message ? error.message : String(error)))
|
|
337
434
|
}
|
|
338
435
|
}
|
|
339
436
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dupguard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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,7 @@
|
|
|
45
45
|
"node": ">=20"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
-
"test": "node tests/detector.test.js"
|
|
48
|
+
"test": "node tests/detector.test.js && node tests/client.test.js"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
package/plugin/host.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// 安装/运行步骤与配置说明见仓库根目录的 README.md。
|
|
12
12
|
//
|
|
13
13
|
// 说明:动态插件代码体不能 require 依赖,因此本版本不集成 settings 服务,
|
|
14
|
-
//
|
|
14
|
+
// 全部检测参数固定取本文件顶部的 CONFIG。npm 常驻版(lib/index.js)会注册
|
|
15
15
|
// "dsh-dupguard" 设置命名空间并提供 Web 设置页(lib/client.js),
|
|
16
|
-
//
|
|
16
|
+
// 白名单与检测参数(阈值/单元长度/窗口/空白与 reasoning、工具参数开关)
|
|
17
|
+
// 均可在设置中动态调整并持久化;两版默认值保持一致。
|
|
17
18
|
// ============================================================================
|
|
18
19
|
|
|
19
20
|
const CONFIG = {
|