dsh-repeat-guard 0.1.3 → 0.1.5
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/README.md +64 -195
- package/lib/client.js +124 -13
- package/lib/config.d.ts +30 -7
- package/lib/config.js +50 -7
- package/lib/detect.d.ts +9 -5
- package/lib/detect.js +58 -12
- package/lib/index.js +4 -4
- package/lib/resume.d.ts +5 -1
- package/lib/resume.js +13 -20
- package/lib/stream-guard.d.ts +2 -1
- package/lib/stream-guard.js +7 -4
- package/lib/turn-stopping-guard.d.ts +3 -1
- package/lib/turn-stopping-guard.js +3 -2
- package/package.json +6 -6
- package/src/client.tsx +145 -19
- package/src/config.ts +78 -10
- package/src/detect.ts +60 -10
- package/src/index.ts +4 -4
- package/src/resume.ts +14 -22
- package/src/stream-guard.ts +16 -3
- package/src/turn-stopping-guard.ts +4 -2
package/src/client.tsx
CHANGED
|
@@ -23,14 +23,25 @@ const SETTINGS_NS = 'repeat-guard';
|
|
|
23
23
|
const THRESHOLD_MIN = 1;
|
|
24
24
|
const THRESHOLD_MAX = 9;
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/** 宿主侧注册的配置。字段都带 schema 默认值,所以在这里是必有的。 */
|
|
27
27
|
interface RepeatGuardSettings {
|
|
28
28
|
/** 拦截短句表。 */
|
|
29
|
-
fragments
|
|
30
|
-
/**
|
|
31
|
-
threshold
|
|
29
|
+
fragments: string[];
|
|
30
|
+
/** 连续命中多少次才拦。 */
|
|
31
|
+
threshold: number;
|
|
32
|
+
/** 是否把"整行由多个表项拼成"也算命中。 */
|
|
33
|
+
inlineRepeat: boolean;
|
|
34
|
+
/** 截断后推给模型的指令正文。 */
|
|
35
|
+
resumeText: string;
|
|
36
|
+
/** 注入消息的折叠行摘要。 */
|
|
37
|
+
resumeSummary: string;
|
|
38
|
+
/** 累计拦截次数,由宿主侧维护。 */
|
|
39
|
+
count: number;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/** 输入框与文本域的公共外观。 */
|
|
43
|
+
const FIELD = `height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px`;
|
|
44
|
+
|
|
34
45
|
/** 样式。令牌名取自 dsh 前端已发布的设计令牌表。 */
|
|
35
46
|
const CSS = `
|
|
36
47
|
.rg-root{display:flex;flex-direction:column;width:100%}
|
|
@@ -53,6 +64,11 @@ const CSS = `
|
|
|
53
64
|
.rg-arrow{display:inline-flex;justify-content:center;align-items:center;width:17px;height:12px;padding:0;border:none;border-radius:3px;cursor:pointer;color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent)}
|
|
54
65
|
.rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
|
|
55
66
|
.rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
|
|
67
|
+
.rg-switch{position:relative;flex:none;width:40px;height:24px;padding:0;border:none;border-radius:12px;cursor:pointer;background:var(--dsw-alias-bg-module-platform);transition:background .15s ease}
|
|
68
|
+
.rg-switch::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .15s ease}
|
|
69
|
+
.rg-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}
|
|
70
|
+
.rg-switch[aria-checked="true"]::after{transform:translateX(16px)}
|
|
71
|
+
.rg-count{display:inline-flex;align-items:center;justify-content:center;min-width:72px;height:36px;padding:0 14px;box-sizing:border-box;border-radius:18px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-variant-numeric:tabular-nums}
|
|
56
72
|
.rg-list{display:flex;flex-direction:column;gap:2px;width:100%;max-height:300px;overflow-y:auto;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);box-sizing:border-box}
|
|
57
73
|
.rg-item{display:flex;align-items:center;gap:8px;padding:5px 6px 5px 12px;border-radius:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}
|
|
58
74
|
.rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
@@ -61,8 +77,10 @@ const CSS = `
|
|
|
61
77
|
.rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
|
|
62
78
|
.rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
|
|
63
79
|
.rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
|
|
64
|
-
.rg-input{flex:1;min-width:0
|
|
65
|
-
.rg-
|
|
80
|
+
.rg-input{flex:1;min-width:0;${FIELD}}
|
|
81
|
+
.rg-summaryInput{flex:none;width:260px;${FIELD}}
|
|
82
|
+
.rg-textarea{width:100%;min-height:84px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px;resize:vertical}
|
|
83
|
+
.rg-input:focus,.rg-summaryInput:focus,.rg-textarea:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
|
|
66
84
|
.rg-button{cursor:pointer;flex:none;height:32px;padding:0 14px;border:none;border-radius:16px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
|
|
67
85
|
.rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
|
|
68
86
|
`;
|
|
@@ -144,6 +162,28 @@ function Stepper({ value, min, max, unit, label, onChange }: StepperProps): Reac
|
|
|
144
162
|
);
|
|
145
163
|
}
|
|
146
164
|
|
|
165
|
+
interface SwitchProps {
|
|
166
|
+
checked: boolean;
|
|
167
|
+
label: string;
|
|
168
|
+
onChange: (next: boolean) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 开关。 */
|
|
172
|
+
function Switch({ checked, label, onChange }: SwitchProps): ReactElement {
|
|
173
|
+
return (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
role="switch"
|
|
177
|
+
aria-checked={checked}
|
|
178
|
+
aria-label={label}
|
|
179
|
+
className="rg-switch"
|
|
180
|
+
onClick={() => {
|
|
181
|
+
onChange(!checked);
|
|
182
|
+
}}
|
|
183
|
+
/>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
interface FragmentListProps {
|
|
148
188
|
items: readonly string[];
|
|
149
189
|
draft: string;
|
|
@@ -209,13 +249,19 @@ function FragmentList({ items, draft, onDraft, onAdd, onRemove }: FragmentListPr
|
|
|
209
249
|
|
|
210
250
|
interface FormProps {
|
|
211
251
|
initial: RepeatGuardSettings;
|
|
212
|
-
|
|
252
|
+
/** 宿主侧实时计数;由外层订阅刷新,不参与本地编辑。 */
|
|
253
|
+
count: number;
|
|
254
|
+
onReset: () => Promise<void>;
|
|
255
|
+
onSave: (next: RepeatGuardSettings) => Promise<void>;
|
|
213
256
|
}
|
|
214
257
|
|
|
215
|
-
/** 设置表单:标题行 +
|
|
216
|
-
function Form({ initial, onSave }: FormProps): ReactElement {
|
|
217
|
-
const [items, setItems] = useState<readonly string[]>(
|
|
218
|
-
const [
|
|
258
|
+
/** 设置表单:标题行 + 五行设置;改动先留在本地,点保存才写。 */
|
|
259
|
+
function Form({ initial, count, onReset, onSave }: FormProps): ReactElement {
|
|
260
|
+
const [items, setItems] = useState<readonly string[]>(initial.fragments);
|
|
261
|
+
const [limit, setLimit] = useState(initial.threshold);
|
|
262
|
+
const [inlineRepeat, setInlineRepeat] = useState(initial.inlineRepeat);
|
|
263
|
+
const [resumeText, setResumeText] = useState(initial.resumeText);
|
|
264
|
+
const [resumeSummary, setResumeSummary] = useState(initial.resumeSummary);
|
|
219
265
|
const [draft, setDraft] = useState('');
|
|
220
266
|
const [note, setNote] = useState('');
|
|
221
267
|
|
|
@@ -236,7 +282,14 @@ function Form({ initial, onSave }: FormProps): ReactElement {
|
|
|
236
282
|
|
|
237
283
|
function save(): void {
|
|
238
284
|
setNote('保存中…');
|
|
239
|
-
onSave(
|
|
285
|
+
onSave({
|
|
286
|
+
fragments: [...items],
|
|
287
|
+
threshold: limit,
|
|
288
|
+
inlineRepeat,
|
|
289
|
+
resumeText,
|
|
290
|
+
resumeSummary,
|
|
291
|
+
count,
|
|
292
|
+
})
|
|
240
293
|
.then(() => {
|
|
241
294
|
setNote('已保存');
|
|
242
295
|
})
|
|
@@ -245,6 +298,16 @@ function Form({ initial, onSave }: FormProps): ReactElement {
|
|
|
245
298
|
});
|
|
246
299
|
}
|
|
247
300
|
|
|
301
|
+
function reset(): void {
|
|
302
|
+
onReset()
|
|
303
|
+
.then(() => {
|
|
304
|
+
setNote('计数已清零');
|
|
305
|
+
})
|
|
306
|
+
.catch((error: unknown) => {
|
|
307
|
+
setNote(`清零失败:${String(error)}`);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
248
311
|
return (
|
|
249
312
|
<div className="rg-root">
|
|
250
313
|
<div className="rg-head">
|
|
@@ -254,19 +317,47 @@ function Form({ initial, onSave }: FormProps): ReactElement {
|
|
|
254
317
|
保存
|
|
255
318
|
</button>
|
|
256
319
|
</div>
|
|
320
|
+
<div className="rg-row">
|
|
321
|
+
<div className="rg-rowText">
|
|
322
|
+
<div className="rg-rowTitle">累计拦截</div>
|
|
323
|
+
<div className="rg-rowDesc">重复打断计数</div>
|
|
324
|
+
</div>
|
|
325
|
+
<span className="rg-control">
|
|
326
|
+
<span className="rg-count">{count} 次</span>
|
|
327
|
+
<button type="button" className="rg-button" onClick={reset}>
|
|
328
|
+
清零
|
|
329
|
+
</button>
|
|
330
|
+
</span>
|
|
331
|
+
</div>
|
|
257
332
|
<div className="rg-row">
|
|
258
333
|
<div className="rg-rowText">
|
|
259
334
|
<div className="rg-rowTitle">连续命中次数</div>
|
|
260
|
-
<div className="rg-rowDesc"
|
|
335
|
+
<div className="rg-rowDesc">循环次数上限;填 1 表示发现即拦</div>
|
|
261
336
|
</div>
|
|
262
337
|
<Stepper
|
|
263
|
-
value={
|
|
338
|
+
value={limit}
|
|
264
339
|
min={THRESHOLD_MIN}
|
|
265
340
|
max={THRESHOLD_MAX}
|
|
266
341
|
unit="次"
|
|
267
342
|
label="连续命中次数"
|
|
268
343
|
onChange={(next) => {
|
|
269
|
-
|
|
344
|
+
setLimit(next);
|
|
345
|
+
setNote('');
|
|
346
|
+
}}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
<div className="rg-row">
|
|
350
|
+
<div className="rg-rowText">
|
|
351
|
+
<div className="rg-rowTitle">行内重复检测</div>
|
|
352
|
+
<div className="rg-rowDesc">
|
|
353
|
+
拦截类似"好。好。好。"的行内循环;关闭时只检测独立段落。
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
<Switch
|
|
357
|
+
checked={inlineRepeat}
|
|
358
|
+
label="行内重复检测"
|
|
359
|
+
onChange={(next) => {
|
|
360
|
+
setInlineRepeat(next);
|
|
270
361
|
setNote('');
|
|
271
362
|
}}
|
|
272
363
|
/>
|
|
@@ -274,7 +365,7 @@ function Form({ initial, onSave }: FormProps): ReactElement {
|
|
|
274
365
|
<div className="rg-row rg-row--stack">
|
|
275
366
|
<div className="rg-rowText">
|
|
276
367
|
<div className="rg-rowTitle">拦截短句</div>
|
|
277
|
-
<div className="rg-rowDesc"
|
|
368
|
+
<div className="rg-rowDesc">标点不可省略</div>
|
|
278
369
|
</div>
|
|
279
370
|
<FragmentList
|
|
280
371
|
items={items}
|
|
@@ -284,6 +375,34 @@ function Form({ initial, onSave }: FormProps): ReactElement {
|
|
|
284
375
|
onRemove={remove}
|
|
285
376
|
/>
|
|
286
377
|
</div>
|
|
378
|
+
<div className="rg-row rg-row--stack">
|
|
379
|
+
<div className="rg-rowText">
|
|
380
|
+
<div className="rg-rowTitle">拦截提示词</div>
|
|
381
|
+
<div className="rg-rowDesc">截断后上下文注入的提示词</div>
|
|
382
|
+
</div>
|
|
383
|
+
<textarea
|
|
384
|
+
className="rg-textarea"
|
|
385
|
+
value={resumeText}
|
|
386
|
+
onChange={(event) => {
|
|
387
|
+
setResumeText(event.target.value);
|
|
388
|
+
setNote('');
|
|
389
|
+
}}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="rg-row">
|
|
393
|
+
<div className="rg-rowText">
|
|
394
|
+
<div className="rg-rowTitle">拦截摘要</div>
|
|
395
|
+
<div className="rg-rowDesc">上下文注入的简介信息,仅用户可见</div>
|
|
396
|
+
</div>
|
|
397
|
+
<input
|
|
398
|
+
className="rg-summaryInput"
|
|
399
|
+
value={resumeSummary}
|
|
400
|
+
onChange={(event) => {
|
|
401
|
+
setResumeSummary(event.target.value);
|
|
402
|
+
setNote('');
|
|
403
|
+
}}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
287
406
|
</div>
|
|
288
407
|
);
|
|
289
408
|
}
|
|
@@ -315,9 +434,16 @@ export function apply(ctx: Context): void {
|
|
|
315
434
|
return (
|
|
316
435
|
<Form
|
|
317
436
|
initial={value}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await scope.set('
|
|
437
|
+
count={value.count}
|
|
438
|
+
onReset={async () => {
|
|
439
|
+
await scope.set('count', 0);
|
|
440
|
+
}}
|
|
441
|
+
onSave={async (next) => {
|
|
442
|
+
await scope.set('fragments', next.fragments);
|
|
443
|
+
await scope.set('threshold', next.threshold);
|
|
444
|
+
await scope.set('inlineRepeat', next.inlineRepeat);
|
|
445
|
+
await scope.set('resumeText', next.resumeText);
|
|
446
|
+
await scope.set('resumeSummary', next.resumeSummary);
|
|
321
447
|
}}
|
|
322
448
|
/>
|
|
323
449
|
);
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
|
|
3
|
+
* 外加一个运行时计数。
|
|
3
4
|
*
|
|
4
5
|
* 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
|
|
5
|
-
* schema
|
|
6
|
+
* schema,客户端设置页写同一个命名空间。判定与续跑时现取,改完立即生效,不用重启。
|
|
7
|
+
*
|
|
8
|
+
* 计数也寄在同一个命名空间里:宿主侧每次掐断写一次,客户端读同一个值、清零就是
|
|
9
|
+
* 把它写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -52,43 +56,107 @@ export const DEFAULT_FRAGMENTS: readonly string[] = [
|
|
|
52
56
|
/** 默认阈值:1 表示发现即拦。 */
|
|
53
57
|
export const DEFAULT_THRESHOLD = 1;
|
|
54
58
|
|
|
59
|
+
/** 默认关闭行内重复检测:只认独占一行的整句。 */
|
|
60
|
+
export const DEFAULT_INLINE_REPEAT = false;
|
|
61
|
+
|
|
62
|
+
/** 默认的续跑指令正文,截断后推给模型。 */
|
|
63
|
+
export const DEFAULT_RESUME_TEXT = [
|
|
64
|
+
'[复读拦截]',
|
|
65
|
+
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
66
|
+
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
67
|
+
].join('\n');
|
|
68
|
+
|
|
69
|
+
/** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
|
|
70
|
+
export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
71
|
+
|
|
55
72
|
const SCHEMA = z.object({
|
|
56
73
|
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
|
|
57
74
|
threshold: z.number().default(DEFAULT_THRESHOLD),
|
|
75
|
+
inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT),
|
|
76
|
+
resumeText: z.string().default(DEFAULT_RESUME_TEXT),
|
|
77
|
+
resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY),
|
|
78
|
+
count: z.number().default(0),
|
|
58
79
|
});
|
|
59
80
|
|
|
60
|
-
/**
|
|
81
|
+
/** 判定与续跑用得上的一份配置。 */
|
|
61
82
|
export interface RepeatGuardConfig {
|
|
62
83
|
/** 短句表,已全部小写化。 */
|
|
63
84
|
readonly fragments: ReadonlySet<string>;
|
|
64
|
-
/**
|
|
85
|
+
/** 连续命中多少次才拦。 */
|
|
65
86
|
readonly threshold: number;
|
|
87
|
+
/** 是否把"整行由多个表项拼成"也算命中。 */
|
|
88
|
+
readonly inlineRepeat: boolean;
|
|
89
|
+
/** 截断后推给模型的指令正文。 */
|
|
90
|
+
readonly resumeText: string;
|
|
91
|
+
/** 注入消息的折叠行摘要。 */
|
|
92
|
+
readonly resumeSummary: string;
|
|
66
93
|
}
|
|
67
94
|
|
|
68
95
|
/** 取当前配置。 */
|
|
69
96
|
export type ConfigSource = () => RepeatGuardConfig;
|
|
70
97
|
|
|
71
|
-
|
|
98
|
+
/** 宿主侧要用到的两件事:取配置、记一次拦截。 */
|
|
99
|
+
export interface GuardRuntime {
|
|
100
|
+
/** 取当前配置。 */
|
|
101
|
+
readonly read: ConfigSource;
|
|
102
|
+
/** 记一次拦截,把计数写回 settings。 */
|
|
103
|
+
readonly countHit: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function compile(value: {
|
|
107
|
+
fragments: readonly string[];
|
|
108
|
+
threshold: number;
|
|
109
|
+
inlineRepeat: boolean;
|
|
110
|
+
resumeText: string;
|
|
111
|
+
resumeSummary: string;
|
|
112
|
+
}): RepeatGuardConfig {
|
|
72
113
|
return {
|
|
73
114
|
fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
|
|
74
115
|
threshold: value.threshold,
|
|
116
|
+
inlineRepeat: value.inlineRepeat,
|
|
117
|
+
resumeText: value.resumeText,
|
|
118
|
+
resumeSummary: value.resumeSummary,
|
|
75
119
|
};
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
/**
|
|
79
|
-
*
|
|
123
|
+
* 注册配置命名空间,返回宿主侧要用的运行时句柄。
|
|
80
124
|
* @param ctx - 宿主 cordis 上下文。
|
|
81
|
-
* @returns
|
|
125
|
+
* @returns 取配置与记数两个入口。
|
|
82
126
|
*/
|
|
83
|
-
export function
|
|
84
|
-
let current = compile({
|
|
127
|
+
export function createRuntime(ctx: Context): GuardRuntime {
|
|
128
|
+
let current = compile({
|
|
129
|
+
fragments: DEFAULT_FRAGMENTS,
|
|
130
|
+
threshold: DEFAULT_THRESHOLD,
|
|
131
|
+
inlineRepeat: DEFAULT_INLINE_REPEAT,
|
|
132
|
+
resumeText: DEFAULT_RESUME_TEXT,
|
|
133
|
+
resumeSummary: DEFAULT_RESUME_SUMMARY,
|
|
134
|
+
});
|
|
135
|
+
let bump: (() => void) | undefined;
|
|
136
|
+
|
|
85
137
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
86
138
|
const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
|
|
87
139
|
const sync = (): void => {
|
|
88
140
|
current = compile(scope.get());
|
|
89
141
|
};
|
|
142
|
+
const write = (patch: object): void => {
|
|
143
|
+
scope.update(patch).catch((error: unknown) => {
|
|
144
|
+
console.log(`[repeat-guard] 写设置失败:${String(error)}`);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
90
147
|
sync();
|
|
91
148
|
scope.watch(sync);
|
|
149
|
+
// 计数口径是"自本次启动或上次清零以来"。
|
|
150
|
+
write({ count: 0 });
|
|
151
|
+
bump = (): void => {
|
|
152
|
+
write({ count: scope.get().count + 1 });
|
|
153
|
+
};
|
|
92
154
|
});
|
|
93
|
-
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
read: () => current,
|
|
158
|
+
countHit: () => {
|
|
159
|
+
bump?.();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
94
162
|
}
|
package/src/detect.ts
CHANGED
|
@@ -2,34 +2,84 @@
|
|
|
2
2
|
* 复读退化判定:查表。
|
|
3
3
|
*
|
|
4
4
|
* 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
|
|
5
|
-
*
|
|
5
|
+
* 基本判据是"整行与表项全等",不拆解行的内部。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 行内重复检测(可开关,默认关)打开时,"整行由多个表项首尾相接拼成"也算命中,
|
|
8
|
+
* 用来兜住把短句连成一串写在同一行里的模型(如"好。好。好。")。
|
|
9
|
+
*
|
|
10
|
+
* 阈值是"连续命中多少次才拦":默认 1 表示发现即拦。计数单位是表项——整行全等算一次,
|
|
11
|
+
* 开了行内检测后行内每一段各算一次;中间夹一行不命中的就重新计数。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 在 line 的 at 处能匹配到的最长表项长度。
|
|
16
|
+
* @param line - 已小写化的行。
|
|
17
|
+
* @param at - 起始下标。
|
|
18
|
+
* @param fragments - 小写化的短句表。
|
|
19
|
+
* @returns 匹配到的长度,0 表示这个位置没有表项。
|
|
20
|
+
*/
|
|
21
|
+
function longestFragmentAt(line: string, at: number, fragments: ReadonlySet<string>): number {
|
|
22
|
+
let size = 0;
|
|
23
|
+
for (const fragment of fragments) {
|
|
24
|
+
if (fragment.length > size && line.startsWith(fragment, at)) {
|
|
25
|
+
size = fragment.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 这一行算几次命中。
|
|
33
|
+
* @param line - 已 trim 并小写化的行。
|
|
34
|
+
* @param fragments - 小写化的短句表。
|
|
35
|
+
* @param inlineRepeat - 是否把"整行由多个表项拼成"也算命中。
|
|
36
|
+
* @returns 0 表示不是命中行,其余为这一行贡献的次数。
|
|
9
37
|
*/
|
|
38
|
+
function countHits(line: string, fragments: ReadonlySet<string>, inlineRepeat: boolean): number {
|
|
39
|
+
if (fragments.has(line)) {
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (!inlineRepeat) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
let at = 0;
|
|
46
|
+
let parts = 0;
|
|
47
|
+
while (at < line.length) {
|
|
48
|
+
const size = longestFragmentAt(line, at, fragments);
|
|
49
|
+
if (size === 0) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
at += size;
|
|
53
|
+
parts += 1;
|
|
54
|
+
}
|
|
55
|
+
return parts > 1 ? parts : 0;
|
|
56
|
+
}
|
|
10
57
|
|
|
11
58
|
/**
|
|
12
59
|
* 找出退化的碎片行。
|
|
13
60
|
* @param text - 已生成的思考文本。
|
|
14
61
|
* @param fragments - 小写化的短句表。
|
|
15
|
-
* @param threshold -
|
|
62
|
+
* @param threshold - 连续命中多少次才判定为复读。
|
|
63
|
+
* @param inlineRepeat - 是否启用行内重复检测。
|
|
16
64
|
* @returns 计数成立时返回那一行的原文,否则返回 null。
|
|
17
65
|
*/
|
|
18
66
|
export function findDegenerateLine(
|
|
19
67
|
text: string,
|
|
20
68
|
fragments: ReadonlySet<string>,
|
|
21
69
|
threshold: number,
|
|
70
|
+
inlineRepeat: boolean,
|
|
22
71
|
): string | null {
|
|
23
72
|
let run = 0;
|
|
24
73
|
for (const raw of text.split('\n')) {
|
|
25
74
|
const line = raw.trim();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (run >= threshold) {
|
|
29
|
-
return line;
|
|
30
|
-
}
|
|
31
|
-
} else {
|
|
75
|
+
const hits = line === '' ? 0 : countHits(line.toLowerCase(), fragments, inlineRepeat);
|
|
76
|
+
if (hits === 0) {
|
|
32
77
|
run = 0;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
run += hits;
|
|
81
|
+
if (run >= threshold) {
|
|
82
|
+
return line;
|
|
33
83
|
}
|
|
34
84
|
}
|
|
35
85
|
return null;
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
23
|
|
|
24
24
|
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
-
import {
|
|
25
|
+
import { createRuntime } from './config.js';
|
|
26
26
|
import { createStreamGuard } from './stream-guard.js';
|
|
27
27
|
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
28
28
|
import type { GuardState } from './types.js';
|
|
@@ -35,7 +35,7 @@ export default function repeatGuard(ctx: Context): void {
|
|
|
35
35
|
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
36
36
|
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
37
37
|
const state: GuardState = { pending: new Set() };
|
|
38
|
-
const
|
|
39
|
-
ctx.on('llm/stream', createStreamGuard(state,
|
|
40
|
-
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
|
|
38
|
+
const runtime = createRuntime(ctx);
|
|
39
|
+
ctx.on('llm/stream', createStreamGuard(state, runtime.read, runtime.countHit), { global: true });
|
|
40
|
+
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state, runtime.read));
|
|
41
41
|
}
|
package/src/resume.ts
CHANGED
|
@@ -11,37 +11,22 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
13
|
* 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
|
|
14
|
+
*
|
|
15
|
+
* 推什么内容由配置决定(见 config.ts 的 resumeText / resumeSummary)。
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
17
19
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
20
|
+
import type { RepeatGuardConfig } from './config.js';
|
|
18
21
|
import type { GuardState } from './types.js';
|
|
19
22
|
|
|
20
|
-
/** 续跑时推给模型的指令正文。 */
|
|
21
|
-
const RESUME_TEXT = [
|
|
22
|
-
'[复读拦截]',
|
|
23
|
-
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
24
|
-
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
25
|
-
].join('\n');
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* 这条注入消息的来源标记。
|
|
29
|
-
* `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
30
|
-
* 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
31
|
-
*/
|
|
32
|
-
const PLUGIN_SOURCE = {
|
|
33
|
-
kind: 'plugin',
|
|
34
|
-
plugin: 'repeat-guard',
|
|
35
|
-
form: 'notice',
|
|
36
|
-
summary: '复读已截断:请继续执行',
|
|
37
|
-
} as const;
|
|
38
|
-
|
|
39
23
|
/**
|
|
40
24
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
41
25
|
* @param state - 跨监听保留的拦截状态。
|
|
42
26
|
* @param agent - 本轮所属的 agent 句柄。
|
|
27
|
+
* @param config - 当前配置,正文与摘要都从这里取。
|
|
43
28
|
*/
|
|
44
|
-
export function reviveTurn(state: GuardState, agent: Agent): void {
|
|
29
|
+
export function reviveTurn(state: GuardState, agent: Agent, config: RepeatGuardConfig): void {
|
|
45
30
|
const sessionId = agent.session.id;
|
|
46
31
|
if (!state.pending.has(sessionId)) {
|
|
47
32
|
return;
|
|
@@ -50,8 +35,15 @@ export function reviveTurn(state: GuardState, agent: Agent): void {
|
|
|
50
35
|
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
|
|
51
36
|
agent.steer(
|
|
52
37
|
createUserMessage({
|
|
53
|
-
content: [{ type: 'text', text:
|
|
54
|
-
|
|
38
|
+
content: [{ type: 'text', text: config.resumeText }],
|
|
39
|
+
// `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
40
|
+
// 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
41
|
+
source: {
|
|
42
|
+
kind: 'plugin',
|
|
43
|
+
plugin: 'repeat-guard',
|
|
44
|
+
form: 'notice',
|
|
45
|
+
summary: config.resumeSummary,
|
|
46
|
+
},
|
|
55
47
|
}),
|
|
56
48
|
);
|
|
57
49
|
}
|
package/src/stream-guard.ts
CHANGED
|
@@ -29,6 +29,7 @@ type StreamListener = (
|
|
|
29
29
|
* @param sessionId - 当前会话 id;有值才登记待续跑标记。
|
|
30
30
|
* @param state - 跨监听保留的拦截状态。
|
|
31
31
|
* @param readConfig - 取当前配置;每个 chunk 现取,改设置立即生效。
|
|
32
|
+
* @param countHit - 记一次拦截;只在真的掐断时调用。
|
|
32
33
|
* @returns 包好的流。
|
|
33
34
|
*/
|
|
34
35
|
async function* guardStream(
|
|
@@ -36,6 +37,7 @@ async function* guardStream(
|
|
|
36
37
|
sessionId: GenerateOptions['sessionId'],
|
|
37
38
|
state: GuardState,
|
|
38
39
|
readConfig: ConfigSource,
|
|
40
|
+
countHit: () => void,
|
|
39
41
|
): AsyncGenerator<StreamChunk> {
|
|
40
42
|
const iterator = downstream[Symbol.asyncIterator]();
|
|
41
43
|
let accumulated = '';
|
|
@@ -49,10 +51,16 @@ async function* guardStream(
|
|
|
49
51
|
if (chunk.type === 'reasoning-delta') {
|
|
50
52
|
accumulated += chunk.text;
|
|
51
53
|
const config = readConfig();
|
|
52
|
-
const hit = findDegenerateLine(
|
|
54
|
+
const hit = findDegenerateLine(
|
|
55
|
+
accumulated,
|
|
56
|
+
config.fragments,
|
|
57
|
+
config.threshold,
|
|
58
|
+
config.inlineRepeat,
|
|
59
|
+
);
|
|
53
60
|
if (hit !== null) {
|
|
54
61
|
const probe = await iterator.next();
|
|
55
62
|
if (!probe.done && probe.value.type === 'reasoning-delta') {
|
|
63
|
+
countHit();
|
|
56
64
|
if (sessionId !== undefined) {
|
|
57
65
|
state.pending.add(sessionId);
|
|
58
66
|
}
|
|
@@ -92,14 +100,19 @@ async function* guardStream(
|
|
|
92
100
|
* 造一个 `llm/stream` 监听器。
|
|
93
101
|
* @param state - 跨监听保留的拦截状态。
|
|
94
102
|
* @param readConfig - 取当前配置。
|
|
103
|
+
* @param countHit - 记一次拦截。
|
|
95
104
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
96
105
|
*/
|
|
97
|
-
export function createStreamGuard(
|
|
106
|
+
export function createStreamGuard(
|
|
107
|
+
state: GuardState,
|
|
108
|
+
readConfig: ConfigSource,
|
|
109
|
+
countHit: () => void,
|
|
110
|
+
): StreamListener {
|
|
98
111
|
return (options, next) => {
|
|
99
112
|
// 辅助调用(上下文压缩、会话标题)不参与检测。
|
|
100
113
|
if (options.purpose !== undefined) {
|
|
101
114
|
return next();
|
|
102
115
|
}
|
|
103
|
-
return guardStream(next(), options.sessionId, state, readConfig);
|
|
116
|
+
return guardStream(next(), options.sessionId, state, readConfig, countHit);
|
|
104
117
|
};
|
|
105
118
|
}
|
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
9
|
+
import type { ConfigSource } from './config.js';
|
|
9
10
|
import { reviveTurn } from './resume.js';
|
|
10
11
|
import type { GuardState } from './types.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* 造一个 `agent/turn-stopping` 监听器。
|
|
14
15
|
* @param state - 跨监听保留的拦截状态。
|
|
16
|
+
* @param readConfig - 取当前配置。
|
|
15
17
|
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
16
18
|
*/
|
|
17
|
-
export function createTurnStoppingGuard(state: GuardState) {
|
|
19
|
+
export function createTurnStoppingGuard(state: GuardState, readConfig: ConfigSource) {
|
|
18
20
|
return (payload: { agent: Agent }): void => {
|
|
19
|
-
reviveTurn(state, payload.agent);
|
|
21
|
+
reviveTurn(state, payload.agent, readConfig());
|
|
20
22
|
};
|
|
21
23
|
}
|