dsh-dupguard 1.0.2 → 1.1.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 +21 -0
- package/README.md +15 -10
- package/lib/client.js +162 -0
- package/lib/index.js +65 -14
- package/package.json +13 -2
- package/plugin/host.js +12 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
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.1.0] - 2026-08-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 图形化设置页(npm 常驻版):新增 `lib/client.js`(`dsh.client` 浏览器端入口),在 DSH
|
|
10
|
+
设置面板注册与「通用设置 / 模型 / 插件 / Agent 预设」并列的「重复守卫」分节,可视化
|
|
11
|
+
编辑白名单(增删字符、恢复默认),样式使用 `--dsw-*` 主题变量与原有设置页一致;
|
|
12
|
+
- 设置持久化与热更新:host 端通过 settings 服务注册 `dsh-dupguard` 命名空间
|
|
13
|
+
(`ignoredChars` 字段,schemastery schema,新增 `@deepseek-ai/schemastery` 依赖),
|
|
14
|
+
设置变更即时热生效(动态版保持零依赖,白名单固定取常量);
|
|
15
|
+
- 测试新增 settings 集成套件(默认 base、热更新、清空、恢复默认),共 48 项。
|
|
16
|
+
|
|
17
|
+
## [1.0.3] - 2026-08-30
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- 思考(reasoning)复读守卫:`monitorReasoning` 默认改为开启,思考中同一字符串连续重复
|
|
22
|
+
10 次以上即截停;修复开启后暴露的 reasoning 文本双重累积缺陷(`b.text` 与 `feedText`
|
|
23
|
+
各加一次,导致闭合块文本翻倍);新增"关闭开关"变体套件验证 `monitorReasoning: false`
|
|
24
|
+
时仍全量透传。测试套件双入口各 21 项,加变体共 44 项。
|
|
25
|
+
|
|
5
26
|
## [1.0.2] - 2026-08-27
|
|
6
27
|
|
|
7
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,11 +19,14 @@ When triggered, the already-generated text is committed as a normal assistant me
|
|
|
19
19
|
|
|
20
20
|
- **实时检测**:逐 token(`text-delta`)检测,复读出现即停,延迟为单个增量。
|
|
21
21
|
- **多种复读形态**:单字符循环、词语循环、带空格/换行分隔的复读均能识别(默认去空白后检测)。
|
|
22
|
+
- **思考守卫**:默认同时检测 reasoning(思考)文本,思考中的复读同样会被截停(可通过 `monitorReasoning` 关闭)。
|
|
22
23
|
- **真正的服务端停止**:提前关闭流迭代 → 适配器 `consumer.abort()` → 中断 HTTP 连接,模型在服务端停止生成。
|
|
23
24
|
- **安全停止**:绝不 `abort()` agent 步骤信号;补发协议合规的 `block-end` + `finish(stop)`,消息正常提交。
|
|
24
|
-
- **Markdown 表格友好**:默认忽略连字符与竖线(`ignoredChars`
|
|
25
|
-
-
|
|
26
|
-
-
|
|
25
|
+
- **Markdown 表格友好**:默认忽略连字符与竖线(`ignoredChars` 白名单),表格分隔行与长分隔线不会被误判为复读。
|
|
26
|
+
- **图形化设置页**(npm 常驻版):在 DSH 设置面板注册与「通用设置 / 模型 / 插件 / Agent 预设」并列的
|
|
27
|
+
「重复守卫」分节,可视化编辑白名单并持久化(`dsh-dupguard` 设置命名空间),修改即时生效。
|
|
28
|
+
- **零配置开箱即用**:默认配置即可用;仅白名单可通过设置页调整。
|
|
29
|
+
- **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js` + `lib/client.js`),行为一致、CI 防漂移。
|
|
27
30
|
- **内置 DSH 兼容补丁**(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
|
|
28
31
|
修复 DSH ≤ 0.1.1-rc.1 的 preset standing-mount 多代并存冲突(见下文"已知限制")。
|
|
29
32
|
|
|
@@ -105,8 +108,8 @@ Edit the `CONFIG` block at the top of `plugin/host.js` / `lib/index.js` (both en
|
|
|
105
108
|
| `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
|
|
106
109
|
| `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后)/ rolling detection window in chars (after whitespace removal) |
|
|
107
110
|
| `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
|
|
108
|
-
| `ignoredChars` | `['-', '\|']` |
|
|
109
|
-
| `monitorReasoning` | `
|
|
111
|
+
| `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) |
|
|
112
|
+
| `monitorReasoning` | `true` | 是否检测思考文本(思考中的复读同样消耗 token,默认截停;只检测可见输出时置 `false`)/ also guard reasoning (thinking) text — on by default; set `false` to guard visible output only |
|
|
110
113
|
| `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
|
|
111
114
|
| `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 |
|
|
112
115
|
|
|
@@ -160,11 +163,12 @@ agent-loop commits the partial text as a normal assistant message.
|
|
|
160
163
|
| 带空格复读 / space-separated | `hello hello hello ...` ×10 |
|
|
161
164
|
| 逐行复读 / line repeats | `抱歉,我无法完成。` ×10 行 |
|
|
162
165
|
| 前缀后循环 / loop after prefix | `好的,下面开始回答:` + `循环` ×10 |
|
|
166
|
+
| 思考复读 / reasoning loop | 思考中 `想` ×10(默认截停) |
|
|
163
167
|
|
|
164
168
|
**不会触发 / Won't trigger**:正常文本中的高频词(检测只针对**连续**重复)、重复 9 次及以下、
|
|
165
|
-
|
|
166
|
-
/ high-frequency words in normal prose (consecutive runs only), ≤9 repeats,
|
|
167
|
-
|
|
169
|
+
工具参数(默认关闭)、Markdown 表格分隔行与长分隔线(连字符与竖线在白名单中,默认忽略)。
|
|
170
|
+
/ high-frequency words in normal prose (consecutive runs only), ≤9 repeats, tool args (off by
|
|
171
|
+
default), Markdown table separator rows and horizontal rules (whitelisted by default).
|
|
168
172
|
|
|
169
173
|
---
|
|
170
174
|
|
|
@@ -175,9 +179,10 @@ reasoning 与工具参数(默认关闭)、Markdown 表格分隔行与长分
|
|
|
175
179
|
├── plugin/
|
|
176
180
|
│ └── host.js # 动态插件形式(cordis_define 的 code.host)
|
|
177
181
|
├── lib/
|
|
178
|
-
│
|
|
182
|
+
│ ├── index.js # npm/组合常驻形式(package.json main 入口,含设置集成)
|
|
183
|
+
│ └── client.js # 浏览器端设置页(ModuleLoader 格式,dsh.client 入口)
|
|
179
184
|
├── tests/
|
|
180
|
-
│ ├── detector.test.js #
|
|
185
|
+
│ ├── detector.test.js # 端到端测试:双入口防漂移 + reasoning 开关 + settings 集成
|
|
181
186
|
│ └── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
|
|
182
187
|
├── .github/workflows/ci.yml # GitHub Actions:Node 18/20/22
|
|
183
188
|
├── package.json
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dupguard 浏览器端 bundle(单文件,经 window.__ModuleLoader__ 加载)。
|
|
3
|
+
*
|
|
4
|
+
* 在 DSH 设置面板注册一个与「通用设置 / 模型 / 插件 / Agent 预设」并列的
|
|
5
|
+
* 分节「重复守卫」:白名单(检测时忽略的字符)的可视化编辑界面。
|
|
6
|
+
* 数据经 settingsScope 绑定宿主 settings 服务的 "dsh-dupguard" 命名空间,
|
|
7
|
+
* 修改即时生效并持久化(settings.yaml)。
|
|
8
|
+
*
|
|
9
|
+
* 样式全部使用 --dsw-* 主题变量,跟随全局亮/暗主题,与原有设置页风格一致。
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-dupguard',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} }
|
|
15
|
+
var exports = module.exports
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
17
|
+
|
|
18
|
+
const React = require('react')
|
|
19
|
+
|
|
20
|
+
const NS = 'dsh-dupguard'
|
|
21
|
+
const SETTINGS_NS = 'dsh-dupguard'
|
|
22
|
+
|
|
23
|
+
const zh = {
|
|
24
|
+
nav: '重复守卫',
|
|
25
|
+
title: '重复输出守卫',
|
|
26
|
+
intro: '模型输出中同一字符串连续重复 10 次以上时会自动截停。此处配置检测时忽略的字符(白名单):这些字符不参与重复统计,例如 Markdown 表格分隔行会大量使用连字符与竖线。',
|
|
27
|
+
list: '当前白名单',
|
|
28
|
+
empty: '白名单为空:所有字符都参与重复统计。',
|
|
29
|
+
addPlaceholder: '输入要忽略的字符',
|
|
30
|
+
add: '添加',
|
|
31
|
+
reset: '恢复默认',
|
|
32
|
+
loading: '加载中…',
|
|
33
|
+
note: '修改即时生效;「恢复默认」回到连字符与竖线。',
|
|
34
|
+
}
|
|
35
|
+
const en = {
|
|
36
|
+
nav: 'Dupguard',
|
|
37
|
+
title: 'Repetition Guard',
|
|
38
|
+
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.',
|
|
39
|
+
list: 'Whitelist',
|
|
40
|
+
empty: 'Whitelist is empty: every character counts.',
|
|
41
|
+
addPlaceholder: 'Character to ignore',
|
|
42
|
+
add: 'Add',
|
|
43
|
+
reset: 'Reset to defaults',
|
|
44
|
+
loading: 'Loading…',
|
|
45
|
+
note: 'Changes take effect immediately; "Reset to defaults" restores hyphen and pipe.',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const css = [
|
|
49
|
+
'.dg-section{display:flex;flex-direction:column;gap:14px;padding:4px 2px 24px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
50
|
+
'.dg-title{font-size:15px;font-weight:600;line-height:24px;margin:0}',
|
|
51
|
+
'.dg-intro{color:var(--dsw-alias-label-secondary);margin:0}',
|
|
52
|
+
'.dg-chips{display:flex;flex-wrap:wrap;gap:8px}',
|
|
53
|
+
'.dg-chip{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 6px 0 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);font-size:12px;color:var(--dsw-alias-label-primary)}',
|
|
54
|
+
'.dg-chip-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:none;border-radius:5px;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:0}',
|
|
55
|
+
'.dg-chip-remove:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
56
|
+
'.dg-add{display:flex;gap:8px}',
|
|
57
|
+
'.dg-input{flex:1;max-width:240px;height:32px;padding:0 12px;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;outline:none;font-family:inherit}',
|
|
58
|
+
'.dg-input:focus{border-color:var(--dsw-alias-brand-primary)}',
|
|
59
|
+
'.dg-btn{height:32px;padding:0 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;cursor:pointer;font-family:inherit}',
|
|
60
|
+
'.dg-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
61
|
+
'.dg-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-on-primary,#fff)}',
|
|
62
|
+
'.dg-btn-primary:hover{background:var(--dsw-alias-brand-primary)}',
|
|
63
|
+
'.dg-note{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}',
|
|
64
|
+
'.dg-empty{color:var(--dsw-alias-label-tertiary);margin:0}',
|
|
65
|
+
].join('\n')
|
|
66
|
+
|
|
67
|
+
if (typeof document !== 'undefined') {
|
|
68
|
+
const tag = document.createElement('style')
|
|
69
|
+
tag.dataset.plugin = 'dsh-dupguard'
|
|
70
|
+
tag.dataset.pluginCss = 'dsh-dupguard/client.css'
|
|
71
|
+
tag.textContent = css
|
|
72
|
+
document.head.appendChild(tag)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const inject = ['slots', 'locale', 'connection', 'settingsScope']
|
|
76
|
+
|
|
77
|
+
function DupguardSection(props) {
|
|
78
|
+
const controller = props.controller
|
|
79
|
+
const t = props.t
|
|
80
|
+
const snap = React.useSyncExternalStore(
|
|
81
|
+
(cb) => controller.subscribe(cb),
|
|
82
|
+
() => controller.getSnapshot(),
|
|
83
|
+
)
|
|
84
|
+
const [draft, setDraft] = React.useState('')
|
|
85
|
+
|
|
86
|
+
if (snap.status !== 'ready') {
|
|
87
|
+
return React.createElement('div', { className: 'dg-section' },
|
|
88
|
+
React.createElement('p', { className: 'dg-empty' }, t('loading')))
|
|
89
|
+
}
|
|
90
|
+
const list = Array.isArray(snap.value && snap.value.ignoredChars) ? snap.value.ignoredChars : []
|
|
91
|
+
|
|
92
|
+
const remove = (ch) => {
|
|
93
|
+
controller.set('ignoredChars', list.filter((item) => item !== ch))
|
|
94
|
+
}
|
|
95
|
+
const add = () => {
|
|
96
|
+
const ch = draft.trim()
|
|
97
|
+
if (ch.length === 0) return
|
|
98
|
+
if (list.indexOf(ch) !== -1) {
|
|
99
|
+
setDraft('')
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
controller.set('ignoredChars', [...list, ch])
|
|
103
|
+
setDraft('')
|
|
104
|
+
}
|
|
105
|
+
const reset = () => {
|
|
106
|
+
controller.unset('ignoredChars')
|
|
107
|
+
}
|
|
108
|
+
const onKey = (e) => {
|
|
109
|
+
if (e.key === 'Enter') add()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return React.createElement('div', { className: 'dg-section' },
|
|
113
|
+
React.createElement('h2', { className: 'dg-title' }, t('title')),
|
|
114
|
+
React.createElement('p', { className: 'dg-intro' }, t('intro')),
|
|
115
|
+
React.createElement('p', { className: 'dg-note' }, t('list')),
|
|
116
|
+
list.length === 0
|
|
117
|
+
? React.createElement('p', { className: 'dg-empty' }, t('empty'))
|
|
118
|
+
: React.createElement('div', { className: 'dg-chips' },
|
|
119
|
+
list.map((ch) => React.createElement('span', { className: 'dg-chip', key: ch },
|
|
120
|
+
ch,
|
|
121
|
+
React.createElement('button', {
|
|
122
|
+
className: 'dg-chip-remove',
|
|
123
|
+
onClick: () => remove(ch),
|
|
124
|
+
'aria-label': 'remove',
|
|
125
|
+
}, '\u00d7')))),
|
|
126
|
+
React.createElement('div', { className: 'dg-add' },
|
|
127
|
+
React.createElement('input', {
|
|
128
|
+
className: 'dg-input',
|
|
129
|
+
placeholder: t('addPlaceholder'),
|
|
130
|
+
value: draft,
|
|
131
|
+
onChange: (e) => setDraft(e.target.value),
|
|
132
|
+
onKeyDown: onKey,
|
|
133
|
+
}),
|
|
134
|
+
React.createElement('button', { className: 'dg-btn dg-btn-primary', onClick: add }, t('add')),
|
|
135
|
+
React.createElement('button', { className: 'dg-btn', onClick: reset }, t('reset')),
|
|
136
|
+
),
|
|
137
|
+
React.createElement('p', { className: 'dg-note' }, t('note')),
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function apply(ctx) {
|
|
142
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dupguard: dictionaries')
|
|
143
|
+
const t = ctx.locale.bind(NS)
|
|
144
|
+
const controller = ctx.settingsScope.bind({ namespace: SETTINGS_NS })
|
|
145
|
+
ctx.effect(() => () => {
|
|
146
|
+
controller.dispose()
|
|
147
|
+
}, 'dupguard: settings scope')
|
|
148
|
+
const injected = () => ({ controller, t })
|
|
149
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
150
|
+
name: 'settings.section',
|
|
151
|
+
id: 'dupguard',
|
|
152
|
+
order: 25,
|
|
153
|
+
label: () => t('nav'),
|
|
154
|
+
inject: injected,
|
|
155
|
+
}, DupguardSection))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
exports.apply = apply
|
|
159
|
+
exports.inject = inject
|
|
160
|
+
return module.exports
|
|
161
|
+
},
|
|
162
|
+
})
|
package/lib/index.js
CHANGED
|
@@ -10,10 +10,24 @@
|
|
|
10
10
|
// { name, apply },供 DSH 组合(cordis.yml / preset)挂载;
|
|
11
11
|
// 与 plugin/host.js(动态 cordis_define 形式)行为完全一致,
|
|
12
12
|
// tests/detector.test.js 会对两者同时跑同一套用例,防止两份代码漂移。
|
|
13
|
+
//
|
|
14
|
+
// 设置集成(仅本常驻版):通过 DSH 的 settings 服务注册命名空间
|
|
15
|
+
// "dsh-dupguard",白名单(ignoredChars)可在 Web 设置页修改并持久化
|
|
16
|
+
// (见 lib/client.js),修改即时热生效。
|
|
13
17
|
// ============================================================================
|
|
14
18
|
|
|
19
|
+
const z = require('@deepseek-ai/schemastery')
|
|
20
|
+
|
|
15
21
|
const name = 'dupguard'
|
|
16
22
|
|
|
23
|
+
/** 设置命名空间:小写 kebab-case(dsh-settings 的命名规范)。 */
|
|
24
|
+
const SETTINGS_NS = 'dsh-dupguard'
|
|
25
|
+
|
|
26
|
+
/** 设置 schema:白名单字符数组(其余检测参数保持代码常量)。 */
|
|
27
|
+
const SETTINGS_SCHEMA = z.object({
|
|
28
|
+
ignoredChars: z.array(z.string()).default(['-', '|']),
|
|
29
|
+
})
|
|
30
|
+
|
|
17
31
|
const CONFIG = {
|
|
18
32
|
// 触发阈值:同一字符串连续重复次数达到该值时停止输出(用户需求:重复十次以上)。
|
|
19
33
|
// 语义为「>= threshold」,即第 10 次重复出现时就触发。
|
|
@@ -32,9 +46,10 @@ const CONFIG = {
|
|
|
32
46
|
// (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
|
|
33
47
|
// 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
|
|
34
48
|
ignoredChars: ['-', '|'],
|
|
35
|
-
// 是否同时检测思考(reasoning
|
|
36
|
-
//
|
|
37
|
-
|
|
49
|
+
// 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
|
|
50
|
+
// 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
|
|
51
|
+
// 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
|
|
52
|
+
monitorReasoning: true,
|
|
38
53
|
// 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
|
|
39
54
|
monitorToolArguments: false,
|
|
40
55
|
// DSH ≤ 0.1.1-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 仍未修复):preset 的 standing mount
|
|
@@ -102,8 +117,10 @@ function sanitizeDelta(delta, config) {
|
|
|
102
117
|
/**
|
|
103
118
|
* 为一次 llm/stream 调用创建守卫。
|
|
104
119
|
* 每次模型调用都会新建一份状态,互不干扰。
|
|
120
|
+
* @param options - llm/stream 的 GenerateOptions。
|
|
121
|
+
* @param runtime - 运行时配置(ignoredChars 可被设置页热更新)。
|
|
105
122
|
*/
|
|
106
|
-
function createStreamGuard(options) {
|
|
123
|
+
function createStreamGuard(options, runtime) {
|
|
107
124
|
// index -> 块状态。用 Map 按块索引累积,避免多个文本块交替输出时互相打断检测。
|
|
108
125
|
const blocks = new Map()
|
|
109
126
|
let stopped = null
|
|
@@ -130,9 +147,9 @@ function createStreamGuard(options) {
|
|
|
130
147
|
/** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
|
|
131
148
|
function feedText(b, delta) {
|
|
132
149
|
b.text += delta
|
|
133
|
-
const piece = sanitizeDelta(delta,
|
|
134
|
-
b.stripped = (b.stripped + piece).slice(-
|
|
135
|
-
return findRepeatedTail(b.stripped,
|
|
150
|
+
const piece = sanitizeDelta(delta, runtime)
|
|
151
|
+
b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
|
|
152
|
+
return findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength)
|
|
136
153
|
}
|
|
137
154
|
|
|
138
155
|
/** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */
|
|
@@ -150,10 +167,12 @@ function createStreamGuard(options) {
|
|
|
150
167
|
}
|
|
151
168
|
case 'reasoning-delta': {
|
|
152
169
|
const b = ensure(chunk.index, 'reasoning')
|
|
153
|
-
|
|
154
|
-
|
|
170
|
+
if (runtime.monitorReasoning) {
|
|
171
|
+
// feedText 内部负责累积完整文本与检测缓冲,这里不得重复 +=
|
|
155
172
|
const hit = feedText(b, chunk.text)
|
|
156
173
|
if (hit !== null) stopped = hit
|
|
174
|
+
} else {
|
|
175
|
+
b.text += chunk.text // 不检测时仍须累积完整内容以便停止时闭合块
|
|
157
176
|
}
|
|
158
177
|
return
|
|
159
178
|
}
|
|
@@ -162,10 +181,10 @@ function createStreamGuard(options) {
|
|
|
162
181
|
if (chunk.id !== undefined) b.toolCallId = chunk.id
|
|
163
182
|
if (chunk.name !== undefined) b.toolCallName = chunk.name
|
|
164
183
|
b.toolCallArguments += chunk.argumentsDelta
|
|
165
|
-
if (
|
|
166
|
-
const piece = sanitizeDelta(chunk.argumentsDelta,
|
|
167
|
-
b.stripped = (b.stripped + piece).slice(-
|
|
168
|
-
const hit = findRepeatedTail(b.stripped,
|
|
184
|
+
if (runtime.monitorToolArguments) {
|
|
185
|
+
const piece = sanitizeDelta(chunk.argumentsDelta, runtime)
|
|
186
|
+
b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow)
|
|
187
|
+
const hit = findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength)
|
|
169
188
|
if (hit !== null) stopped = hit
|
|
170
189
|
}
|
|
171
190
|
return
|
|
@@ -268,12 +287,44 @@ function installStandingMountPatch(ctx) {
|
|
|
268
287
|
console.log('[dupguard] cordisInspect.register 已幂等化(DSH standing-mount 多代并存兼容补丁)')
|
|
269
288
|
}
|
|
270
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 把 CONFIG 拷贝为运行时配置:ignoredChars 会被设置页热更新,
|
|
292
|
+
* 其余字段保持代码常量(设置页只暴露白名单)。
|
|
293
|
+
*/
|
|
294
|
+
function createRuntime() {
|
|
295
|
+
return {
|
|
296
|
+
...CONFIG,
|
|
297
|
+
ignoredChars: [...CONFIG.ignoredChars],
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 从 DSH 的 settings 服务注册白名单设置(仅常驻版)。
|
|
303
|
+
* 返回注销器;settings 服务缺失(无头环境)时不做任何事。
|
|
304
|
+
*/
|
|
305
|
+
function installSettings(ctx, runtime) {
|
|
306
|
+
const settings = ctx.get('settings')
|
|
307
|
+
if (settings === undefined || typeof settings.register !== 'function') return
|
|
308
|
+
const scope = settings.register(SETTINGS_NS, SETTINGS_SCHEMA, {
|
|
309
|
+
base: { ignoredChars: CONFIG.ignoredChars },
|
|
310
|
+
})
|
|
311
|
+
const sync = () => {
|
|
312
|
+
runtime.ignoredChars = [...scope.get().ignoredChars]
|
|
313
|
+
}
|
|
314
|
+
sync()
|
|
315
|
+
const stop = scope.watch(sync)
|
|
316
|
+
ctx.effect(() => () => stop(), 'dupguard: settings watch')
|
|
317
|
+
console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '(白名单可在设置页修改)')
|
|
318
|
+
}
|
|
319
|
+
|
|
271
320
|
function apply(ctx) {
|
|
272
321
|
if (CONFIG.fixStandingMountConflict) installStandingMountPatch(ctx)
|
|
322
|
+
const runtime = createRuntime()
|
|
323
|
+
installSettings(ctx, runtime)
|
|
273
324
|
// llm/stream:包裹每次流式模型调用的瀑布事件。
|
|
274
325
|
// 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。
|
|
275
326
|
// (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式)
|
|
276
|
-
ctx.on('llm/stream', (options, next) => createStreamGuard(options)(next()))
|
|
327
|
+
ctx.on('llm/stream', (options, next) => createStreamGuard(options, runtime)(next()))
|
|
277
328
|
}
|
|
278
329
|
|
|
279
330
|
module.exports = { name, apply }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dupguard",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -11,13 +11,21 @@
|
|
|
11
11
|
"anti-repetition",
|
|
12
12
|
"output-guard",
|
|
13
13
|
"llm",
|
|
14
|
+
"client-plugin",
|
|
14
15
|
"防复读",
|
|
15
16
|
"重复检测"
|
|
16
17
|
],
|
|
17
18
|
"license": "MIT",
|
|
18
19
|
"main": "lib/index.js",
|
|
19
20
|
"exports": {
|
|
20
|
-
".": "./lib/index.js"
|
|
21
|
+
".": "./lib/index.js",
|
|
22
|
+
"./client": "./lib/client.js",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"dsh": {
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web"
|
|
28
|
+
}
|
|
21
29
|
},
|
|
22
30
|
"files": [
|
|
23
31
|
"lib",
|
|
@@ -36,6 +44,9 @@
|
|
|
36
44
|
"access": "public"
|
|
37
45
|
},
|
|
38
46
|
"sideEffects": false,
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
49
|
+
},
|
|
39
50
|
"repository": {
|
|
40
51
|
"type": "git",
|
|
41
52
|
"url": "git+https://github.com/zqh260619/dsh-dupguard.git"
|
package/plugin/host.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// 需要随 DSH 常驻时,请改用 npm/组合形式 lib/index.js(与本文件行为一致,
|
|
10
10
|
// tests/detector.test.js 会对两个入口跑同一套用例防止漂移)。
|
|
11
11
|
// 安装/运行步骤与配置说明见仓库根目录的 README.md。
|
|
12
|
+
//
|
|
13
|
+
// 说明:动态插件代码体不能 require 依赖,因此本版本不集成 settings 服务,
|
|
14
|
+
// 白名单固定取 CONFIG.ignoredChars。npm 常驻版(lib/index.js)会注册
|
|
15
|
+
// "dsh-dupguard" 设置命名空间并提供 Web 设置页(lib/client.js),
|
|
16
|
+
// 白名单可在设置中修改并持久化。
|
|
12
17
|
// ============================================================================
|
|
13
18
|
|
|
14
19
|
const CONFIG = {
|
|
@@ -29,9 +34,10 @@ const CONFIG = {
|
|
|
29
34
|
// (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
|
|
30
35
|
// 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
|
|
31
36
|
ignoredChars: ['-', '|'],
|
|
32
|
-
// 是否同时检测思考(reasoning
|
|
33
|
-
//
|
|
34
|
-
|
|
37
|
+
// 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token,
|
|
38
|
+
// 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"),
|
|
39
|
+
// 也会被截停,属预期行为;需要只检测可见输出时可置为 false。
|
|
40
|
+
monitorReasoning: true,
|
|
35
41
|
// 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
|
|
36
42
|
monitorToolArguments: false,
|
|
37
43
|
// DSH ≤ 0.1.1-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 仍未修复):preset 的 standing mount
|
|
@@ -147,10 +153,12 @@ function createStreamGuard(options) {
|
|
|
147
153
|
}
|
|
148
154
|
case 'reasoning-delta': {
|
|
149
155
|
const b = ensure(chunk.index, 'reasoning')
|
|
150
|
-
b.text += chunk.text // 始终累积:停止时需要完整内容闭合块
|
|
151
156
|
if (CONFIG.monitorReasoning) {
|
|
157
|
+
// feedText 内部负责累积完整文本与检测缓冲,这里不得重复 +=
|
|
152
158
|
const hit = feedText(b, chunk.text)
|
|
153
159
|
if (hit !== null) stopped = hit
|
|
160
|
+
} else {
|
|
161
|
+
b.text += chunk.text // 不检测时仍须累积完整内容以便停止时闭合块
|
|
154
162
|
}
|
|
155
163
|
return
|
|
156
164
|
}
|