dsh-dupguard 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
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.1] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - **白名单按「单个字符」匹配的语义两端不一致**:设置页会把整串输入(如 `ab`)当成一个条目,
10
+ 而检测按单个字符比对,该条目永远不会生效。现在设置页把一次输入拆成单个字符逐个加入
11
+ (忽略其中的空白),宿主对存量或手改的多字符、空、非字符串条目在运行时丢弃并打印告警;
12
+ 白名单比对同时改为按 Unicode 码点进行,emoji 等代理对字符可作为单个条目正常生效。
13
+ - **未改动的数值输入在失焦时也会写入**,把默认值写进用户层(`settings.yaml` 里因此出现
14
+ `threshold: 10`、`maxUnitLength: 80`、`detectionWindow: 8192` 这类无意义条目)。现在值与
15
+ 宿主一致时跳过写入。
16
+ - **跨字段约束只在「最大单元」一行提示**:从「最小单元」一侧提交违规值时只能拿到宿主拒绝后的
17
+ 泛化「保存失败」。现在两行都给出具体错误并在本地拦截写入。
18
+ - 截停收尾遇到未知块类型(协议新增 `ContentBlock` 类型)时会静默按 tool-call 兜底,
19
+ 现补充一次性宿主告警,便于定位协议漂移。
20
+
21
+ ## [1.3.0] - 2026-09-05
22
+
23
+ ### Added
24
+
25
+ - 全部检测参数可在设置页动态调整(此前仅白名单可改):`threshold`、`minUnitLength`、
26
+ `maxUnitLength`、`detectionWindow`、`stripWhitespace`、`monitorReasoning`、
27
+ `monitorToolArguments` 均注册进 `dsh-dupguard` 设置 schema(含上下界与非整数拒绝),
28
+ 改动即时热生效并持久化到 `settings.yaml`;
29
+ - 设置页参数表单:数值输入(失焦或回车提交、范围与整数校验、跨字段校验)与原生风格开关,
30
+ 写入仍走 `settingsScope` 控制器并回读快照校验;
31
+ - **窗口缺口提示**:`detectionWindow < threshold × maxUnitLength` 时,设置页显示
32
+ 「⚠ 检测窗口长度需要提高:至少 N(当前 M = 阈值 × 最大单元),超过 K 字符的重复单元无法识别」,
33
+ 宿主日志同时打印一条同义告警(只提示不拒绝写入);
34
+ - 「恢复默认」改为清空本页全部用户设置(逐字段 unset),回到代码默认值。
35
+
36
+ ### Changed
37
+
38
+ - 宿主 `settings.register` 增加 `validate` 跨字段约束(最大单元长度不得小于最小单元长度),
39
+ 违规写入被拒绝而非静默存入;
40
+ - 运行时设置同步改为逐字段归一化(类型/范围兜底,绝不抛错),非法外部编辑回落到代码默认值;
41
+ - 客户端组件改为全部 hook 前置,消除「加载中 → 就绪」状态切换时的 hook 数量变化风险。
42
+
5
43
  ## [1.2.1] - 2026-09-02
6
44
 
7
45
  ### Fixed
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 表格分隔行(连字符与竖线)不参与重复统计。条目必须**是单个字符**(按 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 |
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
 
@@ -232,6 +247,11 @@ Node 20/22/24 (matching DSH; Node 18 is not supported).
232
247
  - 停止时若恰有未闭合的工具调用块(顺序输出块的适配器几乎不可能),该块会按已累积参数闭合并可能被执行。
233
248
  - 服务端停止依赖适配器在流关闭时中止底层请求的语义(已验证 `dsh-llm-deepseek`;自定义适配器需自查)。
234
249
  - 阈值语义为 `>= threshold`:第 10 次重复出现时即停止。
250
+ - 检测窗口上限 1,048,576 字符:窗口越大,每个增量都要重写一次缓冲(实测 1Mi 窗口约 0.13 ms/增量),
251
+ 极端长输出下会有可感知的额外开销;默认 8192 无此问题。
252
+ - 若手工编辑 `settings.yaml` 写入非法值(如 `minUnitLength > maxUnitLength`),DSH 会在注册时拒绝该
253
+ 命名空间,本插件捕获后仅打印错误日志并整体回落到代码默认值(检测功能不受影响,设置页显示默认值)。
254
+ - 协议新增 `ContentBlock` 类型时,截停收尾对未知块类型只能按 tool-call 兜底并打印一次性告警。
235
255
 
236
256
  ### DSH 运行期间编辑 preset 后的 standing-mount 冲突(DSH ≤ 0.1.2-rc.1 缺陷,本插件已内置补丁)
237
257
 
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,31 +23,75 @@ 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
- addPlaceholder: '输入要忽略的字符',
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
+ errCrossMin: '不能大于最大重复单元长度(当前 {max})。',
55
+ windowWarn: '⚠ 检测窗口长度需要提高:至少 {need}(当前 {current} = 阈值 {threshold} × 最大单元 {maxUnit})。当前窗口下超过 {effective} 字符的重复单元无法识别。',
32
56
  loading: '加载中…',
33
- note: '修改即时生效;「恢复默认」回到连字符与竖线。',
34
- saveFailed: '保存失败:宿主未接受该值,请查看 DSH 宿主日志。',
35
- remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,白名单将使用默认值(连字符与竖线)。',
57
+ remoteHint: '设置修改仅支持本机连接:请通过本机地址(127.0.0.1)打开 DSH 页面后重试。远程访问时 DSH 的设置通道被禁用,检测参数将使用默认值。',
36
58
  unavailable: '设置服务不可用:未读取到「重复守卫」的设置数据。请确认 DSH 为 npm 常驻版挂载(宿主日志应包含「已注册设置命名空间 dsh-dupguard」),并刷新页面重试。',
37
59
  }
38
60
  const en = {
39
61
  nav: 'Dupguard',
40
62
  title: 'Repetition Guard',
41
- 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.',
42
- list: 'Whitelist',
63
+ intro: 'Generation stops when the same string repeats consecutively up to the threshold. Changes below take effect immediately and persist to settings.yaml.',
64
+ list: 'Ignored characters (whitelist, matched per character)',
43
65
  empty: 'Whitelist is empty: every character counts.',
44
- addPlaceholder: 'Character to ignore',
66
+ addPlaceholder: 'Characters to ignore (one or more)',
45
67
  add: 'Add',
68
+ params: 'Detection parameters',
69
+ threshold: 'Threshold (consecutive repeats)',
70
+ thresholdHint: 'Stop once a string repeats this many times in a row (>= semantics). Range {min}–{max}.',
71
+ minUnitLength: 'Minimum repeating-unit length',
72
+ minUnitLengthHint: 'Shortest repeating unit considered, in characters. Range {min}–{max}; 1 catches single-character loops.',
73
+ maxUnitLength: 'Maximum repeating-unit length',
74
+ maxUnitLengthHint: 'Longest repeating unit recognized, in characters after cleaning (whitespace and whitelisted characters are removed). Range {min}–{max}.',
75
+ detectionWindow: 'Detection window (characters)',
76
+ 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.',
77
+ stripWhitespace: 'Ignore whitespace',
78
+ stripWhitespaceHint: 'Remove all whitespace (newlines included) before detection, so separated repeats are still recognized.',
79
+ monitorReasoning: 'Monitor reasoning text',
80
+ monitorReasoningHint: 'Repeats inside reasoning burn tokens too, so they are monitored by default.',
81
+ monitorToolArguments: 'Monitor tool-call arguments',
82
+ monitorToolArgumentsHint: 'Off by default: repeated characters are common in JSON / base64 arguments.',
46
83
  reset: 'Reset to defaults',
84
+ note: '"Reset to defaults" clears every user setting on this page and restores the code defaults.',
85
+ saving: 'Saving…',
86
+ saved: 'Saved',
87
+ saveFailed: 'Save failed: the host did not accept the value; check the input or the DSH host log.',
88
+ errInteger: 'Enter an integer.',
89
+ errRange: 'Allowed range {min}–{max}.',
90
+ errCross: 'Must not be smaller than the minimum unit length (currently {min}).',
91
+ errCrossMin: 'Must not be larger than the maximum unit length (currently {max}).',
92
+ 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.',
47
93
  loading: 'Loading…',
48
- note: 'Changes take effect immediately; "Reset to defaults" restores hyphen and pipe.',
49
- saveFailed: 'Save failed: the host did not accept the value; check the DSH host log.',
50
- 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).',
94
+ 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.',
51
95
  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.',
52
96
  }
53
97
 
@@ -66,6 +110,21 @@ window.__ModuleLoader__.load({
66
110
  '.dg-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
67
111
  '.dg-note{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}',
68
112
  '.dg-empty{color:var(--dsw-alias-label-tertiary);margin:0}',
113
+ '.dg-fields{display:flex;flex-direction:column;gap:12px}',
114
+ '.dg-field{display:flex;flex-direction:column;gap:4px}',
115
+ '.dg-field-head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
116
+ '.dg-field-label{color:var(--dsw-alias-label-primary);font-size:13px}',
117
+ '.dg-field-hint{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}',
118
+ '.dg-field-error{color:var(--dsw-alias-state-error-primary);font-size:12px;margin:0}',
119
+ '.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}',
120
+ '.dg-num:focus{border-color:var(--dsw-alias-brand-primary)}',
121
+ '.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}',
122
+ '.dg-switch-on{background:var(--dsw-alias-brand-primary)}',
123
+ '.dg-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .12s}',
124
+ '.dg-switch-on .dg-switch-thumb{transform:translateX(16px)}',
125
+ '.dg-warn{color:var(--dsw-alias-state-warn-primary);font-size:12px;margin:0}',
126
+ '.dg-error{color:var(--dsw-alias-state-error-primary)}',
127
+ '.dg-footer{display:flex;align-items:center;gap:12px;flex-wrap:wrap}',
69
128
  ].join('\n')
70
129
 
71
130
  if (typeof document !== 'undefined') {
@@ -78,18 +137,117 @@ window.__ModuleLoader__.load({
78
137
 
79
138
  const inject = ['slots', 'locale', 'connection', 'settingsScope']
80
139
 
140
+ // ---------------------------------------------------------------------
141
+ // 字段元数据:与宿主 lib/index.js 的 LIMITS / CONFIG 必须保持一致。
142
+ // ---------------------------------------------------------------------
143
+ const NUMERIC_FIELDS = [
144
+ { key: 'threshold', min: 2, max: 1000 },
145
+ { key: 'minUnitLength', min: 1, max: 4096 },
146
+ { key: 'maxUnitLength', min: 1, max: 8192 },
147
+ { key: 'detectionWindow', min: 64, max: 1048576 },
148
+ ]
149
+ const BOOL_FIELDS = ['stripWhitespace', 'monitorReasoning', 'monitorToolArguments']
150
+ const DEFAULTS = {
151
+ ignoredChars: ['-', '|'],
152
+ threshold: 10,
153
+ minUnitLength: 1,
154
+ maxUnitLength: 80,
155
+ detectionWindow: 8192,
156
+ stripWhitespace: true,
157
+ monitorReasoning: true,
158
+ monitorToolArguments: false,
159
+ }
160
+ const ALL_FIELDS = ['ignoredChars'].concat(NUMERIC_FIELDS.map((field) => field.key), BOOL_FIELDS)
161
+
162
+ /** 模板占位符替换:{name} → vars.name。 */
163
+ const fmt = (text, vars) =>
164
+ String(text).replace(/\{(\w+)\}/g, (match, key) => (vars[key] === undefined ? match : String(vars[key])))
165
+
166
+ /**
167
+ * 把宿主设置值归一化为完整形态:缺失或越界的字段回落默认,
168
+ * 保证任何异常形状到达视图时仍是 ready 且可渲染。
169
+ */
170
+ function normalizeValue(raw) {
171
+ const out = { ignoredChars: [...DEFAULTS.ignoredChars] }
172
+ for (const field of NUMERIC_FIELDS) out[field.key] = DEFAULTS[field.key]
173
+ for (const key of BOOL_FIELDS) out[key] = DEFAULTS[key]
174
+ try {
175
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
176
+ if (Array.isArray(raw.ignoredChars)) {
177
+ out.ignoredChars = raw.ignoredChars.filter((ch) => typeof ch === 'string')
178
+ }
179
+ for (const field of NUMERIC_FIELDS) {
180
+ const value = raw[field.key]
181
+ if (Number.isSafeInteger(value) && value >= field.min && value <= field.max) out[field.key] = value
182
+ }
183
+ for (const key of BOOL_FIELDS) {
184
+ if (typeof raw[key] === 'boolean') out[key] = raw[key]
185
+ }
186
+ }
187
+ } catch (_normalizeError) {}
188
+ return out
189
+ }
190
+
191
+ /** 归一化值为编辑表单:数值字段存字符串,便于输入中途的状态。 */
192
+ function toForm(value) {
193
+ const normalized = normalizeValue(value)
194
+ const form = { ignoredChars: normalized.ignoredChars }
195
+ for (const field of NUMERIC_FIELDS) form[field.key] = String(normalized[field.key])
196
+ for (const key of BOOL_FIELDS) form[key] = normalized[key]
197
+ return form
198
+ }
199
+
200
+ /** 从输入文本解析整数(仅接受纯整数,避免 1e3 / 3.5 这类写法)。 */
201
+ function parseInt10(text) {
202
+ const value = typeof text === 'string' ? text.trim() : String(text)
203
+ return /^-?\d+$/.test(value) ? Number(value) : NaN
204
+ }
205
+
81
206
  function DupguardSection(props) {
82
207
  const controller = props.controller
83
208
  const t = props.t
209
+
210
+ // 全部 hook 在早退之前调用,保证 loading → ready 的状态切换不改变 hook 数量。
84
211
  const snap = React.useSyncExternalStore(
85
212
  (cb) => controller.subscribe(cb),
86
213
  () => controller.getSnapshot(),
87
214
  )
88
- const [draft, setDraft] = React.useState('')
89
215
  const mirrorSnap = React.useSyncExternalStore(
90
216
  (cb) => props.mirror.subscribe(cb),
91
217
  () => props.mirror.getSnapshot(),
92
218
  )
219
+ const [draft, setDraft] = React.useState('')
220
+ const [form, setForm] = React.useState(null)
221
+ const [writeState, setWriteState] = React.useState(null)
222
+ const [errors, setErrors] = React.useState({})
223
+ const dirty = React.useRef({})
224
+ const lastRemote = React.useRef(null)
225
+
226
+ // 每次打开本页都强制重拉镜像:保证显示的是宿主持久化的最新值。
227
+ React.useEffect(() => {
228
+ if (props.mirror && typeof props.mirror.load === 'function') {
229
+ props.mirror.load()
230
+ }
231
+ }, [])
232
+
233
+ // 同步模型:手动修改过的字段本地自治(免疫旧值回传),其余字段跟随镜像快照。
234
+ const ready = snap.status === 'ready' && snap.value !== undefined
235
+ const remoteForm = ready ? toForm(snap.value) : null
236
+ if (ready) {
237
+ const signature = JSON.stringify(remoteForm)
238
+ if (form === null) {
239
+ lastRemote.current = signature
240
+ setForm(remoteForm)
241
+ } else if (lastRemote.current !== signature) {
242
+ lastRemote.current = signature
243
+ const next = {}
244
+ for (const key of ALL_FIELDS) {
245
+ next[key] = dirty.current[key] === true ? form[key] : remoteForm[key]
246
+ }
247
+ setForm(next)
248
+ }
249
+ }
250
+ const shown = form === null ? remoteForm : form
93
251
 
94
252
  if (props.isLoopback === false) {
95
253
  return React.createElement('div', { className: 'dg-section' },
@@ -101,7 +259,7 @@ window.__ModuleLoader__.load({
101
259
  React.createElement('h2', { className: 'dg-title' }, t('title')),
102
260
  React.createElement('p', { className: 'dg-intro' }, t('unavailable')))
103
261
  }
104
- if (snap.status !== 'ready') {
262
+ if (!ready || shown === null) {
105
263
  const mirrorError = mirrorSnap && mirrorSnap.error ? String(mirrorSnap.error) : null
106
264
  const mirrorStatus = mirrorSnap ? String(mirrorSnap.status) : 'unknown'
107
265
  return React.createElement('div', { className: 'dg-section' },
@@ -110,92 +268,172 @@ window.__ModuleLoader__.load({
110
268
  'mirror=' + mirrorStatus + (mirrorError === null ? '' : ' error=' + mirrorError)))
111
269
  }
112
270
 
113
- // UI 同步模型:手动修改前跟随镜像快照(含本页挂载时的强制重拉),
114
- // 手动修改后本地自治(镜像回传不再覆盖,避免旧值回滚)。
115
- const remoteList = Array.isArray(snap.value && snap.value.ignoredChars) ? snap.value.ignoredChars : []
116
- const [list, setList] = React.useState(null)
117
- const touched = React.useRef(false)
118
- const lastRemote = React.useRef(remoteList)
119
- if (list === null || (!touched.current && lastRemote.current !== remoteList)) {
120
- lastRemote.current = remoteList
121
- setList(remoteList)
271
+ // ---- 取值校验:逐字段范围 + 跨字段(最大单元 ≥ 最小单元) ----
272
+ const parsed = {}
273
+ let numericValid = true
274
+ for (const field of NUMERIC_FIELDS) {
275
+ const value = parseInt10(shown[field.key])
276
+ parsed[field.key] = value
277
+ if (!Number.isInteger(value) || value < field.min || value > field.max) numericValid = false
122
278
  }
123
- const shown = list === null ? remoteList : list
124
- const [writeState, setWriteState] = React.useState(null)
279
+ const crossInvalid = Number.isInteger(parsed.minUnitLength) && Number.isInteger(parsed.maxUnitLength) &&
280
+ parsed.maxUnitLength < parsed.minUnitLength
125
281
 
126
- // 每次打开本页都强制重拉镜像:保证显示的是宿主持久化的最新值。
127
- React.useEffect(() => {
128
- if (props.mirror && typeof props.mirror.load === 'function') {
129
- props.mirror.load()
130
- }
131
- }, [])
282
+ const fieldError = (field) => {
283
+ const value = parsed[field.key]
284
+ if (!Number.isInteger(value)) return t('errInteger')
285
+ if (value < field.min || value > field.max) return fmt(t('errRange'), { min: field.min, max: field.max })
286
+ // 跨字段约束在两侧都提示:否则从「最小单元」一侧提交违规值时,
287
+ // 只能拿到宿主拒绝后的泛化「保存失败」。
288
+ if (crossInvalid && field.key === 'maxUnitLength') return fmt(t('errCross'), { min: parsed.minUnitLength })
289
+ if (crossInvalid && field.key === 'minUnitLength') return fmt(t('errCrossMin'), { max: parsed.maxUnitLength })
290
+ return null
291
+ }
292
+
293
+ // 窗口缺口提示:窗口 < 阈值 × 最大单元长度时,更长的单元无法凑满重复次数。
294
+ const shortfall = numericValid && !crossInvalid && parsed.detectionWindow < parsed.threshold * parsed.maxUnitLength
295
+ ? {
296
+ need: parsed.threshold * parsed.maxUnitLength,
297
+ current: parsed.detectionWindow,
298
+ threshold: parsed.threshold,
299
+ maxUnit: parsed.maxUnitLength,
300
+ effective: Math.floor(parsed.detectionWindow / parsed.threshold),
301
+ }
302
+ : null
132
303
 
133
- // 写路径走 settingsScope 控制器(DSH 自己的设置写通道):自动携带最新
134
- // revision、串行化并发写,并把宿主应答折叠回共享镜像。
135
- // 注意:DSH 0.1.2 起客户端不再暴露 connection.api(改为 ctx.remote.<ns>),
136
- // 直接调 wire 面会在 0.1.2 上抛错并卡在「保存中…」;控制器接口两版一致。
137
- const finishWrite = (expected) => {
138
- // 写入结果由控制器折叠进镜像,回读快照校验是否真的落盘。
139
- const snapNow = props.controller.getSnapshot()
140
- const applied = snapNow !== undefined && snapNow.value !== undefined && Array.isArray(snapNow.value.ignoredChars)
141
- ? snapNow.value.ignoredChars
142
- : null
143
- const same = applied !== null && applied.length === expected.length &&
144
- applied.every((ch, index) => ch === expected[index])
145
- setWriteState(same ? 'saved' : 'error:' + t('saveFailed'))
304
+ // ---- 写路径:settingsScope 控制器(DSH 自己的设置写通道,自动携带 revision、
305
+ // 串行化并发写、把宿主应答折叠回镜像)。DSH 0.1.2 起客户端不再暴露
306
+ // connection.api,控制器接口自 0.1.1 起稳定,故不直接触碰 wire 面。 ----
307
+ const snapshotValue = () => {
308
+ const current = controller.getSnapshot()
309
+ return current !== undefined && current.value !== undefined ? current.value : {}
146
310
  }
147
- const runWrite = (operation, expected) => {
311
+ const sameList = (a, b) => Array.isArray(a) && Array.isArray(b) &&
312
+ a.length === b.length && a.every((item, index) => item === b[index])
313
+ const currentForm = () => (form === null ? remoteForm : form)
314
+ const runWrite = (operation, verify) => {
148
315
  setWriteState('saving')
149
316
  Promise.resolve()
150
317
  .then(() => operation())
151
- .then(() => finishWrite(expected), (error) => {
318
+ .then(() => setWriteState(verify() ? 'saved' : 'error:' + t('saveFailed')), (error) => {
152
319
  setWriteState('error:' + String((error && error.message) || error))
153
320
  })
154
321
  }
155
- const persist = (next) => {
156
- runWrite(() => props.controller.set('ignoredChars', next), next)
322
+ const commitList = (next) => {
323
+ dirty.current.ignoredChars = true
324
+ setForm({ ...currentForm(), ignoredChars: next })
325
+ runWrite(() => controller.set('ignoredChars', next), () => sameList(snapshotValue().ignoredChars, next))
157
326
  }
158
- const persistUnset = () => {
159
- // unset 后解析值回到 schema 默认(宿主 base)白名单。
160
- runWrite(() => props.controller.unset('ignoredChars'), ['-', '|'])
327
+ const commitNumber = (field) => {
328
+ const message = fieldError(field)
329
+ if (message !== null) {
330
+ setErrors((prev) => ({ ...prev, [field.key]: message }))
331
+ return
332
+ }
333
+ const value = parsed[field.key]
334
+ setErrors((prev) => ({ ...prev, [field.key]: null }))
335
+ // 值未变化时不写入:避免仅仅聚焦/失焦就把默认值写进用户层,
336
+ // 污染 settings.yaml 并让「恢复默认」失去意义。
337
+ if (snapshotValue()[field.key] === value) {
338
+ dirty.current[field.key] = false
339
+ setForm({ ...currentForm(), [field.key]: String(value) })
340
+ return
341
+ }
342
+ dirty.current[field.key] = true
343
+ setForm({ ...currentForm(), [field.key]: String(value) })
344
+ runWrite(() => controller.set(field.key, value), () => snapshotValue()[field.key] === value)
161
345
  }
162
-
163
- const remove = (ch) => {
164
- const next = shown.filter((item) => item !== ch)
165
- touched.current = true
166
- setList(next)
167
- persist(next)
346
+ const commitBool = (key) => {
347
+ const value = shown[key] !== true
348
+ dirty.current[key] = true
349
+ setForm({ ...currentForm(), [key]: value })
350
+ runWrite(() => controller.set(key, value), () => snapshotValue()[key] === value)
351
+ }
352
+ const resetAll = () => {
353
+ dirty.current = {}
354
+ setErrors({})
355
+ runWrite(
356
+ () => Promise.all(ALL_FIELDS.map((key) => controller.unset(key))),
357
+ () => {
358
+ setForm(toForm(snapshotValue()))
359
+ const current = controller.getSnapshot()
360
+ const user = current !== undefined ? current.user : undefined
361
+ if (user === undefined || user === null) return true
362
+ return ALL_FIELDS.every((key) => user[key] === undefined)
363
+ },
364
+ )
168
365
  }
366
+
169
367
  const add = () => {
170
- const ch = draft.trim()
171
- if (ch.length === 0) return
172
- if (shown.indexOf(ch) !== -1) {
173
- setDraft('')
174
- return
175
- }
176
- const next = [...shown, ch]
177
- touched.current = true
178
- setList(next)
179
- persist(next)
368
+ const text = draft.trim()
369
+ if (text.length === 0) return
180
370
  setDraft('')
371
+ // 白名单按单个字符匹配:一次输入多个字符时逐个加入(忽略输入中的空白),
372
+ // 否则整串会被当成一个条目,永远不会命中。
373
+ const next = [...shown.ignoredChars]
374
+ let changed = false
375
+ for (const ch of text) {
376
+ if (/\s/.test(ch)) continue
377
+ if (next.indexOf(ch) !== -1) continue
378
+ next.push(ch)
379
+ changed = true
380
+ }
381
+ if (changed) commitList(next)
181
382
  }
182
- const reset = () => {
183
- touched.current = true
184
- setList(['-', '|'])
185
- persistUnset()
186
- }
187
- const onKey = (e) => {
188
- if (e.key === 'Enter') add()
383
+ const remove = (ch) => commitList(shown.ignoredChars.filter((item) => item !== ch))
384
+
385
+ const fieldRow = (field) => {
386
+ const message = errors[field.key] !== undefined && errors[field.key] !== null
387
+ ? errors[field.key]
388
+ : fieldError(field)
389
+ return React.createElement('div', { className: 'dg-field', key: field.key },
390
+ React.createElement('div', { className: 'dg-field-head' },
391
+ React.createElement('label', { className: 'dg-field-label', htmlFor: 'dg-' + field.key }, t(field.key)),
392
+ React.createElement('input', {
393
+ id: 'dg-' + field.key,
394
+ className: 'dg-num',
395
+ type: 'number',
396
+ min: field.min,
397
+ max: field.max,
398
+ step: 1,
399
+ value: shown[field.key],
400
+ onChange: (event) => {
401
+ const value = event.target.value
402
+ setForm({ ...currentForm(), [field.key]: value })
403
+ setErrors((prev) => ({ ...prev, [field.key]: null }))
404
+ },
405
+ onBlur: () => commitNumber(field),
406
+ onKeyDown: (event) => {
407
+ if (event.key === 'Enter') commitNumber(field)
408
+ },
409
+ })),
410
+ React.createElement('p', { className: 'dg-field-hint' },
411
+ fmt(t(field.key + 'Hint'), { min: field.min, max: field.max })),
412
+ message === null ? null : React.createElement('p', { className: 'dg-field-error' }, message),
413
+ )
189
414
  }
415
+ const switchRow = (key) => React.createElement('div', { className: 'dg-field', key: key },
416
+ React.createElement('div', { className: 'dg-field-head' },
417
+ React.createElement('span', { className: 'dg-field-label' }, t(key)),
418
+ React.createElement('button', {
419
+ className: 'dg-switch' + (shown[key] === true ? ' dg-switch-on' : ''),
420
+ type: 'button',
421
+ role: 'switch',
422
+ 'aria-checked': shown[key] === true,
423
+ onClick: () => commitBool(key),
424
+ }, React.createElement('span', { className: 'dg-switch-thumb' }))),
425
+ React.createElement('p', { className: 'dg-field-hint' }, t(key + 'Hint')),
426
+ )
190
427
 
191
428
  return React.createElement('div', { className: 'dg-section' },
192
429
  React.createElement('h2', { className: 'dg-title' }, t('title')),
193
430
  React.createElement('p', { className: 'dg-intro' }, t('intro')),
431
+
194
432
  React.createElement('p', { className: 'dg-note' }, t('list')),
195
- shown.length === 0
433
+ shown.ignoredChars.length === 0
196
434
  ? React.createElement('p', { className: 'dg-empty' }, t('empty'))
197
435
  : React.createElement('div', { className: 'dg-chips' },
198
- shown.map((ch) => React.createElement('span', { className: 'dg-chip', key: ch },
436
+ shown.ignoredChars.map((ch) => React.createElement('span', { className: 'dg-chip', key: ch },
199
437
  ch,
200
438
  React.createElement('button', {
201
439
  className: 'dg-chip-remove',
@@ -208,19 +446,31 @@ window.__ModuleLoader__.load({
208
446
  className: 'dg-input',
209
447
  placeholder: t('addPlaceholder'),
210
448
  value: draft,
211
- onChange: (e) => setDraft(e.target.value),
212
- onKeyDown: onKey,
449
+ onChange: (event) => setDraft(event.target.value),
450
+ onKeyDown: (event) => {
451
+ if (event.key === 'Enter') add()
452
+ },
213
453
  }),
214
454
  React.createElement('button', { className: 'dg-btn', type: 'button', onClick: add }, t('add')),
215
- React.createElement('button', { className: 'dg-btn', type: 'button', onClick: reset }, t('reset')),
216
455
  ),
217
- writeState === null
218
- ? React.createElement('p', { className: 'dg-note' }, t('note'))
219
- : writeState === 'saving'
220
- ? React.createElement('p', { className: 'dg-note' }, '保存中…')
221
- : writeState === 'saved'
222
- ? React.createElement('p', { className: 'dg-note' }, '已保存')
223
- : React.createElement('p', { className: 'dg-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, writeState),
456
+
457
+ React.createElement('p', { className: 'dg-note' }, t('params')),
458
+ React.createElement('div', { className: 'dg-fields' },
459
+ NUMERIC_FIELDS.map((field) => fieldRow(field)),
460
+ BOOL_FIELDS.map((key) => switchRow(key)),
461
+ ),
462
+ shortfall === null ? null : React.createElement('p', { className: 'dg-warn' }, fmt(t('windowWarn'), shortfall)),
463
+
464
+ React.createElement('div', { className: 'dg-footer' },
465
+ React.createElement('button', { className: 'dg-btn', type: 'button', onClick: resetAll }, t('reset')),
466
+ writeState === null
467
+ ? React.createElement('p', { className: 'dg-note' }, t('note'))
468
+ : writeState === 'saving'
469
+ ? React.createElement('p', { className: 'dg-note' }, t('saving'))
470
+ : writeState === 'saved'
471
+ ? React.createElement('p', { className: 'dg-note' }, t('saved'))
472
+ : React.createElement('p', { className: 'dg-note dg-error' }, writeState),
473
+ ),
224
474
  )
225
475
  }
226
476
 
@@ -231,22 +481,10 @@ window.__ModuleLoader__.load({
231
481
  const isLoopback = connection !== undefined && connection.isLoopback === true
232
482
  // 自定义 decode:绕开客户端 schema 复水化与校验。DSH 的 SettingsScopeController
233
483
  // 以 spec.decode(view.value) 调用,即直接传入本命名空间的设置值
234
- // ({ignoredChars: [...]}),而非 wire 视图。直接解读该值的 ignoredChars 字段。
484
+ // ({ignoredChars, threshold, ...}),而非 wire 视图。
235
485
  const controller = ctx.settingsScope.bind({
236
486
  namespace: SETTINGS_NS,
237
- decode: (value) => {
238
- // 尽力解读;任何异常形状回退到默认白名单,保证视图到达即进入 ready。
239
- try {
240
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
241
- const list = value.ignoredChars
242
- if (Array.isArray(list)) {
243
- const strings = list.filter((ch) => typeof ch === 'string')
244
- return { ignoredChars: strings }
245
- }
246
- }
247
- } catch (_decodeError) {}
248
- return { ignoredChars: ['-', '|'] }
249
- },
487
+ decode: (value) => normalizeValue(value),
250
488
  })
251
489
  const mirror = ctx.settingsScope.describe()
252
490
  ctx.effect(() => () => {
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
  }
@@ -59,6 +78,8 @@ const CONFIG = {
59
78
  // 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成
60
79
  // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
61
80
  // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
81
+ // 条目必须是单个字符(按 Unicode 码点匹配,emoji 也算一个);
82
+ // 多字符条目永远不会命中,运行时会丢弃并告警。
62
83
  ignoredChars: ['-', '|'],
63
84
  // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
64
85
  // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
@@ -110,12 +131,11 @@ function stripWhitespace(text) {
110
131
  return text.replace(/\s+/g, '')
111
132
  }
112
133
 
113
- /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。 */
134
+ /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。按 Unicode 码点逐个比对。 */
114
135
  function stripIgnoredChars(text, ignored) {
115
136
  if (ignored.length === 0) return text
116
137
  let out = ''
117
- for (let i = 0; i < text.length; i++) {
118
- const ch = text[i]
138
+ for (const ch of text) {
119
139
  if (ignored.indexOf(ch) === -1) out += ch
120
140
  }
121
141
  return out
@@ -128,6 +148,46 @@ function sanitizeDelta(delta, config) {
128
148
  return piece
129
149
  }
130
150
 
151
+ /**
152
+ * 未知块类型只会在 DSH 协议新增 ContentBlock 类型时出现:此时没有合法的合成
153
+ * block-end,只能按 tool-call 兜底并告警一次,便于定位协议漂移。
154
+ */
155
+ let warnedUnknownBlockType = false
156
+ function warnUnknownBlockType(blockType) {
157
+ if (warnedUnknownBlockType) return
158
+ warnedUnknownBlockType = true
159
+ console.warn(
160
+ '[dupguard] 遇到未知块类型 ' + JSON.stringify(blockType) +
161
+ ':截停收尾只能按 tool-call 兜底,请升级插件以匹配新的 DSH 协议。'
162
+ )
163
+ }
164
+
165
+ /**
166
+ * 白名单按「单个字符」(Unicode 码点)匹配:多字符或空条目永远不会命中,
167
+ * 属于无效配置。此处丢弃并告警,避免设置页显示了一个永远不生效的条目。
168
+ */
169
+ function normalizeIgnoredChars(raw, fallback) {
170
+ if (!Array.isArray(raw)) return [...fallback]
171
+ const kept = []
172
+ const dropped = []
173
+ for (const entry of raw) {
174
+ if (typeof entry !== 'string') {
175
+ dropped.push(String(entry))
176
+ continue
177
+ }
178
+ // [...entry] 按码点拆分:emoji 等代理对字符算一个字符。
179
+ if ([...entry].length === 1) kept.push(entry)
180
+ else dropped.push(entry)
181
+ }
182
+ if (dropped.length > 0) {
183
+ console.warn(
184
+ '[dupguard] 白名单条目必须是单个字符,已忽略无效条目:' + JSON.stringify(dropped) +
185
+ '(白名单按字符匹配,多字符条目不会生效)。'
186
+ )
187
+ }
188
+ return kept
189
+ }
190
+
131
191
  /**
132
192
  * 为一次 llm/stream 调用创建守卫。
133
193
  * 每次模型调用都会新建一份状态,互不干扰。
@@ -222,6 +282,7 @@ function createStreamGuard(options, runtime) {
222
282
  } else if (b.blockType === 'reasoning') {
223
283
  block = { type: 'reasoning', text: b.text }
224
284
  } else {
285
+ if (b.blockType !== 'tool-call') warnUnknownBlockType(b.blockType)
225
286
  block = {
226
287
  type: 'tool-call',
227
288
  id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index,
@@ -302,18 +363,84 @@ function installStandingMountPatch(ctx) {
302
363
  }
303
364
 
304
365
  /**
305
- * 把 CONFIG 拷贝为运行时配置:ignoredChars 会被设置页热更新,
306
- * 其余字段保持代码常量(设置页只暴露白名单)。
366
+ * 组合层默认值(= 代码 CONFIG)。设置页的用户层被清空(unset)时回落到这里,
367
+ * 因此「恢复默认」等价于回到 CONFIG。
307
368
  */
308
- function createRuntime() {
369
+ function settingsBase() {
309
370
  return {
310
- ...CONFIG,
311
371
  ignoredChars: [...CONFIG.ignoredChars],
372
+ threshold: CONFIG.threshold,
373
+ minUnitLength: CONFIG.minUnitLength,
374
+ maxUnitLength: CONFIG.maxUnitLength,
375
+ detectionWindow: CONFIG.detectionWindow,
376
+ stripWhitespace: CONFIG.stripWhitespace,
377
+ monitorReasoning: CONFIG.monitorReasoning,
378
+ monitorToolArguments: CONFIG.monitorToolArguments,
312
379
  }
313
380
  }
314
381
 
382
+ /** 每次模型调用使用的运行时配置副本(字段均可被设置页热更新)。 */
383
+ function createRuntime() {
384
+ return settingsBase()
385
+ }
386
+
315
387
  /**
316
- * 从 DSH 的 settings 服务注册白名单设置(仅常驻版)。
388
+ * 检测窗口是否小于「阈值 × 最大单元长度」。
389
+ *
390
+ * 窗口是清洗后保留的字符数:一个长度 p 的单元需要 p × threshold 个字符才可能
391
+ * 凑满阈值,因此 p 超过 floor(窗口 / 阈值) 的重复单元无法被识别。返回缺口描述
392
+ * (含所需窗口)或 null。
393
+ */
394
+ function windowShortfall(runtime) {
395
+ const needed = runtime.threshold * runtime.maxUnitLength
396
+ if (runtime.detectionWindow >= needed) return null
397
+ return {
398
+ needed: needed,
399
+ current: runtime.detectionWindow,
400
+ effectiveMaxUnit: Math.floor(runtime.detectionWindow / runtime.threshold),
401
+ }
402
+ }
403
+
404
+ /** 窗口偏小时打一条宿主日志(设置页另有同样的提示)。 */
405
+ function warnShortWindow(runtime) {
406
+ const short = windowShortfall(runtime)
407
+ if (short === null) return
408
+ console.warn(
409
+ '[dupguard] 检测窗口长度需要提高:至少 ' + String(short.needed) +
410
+ '(= 阈值 ' + String(runtime.threshold) + ' × 最大单元长度 ' + String(runtime.maxUnitLength) +
411
+ '),当前 ' + String(short.current) + ';超过 ' + String(short.effectiveMaxUnit) +
412
+ ' 字符的重复单元将无法识别。请在设置页提高「检测窗口」,或降低阈值/最大单元长度。'
413
+ )
414
+ }
415
+
416
+ /**
417
+ * 把设置值归一化进 runtime。外部编辑的设置文档可能带来非法值(schema 校验失败
418
+ * 会保留上一份好值,但归一化仍以防万一),因此逐字段做类型与范围兜底,且绝不抛出:
419
+ * 该方法运行在每次设置提交与插件注册路径上,不能影响检测主体。
420
+ */
421
+ function applySettingsToRuntime(runtime, value) {
422
+ const fallback = settingsBase()
423
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
424
+ const pickInt = (key) => {
425
+ const raw = source[key]
426
+ const limit = LIMITS[key]
427
+ if (Number.isSafeInteger(raw) && raw >= limit.min && raw <= limit.max) return raw
428
+ return fallback[key]
429
+ }
430
+ const pickBool = (key) => (typeof source[key] === 'boolean' ? source[key] : fallback[key])
431
+ runtime.ignoredChars = normalizeIgnoredChars(source.ignoredChars, fallback.ignoredChars)
432
+ runtime.threshold = pickInt('threshold')
433
+ runtime.minUnitLength = pickInt('minUnitLength')
434
+ runtime.maxUnitLength = pickInt('maxUnitLength')
435
+ runtime.detectionWindow = pickInt('detectionWindow')
436
+ runtime.stripWhitespace = pickBool('stripWhitespace')
437
+ runtime.monitorReasoning = pickBool('monitorReasoning')
438
+ runtime.monitorToolArguments = pickBool('monitorToolArguments')
439
+ warnShortWindow(runtime)
440
+ }
441
+
442
+ /**
443
+ * 从 DSH 的 settings 服务注册检测参数(仅常驻版);设置页可动态调整全部字段。
317
444
  * 返回注销器;settings 服务缺失(无头环境)时不做任何事。
318
445
  */
319
446
  async function installSettings(ctx, runtime) {
@@ -322,18 +449,28 @@ async function installSettings(ctx, runtime) {
322
449
  try {
323
450
  const schema = await loadSettingsSchema()
324
451
  const scope = settings.register(SETTINGS_NS, schema, {
325
- base: { ignoredChars: CONFIG.ignoredChars },
452
+ base: settingsBase(),
453
+ // schema 表达不了的跨字段约束:maxUnitLength 不得小于 minUnitLength,
454
+ // 违规的写入在此被拒绝(调用方收到错误),不会存入文档。
455
+ validate: (value) => {
456
+ if (value.maxUnitLength < value.minUnitLength) {
457
+ throw new TypeError(
458
+ '最大重复单元长度(' + String(value.maxUnitLength) +
459
+ ')不能小于最小重复单元长度(' + String(value.minUnitLength) + ')'
460
+ )
461
+ }
462
+ },
326
463
  })
327
464
  const sync = () => {
328
- runtime.ignoredChars = [...scope.get().ignoredChars]
465
+ applySettingsToRuntime(runtime, scope.get())
329
466
  }
330
467
  sync()
331
468
  const stop = scope.watch(sync)
332
469
  ctx.effect(() => () => stop(), 'dupguard: settings watch')
333
- console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '(白名单可在设置页修改)')
470
+ console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '(检测参数可在设置页动态调整)')
334
471
  } catch (error) {
335
- // 设置注册失败不得破坏检测主体:白名单保持代码常量,仅损失设置页能力。
336
- console.error('[dupguard] 设置命名空间注册失败(白名单将使用默认值):' + (error && error.message ? error.message : String(error)))
472
+ // 设置注册失败不得破坏检测主体:参数保持代码常量,仅损失设置页能力。
473
+ console.error('[dupguard] 设置命名空间注册失败(检测参数将使用代码默认值):' + (error && error.message ? error.message : String(error)))
337
474
  }
338
475
  }
339
476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dupguard",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
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",
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 = {