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 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
- 「重复守卫」分节,可视化编辑白名单并持久化(`dsh-dupguard` 设置命名空间),修改即时生效。
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.1-rc.1 的 preset standing-mount 多代并存冲突(见下文"已知限制")。
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
- 修改 `plugin/host.js` 或 `lib/index.js` 顶部 `CONFIG` 常量(两个入口需保持同步,CI 会校验一致性):
116
+ 检测参数的默认值定义在 `plugin/host.js` 与 `lib/index.js` 顶部的 `CONFIG`(两个入口需保持同步,
117
+ CI 会校验行为一致性)。**npm 常驻版**:下表参数除 `fixStandingMountConflict` 外全部可在设置页
118
+ 「重复守卫」分节中动态调整并持久化到 `settings.yaml`,改动即时生效(动态版固定取常量)。
115
119
 
116
- Edit the `CONFIG` block at the top of `plugin/host.js` / `lib/index.js` (both entries must stay in sync; CI verifies behavioral parity).
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` | 检测滚动窗口(字符,去空白后)/ rolling detection window in chars (after whitespace removal) |
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` | `['-', '\|']` | 检测时忽略的字符白名单(默认值):Markdown 表格分隔行(连字符与竖线)不参与重复统计;npm 常驻版可在设置页「重复守卫」分节中可视化修改并持久化(动态版固定取此常量)/ whitelist of characters ignored during detection (default): Markdown table separators don't count as repetition; the npm build exposes it in the settings page "Dupguard" with persistence (the dynamic build uses this constant) |
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.1-rc.1 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突 / idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.1-rc.1 standing-mount conflict |
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
- node tests/detector.test.js # 或 npm test
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/工具参数、未闭合工具调用块的闭合、多次调用状态隔离等。CI 在 Node 20/22/24
218
- 上运行(与 DSH 一致,不支持 Node 18)。
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: '模型输出中同一字符串连续重复 10 次以上时会自动截停。此处配置检测时忽略的字符(白名单):这些字符不参与重复统计,例如 Markdown 表格分隔行会大量使用连字符与竖线。',
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
- note: '修改即时生效;「恢复默认」回到连字符与竖线。',
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 10 or more times in a row. Configure the whitelisted characters that are ignored during detection, e.g. Markdown table separators use many hyphens and pipes.',
41
- list: 'Whitelist',
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
- note: 'Changes take effect immediately; "Reset to defaults" restores hyphen and pipe.',
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 (snap.status !== 'ready') {
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
- // UI 同步模型:手动修改前跟随镜像快照(含本页挂载时的强制重拉),
112
- // 手动修改后本地自治(镜像回传不再覆盖,避免旧值回滚)。
113
- const remoteList = Array.isArray(snap.value && snap.value.ignoredChars) ? snap.value.ignoredChars : []
114
- const [list, setList] = React.useState(null)
115
- const touched = React.useRef(false)
116
- const lastRemote = React.useRef(remoteList)
117
- if (list === null || (!touched.current && lastRemote.current !== remoteList)) {
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 shown = list === null ? remoteList : list
122
- const [writeState, setWriteState] = React.useState(null)
277
+ const crossInvalid = Number.isInteger(parsed.minUnitLength) && Number.isInteger(parsed.maxUnitLength) &&
278
+ parsed.maxUnitLength < parsed.minUnitLength
123
279
 
124
- // 每次打开本页都强制重拉镜像:保证显示的是宿主持久化的最新值。
125
- React.useEffect(() => {
126
- if (props.mirror && typeof props.mirror.load === 'function') {
127
- props.mirror.load()
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
- // 直接经设置 RPC 写(models 页同款),可观察到真实错误;
132
- // 乐观更新保证界面即时反映,写入结果异步回填状态。
133
- const persist = (next) => {
134
- setWriteState('saving')
135
- const revision = snap.revision === undefined ? undefined : snap.revision
136
- props.api.settings.mutate({
137
- ns: SETTINGS_NS,
138
- ops: [{ op: 'set', path: ['ignoredChars'], value: next }],
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
- }, (error) => {
152
- setWriteState('error:' + String(error && error.message || error))
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 persistUnset = () => {
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
- const revision = snap.revision === undefined ? undefined : snap.revision
158
- props.api.settings.mutate({
159
- ns: SETTINGS_NS,
160
- ops: [{ op: 'unset', path: ['ignoredChars'] }],
161
- ...(revision === undefined ? {} : { expectedRevision: revision }),
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
- const remove = (ch) => {
178
- const next = shown.filter((item) => item !== ch)
179
- touched.current = true
180
- setList(next)
181
- persist(next)
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 reset = () => {
197
- touched.current = true
198
- setList(['-', '|'])
199
- persistUnset()
200
- }
201
- const onKey = (e) => {
202
- if (e.key === 'Enter') add()
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: (e) => setDraft(e.target.value),
226
- onKeyDown: onKey,
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
- writeState === null
232
- ? React.createElement('p', { className: 'dg-note' }, t('note'))
233
- : writeState === 'saving'
234
- ? React.createElement('p', { className: 'dg-note' }, '保存中…')
235
- : writeState === 'saved'
236
- ? React.createElement('p', { className: 'dg-note' }, '已保存')
237
- : React.createElement('p', { className: 'dg-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, writeState),
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: [...]}),而非 wire 视图。直接解读该值的 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, api: connection.api })
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
- * 把 CONFIG 拷贝为运行时配置:ignoredChars 会被设置页热更新,
306
- * 其余字段保持代码常量(设置页只暴露白名单)。
324
+ * 组合层默认值(= 代码 CONFIG)。设置页的用户层被清空(unset)时回落到这里,
325
+ * 因此「恢复默认」等价于回到 CONFIG。
307
326
  */
308
- function createRuntime() {
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
- * 从 DSH 的 settings 服务注册白名单设置(仅常驻版)。
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: { ignoredChars: CONFIG.ignoredChars },
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.ignoredChars = [...scope.get().ignoredChars]
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] 设置命名空间注册失败(白名单将使用默认值):' + (error && error.message ? error.message : String(error)))
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.2.0",
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
- // 白名单固定取 CONFIG.ignoredChars。npm 常驻版(lib/index.js)会注册
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 = {