dsh-repeat-guard 0.1.0 → 0.1.2
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 +74 -29
- package/lib/client.d.ts +17 -0
- package/lib/client.js +113 -0
- package/lib/config.d.ts +28 -0
- package/lib/config.js +73 -0
- package/lib/detect.d.ts +8 -5
- package/lib/detect.js +17 -50
- package/lib/index.d.ts +2 -2
- package/lib/index.js +11 -12
- package/lib/resume.d.ts +4 -3
- package/lib/resume.js +16 -31
- package/lib/stream-guard.d.ts +7 -3
- package/lib/stream-guard.js +28 -13
- package/lib/turn-stopping-guard.d.ts +4 -3
- package/lib/turn-stopping-guard.js +2 -3
- package/lib/types.d.ts +4 -43
- package/lib/types.js +4 -3
- package/package.json +32 -2
- package/src/client.tsx +118 -0
- package/src/config.ts +94 -0
- package/src/detect.ts +20 -52
- package/src/index.ts +14 -17
- package/src/resume.ts +21 -40
- package/src/stream-guard.ts +35 -18
- package/src/turn-stopping-guard.ts +5 -8
- package/src/types.ts +4 -48
package/lib/resume.js
CHANGED
|
@@ -10,55 +10,40 @@
|
|
|
10
10
|
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
11
|
*
|
|
12
12
|
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
-
* 用户输入。dsh
|
|
13
|
+
* 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
|
|
14
14
|
*/
|
|
15
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
15
16
|
/** 续跑时推给模型的指令正文。 */
|
|
16
17
|
const RESUME_TEXT = [
|
|
17
18
|
'[复读拦截]',
|
|
18
19
|
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
19
20
|
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
20
21
|
].join('\n');
|
|
21
|
-
/** 折叠行显示的一行摘要;空字符串会让客户端回退成 opaque 渲染,故不可为空。 */
|
|
22
|
-
const RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* 这条注入消息的来源标记。
|
|
24
|
+
* `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
25
|
+
* 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
26
26
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
kind: 'plugin',
|
|
34
|
-
plugin: 'repeat-guard',
|
|
35
|
-
form: 'notice',
|
|
36
|
-
summary: RESUME_SUMMARY,
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
}
|
|
27
|
+
const PLUGIN_SOURCE = {
|
|
28
|
+
kind: 'plugin',
|
|
29
|
+
plugin: 'repeat-guard',
|
|
30
|
+
form: 'notice',
|
|
31
|
+
summary: '复读已截断:请继续执行',
|
|
32
|
+
};
|
|
40
33
|
/**
|
|
41
34
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
42
35
|
* @param state - 跨监听保留的拦截状态。
|
|
43
36
|
* @param agent - 本轮所属的 agent 句柄。
|
|
44
37
|
*/
|
|
45
38
|
export function reviveTurn(state, agent) {
|
|
46
|
-
|
|
47
|
-
if (agent === undefined || typeof agent.steer !== 'function') {
|
|
48
|
-
console.log('[repeat-guard] turn-stopping:载荷里没有 agent 句柄,不续跑');
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const sessionId = agent.session?.id;
|
|
52
|
-
if (sessionId === undefined) {
|
|
53
|
-
console.log('[repeat-guard] turn-stopping:agent 上没有会话 id,不续跑');
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
39
|
+
const sessionId = agent.session.id;
|
|
56
40
|
if (!state.pending.has(sessionId)) {
|
|
57
|
-
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 没有待续跑标记,不续跑` +
|
|
58
|
-
`(当前有标记的会话:${[...state.pending].join(',') || '无'})`);
|
|
59
41
|
return;
|
|
60
42
|
}
|
|
61
43
|
state.pending.delete(sessionId);
|
|
62
44
|
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
|
|
63
|
-
agent.steer(
|
|
45
|
+
agent.steer(createUserMessage({
|
|
46
|
+
content: [{ type: 'text', text: RESUME_TEXT }],
|
|
47
|
+
source: PLUGIN_SOURCE,
|
|
48
|
+
}));
|
|
64
49
|
}
|
package/lib/stream-guard.d.ts
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
5
|
*/
|
|
6
|
-
import type { GenerateOptions,
|
|
6
|
+
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
7
|
+
import type { ConfigSource } from './config.js';
|
|
8
|
+
import type { GuardState } from './types.js';
|
|
7
9
|
/** `llm/stream` 的监听器签名。 */
|
|
8
|
-
|
|
10
|
+
type StreamListener = (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>;
|
|
9
11
|
/**
|
|
10
12
|
* 造一个 `llm/stream` 监听器。
|
|
11
13
|
* @param state - 跨监听保留的拦截状态。
|
|
14
|
+
* @param readConfig - 取当前配置。
|
|
12
15
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
13
16
|
*/
|
|
14
|
-
export declare function createStreamGuard(state: GuardState): StreamListener;
|
|
17
|
+
export declare function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener;
|
|
18
|
+
export {};
|
package/lib/stream-guard.js
CHANGED
|
@@ -10,12 +10,17 @@ import { findDegenerateLine } from './detect.js';
|
|
|
10
10
|
* 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
|
|
11
11
|
* 所以累积文本用一个字符串即可,不需要按 index 分开存。
|
|
12
12
|
*
|
|
13
|
+
* 命中之后先往下探一格,判"思考段是不是就到这儿了":再往下若还是 reasoning-delta,
|
|
14
|
+
* 说明思考在继续复读,该拦;若换成正文、工具调用,或直接收尾,说明命中行本来就是
|
|
15
|
+
* 这段思考的最后一句,属正常收尾,不拦。
|
|
16
|
+
*
|
|
13
17
|
* @param downstream - 上游模型的流。
|
|
14
18
|
* @param sessionId - 当前会话 id;有值才登记待续跑标记。
|
|
15
19
|
* @param state - 跨监听保留的拦截状态。
|
|
20
|
+
* @param readConfig - 取当前配置;每个 chunk 现取,改设置立即生效。
|
|
16
21
|
* @returns 包好的流。
|
|
17
22
|
*/
|
|
18
|
-
async function* guardStream(downstream, sessionId, state) {
|
|
23
|
+
async function* guardStream(downstream, sessionId, state, readConfig) {
|
|
19
24
|
const iterator = downstream[Symbol.asyncIterator]();
|
|
20
25
|
let accumulated = '';
|
|
21
26
|
try {
|
|
@@ -25,20 +30,29 @@ async function* guardStream(downstream, sessionId, state) {
|
|
|
25
30
|
return;
|
|
26
31
|
}
|
|
27
32
|
const chunk = step.value;
|
|
28
|
-
if (chunk
|
|
33
|
+
if (chunk.type === 'reasoning-delta') {
|
|
29
34
|
accumulated += chunk.text;
|
|
30
|
-
const
|
|
35
|
+
const config = readConfig();
|
|
36
|
+
const hit = findDegenerateLine(accumulated, config.fragments, config.threshold);
|
|
31
37
|
if (hit !== null) {
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
const probe = await iterator.next();
|
|
39
|
+
if (!probe.done && probe.value.type === 'reasoning-delta') {
|
|
40
|
+
if (sessionId !== undefined) {
|
|
41
|
+
state.pending.add(sessionId);
|
|
42
|
+
}
|
|
43
|
+
console.log(`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
|
|
44
|
+
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
45
|
+
yield chunk;
|
|
46
|
+
yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
|
|
47
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
48
|
+
return;
|
|
34
49
|
}
|
|
35
|
-
console.log(`[repeat-guard]
|
|
36
|
-
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
37
|
-
const index = chunk.index ?? 0;
|
|
50
|
+
console.log(`[repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
|
|
38
51
|
yield chunk;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
if (!probe.done) {
|
|
53
|
+
yield probe.value;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
yield chunk;
|
|
@@ -58,14 +72,15 @@ async function* guardStream(downstream, sessionId, state) {
|
|
|
58
72
|
/**
|
|
59
73
|
* 造一个 `llm/stream` 监听器。
|
|
60
74
|
* @param state - 跨监听保留的拦截状态。
|
|
75
|
+
* @param readConfig - 取当前配置。
|
|
61
76
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
62
77
|
*/
|
|
63
|
-
export function createStreamGuard(state) {
|
|
78
|
+
export function createStreamGuard(state, readConfig) {
|
|
64
79
|
return (options, next) => {
|
|
65
80
|
// 辅助调用(上下文压缩、会话标题)不参与检测。
|
|
66
81
|
if (options.purpose !== undefined) {
|
|
67
82
|
return next();
|
|
68
83
|
}
|
|
69
|
-
return guardStream(next(), options.sessionId, state);
|
|
84
|
+
return guardStream(next(), options.sessionId, state, readConfig);
|
|
70
85
|
};
|
|
71
86
|
}
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* 监听器的返回值不被使用(该事件是 serial 的副作用钩子),实现方式是调
|
|
5
5
|
* `agent.steer(...)`,见 resume.ts 的说明。
|
|
6
6
|
*/
|
|
7
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
7
8
|
import type { GuardState } from './types.js';
|
|
8
|
-
/** `agent/turn-stopping` 的监听器签名。 */
|
|
9
|
-
export type TurnStoppingListener = (payload: unknown) => void;
|
|
10
9
|
/**
|
|
11
10
|
* 造一个 `agent/turn-stopping` 监听器。
|
|
12
11
|
* @param state - 跨监听保留的拦截状态。
|
|
13
12
|
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
14
13
|
*/
|
|
15
|
-
export declare function createTurnStoppingGuard(state: GuardState):
|
|
14
|
+
export declare function createTurnStoppingGuard(state: GuardState): (payload: {
|
|
15
|
+
agent: Agent;
|
|
16
|
+
}) => void;
|
|
@@ -11,8 +11,7 @@ import { reviveTurn } from './resume.js';
|
|
|
11
11
|
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
12
12
|
*/
|
|
13
13
|
export function createTurnStoppingGuard(state) {
|
|
14
|
-
return (
|
|
15
|
-
|
|
16
|
-
reviveTurn(state, payload?.agent);
|
|
14
|
+
return (payload) => {
|
|
15
|
+
reviveTurn(state, payload.agent);
|
|
17
16
|
};
|
|
18
17
|
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,49 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 本插件自有的类型。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 与 dsh 交互的类型不在这里重抄:`Context`、`StreamChunk`、`GenerateOptions`、
|
|
5
|
+
* `Agent`、`UserMessage` 一律从 `@deepseek-ai/*` 引。本地抄一份会在 dsh 升级时
|
|
6
|
+
* 静默过期,而官方声明会让它在构建期就报出来。
|
|
6
7
|
*/
|
|
7
|
-
/** 一个流式 chunk。本插件只读 reasoning-delta 的 index/text,其余字段原样透传。 */
|
|
8
|
-
export interface StreamChunk {
|
|
9
|
-
readonly type: string;
|
|
10
|
-
readonly index?: number;
|
|
11
|
-
readonly text?: string;
|
|
12
|
-
readonly [key: string]: unknown;
|
|
13
|
-
}
|
|
14
|
-
/** 一次模型调用的参数;只声明本插件读到的两个字段。 */
|
|
15
|
-
export interface GenerateOptions {
|
|
16
|
-
readonly purpose?: string;
|
|
17
|
-
readonly sessionId?: string;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* `agent/turn-stopping` 的载荷。
|
|
21
|
-
*
|
|
22
|
-
* dsh 的注释写明:本轮即将关闭(模型不再欠响应)时、在边界提交之前 await 这个事件;
|
|
23
|
-
* 监听器若不同意关闭,就调 `agent.steer(...)` 推入新的输入,机器会重读收件箱——
|
|
24
|
-
* 有新的 steering 就再跑一步,没有才真正关闭本轮。
|
|
25
|
-
*/
|
|
26
|
-
export interface TurnStoppingPayload {
|
|
27
|
-
readonly agent?: SteerableAgent;
|
|
28
|
-
}
|
|
29
|
-
/** 能往本轮收件箱推输入、从而让本轮继续的 agent 句柄。 */
|
|
30
|
-
export interface SteerableAgent {
|
|
31
|
-
readonly session?: {
|
|
32
|
-
readonly id?: string;
|
|
33
|
-
};
|
|
34
|
-
/** 推入 `next-step` 并唤醒驱动器:本轮再跑一步。 */
|
|
35
|
-
steer(input: unknown): void;
|
|
36
|
-
}
|
|
37
|
-
/** 注入给 dsh 的 cordis 上下文;只声明本插件用到的能力。 */
|
|
38
|
-
export interface PluginContext {
|
|
39
|
-
/**
|
|
40
|
-
* 监听器的形参随所监听的事件而异,只能声明成 any[]:换成 unknown[] 会因函数
|
|
41
|
-
* 参数逆变,导致各监听器的具体签名无法赋值。
|
|
42
|
-
*/
|
|
43
|
-
on(name: string, listener: (...args: any[]) => unknown, options?: {
|
|
44
|
-
global?: boolean;
|
|
45
|
-
}): void;
|
|
46
|
-
}
|
|
47
8
|
/** 跨两次监听保留的拦截状态。 */
|
|
48
9
|
export interface GuardState {
|
|
49
10
|
/** 发生过截断、等待下一步续跑的会话 id。 */
|
package/lib/types.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 本插件自有的类型。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 与 dsh 交互的类型不在这里重抄:`Context`、`StreamChunk`、`GenerateOptions`、
|
|
5
|
+
* `Agent`、`UserMessage` 一律从 `@deepseek-ai/*` 引。本地抄一份会在 dsh 升级时
|
|
6
|
+
* 静默过期,而官方声明会让它在构建期就报出来。
|
|
6
7
|
*/
|
|
7
8
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-repeat-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "流式输出退化拦截:检测碎片复读,掐断本次生成并提醒模型直接执行工具调用",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
11
|
"default": "./lib/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
13
16
|
"./package.json": "./package.json"
|
|
14
17
|
},
|
|
15
18
|
"files": [
|
|
@@ -20,15 +23,42 @@
|
|
|
20
23
|
"dsh": {
|
|
21
24
|
"bundle": {
|
|
22
25
|
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web",
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
32
|
+
]
|
|
23
33
|
}
|
|
24
34
|
},
|
|
25
35
|
"scripts": {
|
|
26
|
-
"build": "tsc",
|
|
36
|
+
"build": "tsc && node scripts/build-client.mjs",
|
|
27
37
|
"prepack": "npm run build"
|
|
28
38
|
},
|
|
29
39
|
"license": "MIT",
|
|
30
40
|
"author": "yuqing",
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
43
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
|
|
46
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
49
|
+
"react": "^18.2.0"
|
|
50
|
+
},
|
|
31
51
|
"devDependencies": {
|
|
52
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
53
|
+
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
|
58
|
+
"@deepseek-ai/schemastery": "3.18.2",
|
|
59
|
+
"@types/node": "^22.0.0",
|
|
60
|
+
"@types/react": "^19.3.0",
|
|
61
|
+
"esbuild": "^0.28.2",
|
|
32
62
|
"typescript": "^7.0.2"
|
|
33
63
|
}
|
|
34
64
|
}
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端设置页:在设置窗口左侧加一项"复读打断"。
|
|
3
|
+
*
|
|
4
|
+
* 面板里编辑两样东西——拦截短句表(一行一句)与连续命中阈值,两者都写宿主侧
|
|
5
|
+
* 注册的 settings 命名空间,保存后宿主侧立即按新配置判定。
|
|
6
|
+
*
|
|
7
|
+
* 本文件由 scripts/build-client.mjs 用 esbuild 单独打包成单文件 bundle
|
|
8
|
+
* (`lib/client.js`),不走 tsc 的 lib 输出。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
12
|
+
// 空导入:只为加载这两处的 declaration merging(往 cordis 的 Context 上补
|
|
13
|
+
// slots 与 settingsScope 两个客户端服务)。
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
16
|
+
import { useEffect, useState, type ReactElement } from 'react';
|
|
17
|
+
|
|
18
|
+
/** settings 命名空间,必须与宿主侧 config.ts 的 SETTINGS_NS 逐字一致。 */
|
|
19
|
+
const SETTINGS_NS = 'repeat-guard';
|
|
20
|
+
|
|
21
|
+
/** 本插件配置节选。 */
|
|
22
|
+
interface RepeatGuardSettings {
|
|
23
|
+
/** 拦截短句表。 */
|
|
24
|
+
fragments?: string[];
|
|
25
|
+
/** 连续命中多少行才拦。 */
|
|
26
|
+
threshold?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
|
|
30
|
+
export const inject = ['slots', 'settingsScope'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 客户端插件入口。
|
|
34
|
+
* @param ctx - 浏览器侧 cordis 上下文。
|
|
35
|
+
*/
|
|
36
|
+
export function apply(ctx: Context): void {
|
|
37
|
+
const scope = ctx.settingsScope.bind<RepeatGuardSettings>({ namespace: SETTINGS_NS });
|
|
38
|
+
|
|
39
|
+
function Form({ initial }: { initial: RepeatGuardSettings }): ReactElement {
|
|
40
|
+
const [fragments, setFragments] = useState(() => (initial.fragments ?? []).join('\n'));
|
|
41
|
+
const [threshold, setThreshold] = useState(() => String(initial.threshold ?? 1));
|
|
42
|
+
const [message, setMessage] = useState('');
|
|
43
|
+
|
|
44
|
+
function save(): void {
|
|
45
|
+
const list = fragments
|
|
46
|
+
.split('\n')
|
|
47
|
+
.map((line) => line.trim())
|
|
48
|
+
.filter((line) => line !== '');
|
|
49
|
+
const count = Number(threshold);
|
|
50
|
+
setMessage('保存中…');
|
|
51
|
+
scope
|
|
52
|
+
.set('fragments', list)
|
|
53
|
+
.then(() => scope.set('threshold', count))
|
|
54
|
+
.then(() => {
|
|
55
|
+
setMessage('已保存');
|
|
56
|
+
})
|
|
57
|
+
.catch((error: unknown) => {
|
|
58
|
+
setMessage(`保存失败:${String(error)}`);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
64
|
+
<label>
|
|
65
|
+
<span>连续命中次数</span>
|
|
66
|
+
<input
|
|
67
|
+
type="number"
|
|
68
|
+
value={threshold}
|
|
69
|
+
onChange={(event) => {
|
|
70
|
+
setThreshold(event.target.value);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
<span>填 1 表示命中即拦;填 n 表示连着 n 行都命中才拦。</span>
|
|
74
|
+
</label>
|
|
75
|
+
<label>
|
|
76
|
+
<span>拦截短句(一行一句,连标点一起写)</span>
|
|
77
|
+
<textarea
|
|
78
|
+
rows={16}
|
|
79
|
+
style={{ width: '100%', fontFamily: 'monospace' }}
|
|
80
|
+
value={fragments}
|
|
81
|
+
onChange={(event) => {
|
|
82
|
+
setFragments(event.target.value);
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
<div>
|
|
87
|
+
<button type="button" onClick={save}>
|
|
88
|
+
保存
|
|
89
|
+
</button>
|
|
90
|
+
<span>{message}</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Panel(): ReactElement {
|
|
97
|
+
const [snapshot, setSnapshot] = useState(() => scope.getSnapshot());
|
|
98
|
+
useEffect(
|
|
99
|
+
() =>
|
|
100
|
+
scope.subscribe(() => {
|
|
101
|
+
setSnapshot(scope.getSnapshot());
|
|
102
|
+
}),
|
|
103
|
+
[],
|
|
104
|
+
);
|
|
105
|
+
const value = snapshot.value;
|
|
106
|
+
if (snapshot.status !== 'ready' || value === undefined) {
|
|
107
|
+
return <p>{`配置尚未就绪(${snapshot.status})`}</p>;
|
|
108
|
+
}
|
|
109
|
+
return <Form initial={value} />;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
ctx.slots.inject('settings.section', () =>
|
|
113
|
+
ctx.slots.register(
|
|
114
|
+
{ name: 'settings.section', id: 'repeat-guard', order: 100, label: '复读打断' },
|
|
115
|
+
Panel,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件配置:拦截短句表与连续命中阈值。
|
|
3
|
+
*
|
|
4
|
+
* 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
|
|
5
|
+
* schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
+
// 空导入:只为加载它的 declaration merging(往 cordis 的 Context 上补 settings 服务)。
|
|
10
|
+
import type {} from '@deepseek-ai/dsh-settings';
|
|
11
|
+
import z from '@deepseek-ai/schemastery';
|
|
12
|
+
|
|
13
|
+
/** settings 命名空间。客户端设置页必须写同一个值。 */
|
|
14
|
+
export const SETTINGS_NS = 'repeat-guard';
|
|
15
|
+
|
|
16
|
+
/** 默认的拦截短句表,标点照原样写。 */
|
|
17
|
+
export const DEFAULT_FRAGMENTS: readonly string[] = [
|
|
18
|
+
'好。',
|
|
19
|
+
'好的。',
|
|
20
|
+
'好嘞。',
|
|
21
|
+
'对。',
|
|
22
|
+
'对的。',
|
|
23
|
+
'是。',
|
|
24
|
+
'是的。',
|
|
25
|
+
'行。',
|
|
26
|
+
'嗯。',
|
|
27
|
+
'可以。',
|
|
28
|
+
'明白。',
|
|
29
|
+
'收到。',
|
|
30
|
+
'了解。',
|
|
31
|
+
'执行。',
|
|
32
|
+
'继续。',
|
|
33
|
+
'确认。',
|
|
34
|
+
'完成。',
|
|
35
|
+
'搞定。',
|
|
36
|
+
'写。',
|
|
37
|
+
'查。',
|
|
38
|
+
'看。',
|
|
39
|
+
'ok.',
|
|
40
|
+
'okay.',
|
|
41
|
+
'sure.',
|
|
42
|
+
'alright.',
|
|
43
|
+
'right.',
|
|
44
|
+
'yes.',
|
|
45
|
+
'done.',
|
|
46
|
+
'got it.',
|
|
47
|
+
'let me go.',
|
|
48
|
+
'let me do it.',
|
|
49
|
+
'check it.',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** 默认阈值:1 表示发现即拦。 */
|
|
53
|
+
export const DEFAULT_THRESHOLD = 1;
|
|
54
|
+
|
|
55
|
+
const SCHEMA = z.object({
|
|
56
|
+
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
|
|
57
|
+
threshold: z.number().default(DEFAULT_THRESHOLD),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** 一份生效中的配置,已编译成判定时直接可用的形式。 */
|
|
61
|
+
export interface RepeatGuardConfig {
|
|
62
|
+
/** 短句表,已全部小写化。 */
|
|
63
|
+
readonly fragments: ReadonlySet<string>;
|
|
64
|
+
/** 连续命中多少行才拦。 */
|
|
65
|
+
readonly threshold: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 取当前配置。 */
|
|
69
|
+
export type ConfigSource = () => RepeatGuardConfig;
|
|
70
|
+
|
|
71
|
+
function compile(value: { fragments: readonly string[]; threshold: number }): RepeatGuardConfig {
|
|
72
|
+
return {
|
|
73
|
+
fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
|
|
74
|
+
threshold: value.threshold,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 注册配置命名空间,并返回取当前配置的函数。
|
|
80
|
+
* @param ctx - 宿主 cordis 上下文。
|
|
81
|
+
* @returns 每次调用都返回最新配置。
|
|
82
|
+
*/
|
|
83
|
+
export function createConfigSource(ctx: Context): ConfigSource {
|
|
84
|
+
let current = compile({ fragments: DEFAULT_FRAGMENTS, threshold: DEFAULT_THRESHOLD });
|
|
85
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
86
|
+
const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
|
|
87
|
+
const sync = (): void => {
|
|
88
|
+
current = compile(scope.get());
|
|
89
|
+
};
|
|
90
|
+
sync();
|
|
91
|
+
scope.watch(sync);
|
|
92
|
+
});
|
|
93
|
+
return () => current;
|
|
94
|
+
}
|
package/src/detect.ts
CHANGED
|
@@ -1,67 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 复读退化判定:查表。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
|
|
5
|
+
* 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
|
|
8
|
+
* (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
* 会被复读的空话短句表,标点照原样写。
|
|
12
|
-
*
|
|
13
|
-
* 英文条目一律写小写,判定时会把整行转成小写再比。
|
|
14
|
-
*/
|
|
15
|
-
const FRAGMENTS: readonly string[] = [
|
|
16
|
-
// 中文
|
|
17
|
-
'好。',
|
|
18
|
-
'好的。',
|
|
19
|
-
'好嘞。',
|
|
20
|
-
'对。',
|
|
21
|
-
'对的。',
|
|
22
|
-
'是。',
|
|
23
|
-
'是的。',
|
|
24
|
-
'行。',
|
|
25
|
-
'嗯。',
|
|
26
|
-
'可以。',
|
|
27
|
-
'明白。',
|
|
28
|
-
'收到。',
|
|
29
|
-
'了解。',
|
|
30
|
-
'执行。',
|
|
31
|
-
'继续。',
|
|
32
|
-
'确认。',
|
|
33
|
-
'完成。',
|
|
34
|
-
'搞定。',
|
|
35
|
-
'写。',
|
|
36
|
-
'查。',
|
|
37
|
-
'看。',
|
|
38
|
-
// 英文
|
|
39
|
-
'ok.',
|
|
40
|
-
'okay.',
|
|
41
|
-
'sure.',
|
|
42
|
-
'alright.',
|
|
43
|
-
'right.',
|
|
44
|
-
'yes.',
|
|
45
|
-
'done.',
|
|
46
|
-
'got it.',
|
|
47
|
-
'let me go.',
|
|
48
|
-
'let me do it.',
|
|
49
|
-
'check it.',
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
/** 查表用的集合。 */
|
|
53
|
-
const FRAGMENT_SET = new Set(FRAGMENTS);
|
|
54
|
-
|
|
55
11
|
/**
|
|
56
12
|
* 找出退化的碎片行。
|
|
57
13
|
* @param text - 已生成的思考文本。
|
|
58
|
-
* @
|
|
14
|
+
* @param fragments - 小写化的短句表。
|
|
15
|
+
* @param threshold - 连续命中多少行才判定为复读。
|
|
16
|
+
* @returns 计数成立时返回那一行的原文,否则返回 null。
|
|
59
17
|
*/
|
|
60
|
-
export function findDegenerateLine(
|
|
18
|
+
export function findDegenerateLine(
|
|
19
|
+
text: string,
|
|
20
|
+
fragments: ReadonlySet<string>,
|
|
21
|
+
threshold: number,
|
|
22
|
+
): string | null {
|
|
23
|
+
let run = 0;
|
|
61
24
|
for (const raw of text.split('\n')) {
|
|
62
25
|
const line = raw.trim();
|
|
63
|
-
if (line !== '' &&
|
|
64
|
-
|
|
26
|
+
if (line !== '' && fragments.has(line.toLowerCase())) {
|
|
27
|
+
run += 1;
|
|
28
|
+
if (run >= threshold) {
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
run = 0;
|
|
65
33
|
}
|
|
66
34
|
}
|
|
67
35
|
return null;
|