dsh-repeat-guard 0.1.1 → 0.1.3
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 +69 -24
- package/lib/client.d.ts +18 -0
- package/lib/client.js +299 -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.js +4 -2
- package/lib/stream-guard.d.ts +3 -1
- package/lib/stream-guard.js +27 -11
- package/package.json +24 -3
- package/src/client.tsx +332 -0
- package/src/config.ts +94 -0
- package/src/detect.ts +20 -52
- package/src/index.ts +4 -2
- package/src/stream-guard.ts +30 -10
package/lib/detect.js
CHANGED
|
@@ -1,64 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 复读退化判定:查表。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
|
|
5
|
+
* 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
|
|
8
|
+
* (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
|
|
8
9
|
*/
|
|
9
|
-
/**
|
|
10
|
-
* 会被复读的空话短句表,标点照原样写。
|
|
11
|
-
*
|
|
12
|
-
* 英文条目一律写小写,判定时会把整行转成小写再比。
|
|
13
|
-
*/
|
|
14
|
-
const FRAGMENTS = [
|
|
15
|
-
// 中文
|
|
16
|
-
'好。',
|
|
17
|
-
'好的。',
|
|
18
|
-
'好嘞。',
|
|
19
|
-
'对。',
|
|
20
|
-
'对的。',
|
|
21
|
-
'是。',
|
|
22
|
-
'是的。',
|
|
23
|
-
'行。',
|
|
24
|
-
'嗯。',
|
|
25
|
-
'可以。',
|
|
26
|
-
'明白。',
|
|
27
|
-
'收到。',
|
|
28
|
-
'了解。',
|
|
29
|
-
'执行。',
|
|
30
|
-
'继续。',
|
|
31
|
-
'确认。',
|
|
32
|
-
'完成。',
|
|
33
|
-
'搞定。',
|
|
34
|
-
'写。',
|
|
35
|
-
'查。',
|
|
36
|
-
'看。',
|
|
37
|
-
// 英文
|
|
38
|
-
'ok.',
|
|
39
|
-
'okay.',
|
|
40
|
-
'sure.',
|
|
41
|
-
'alright.',
|
|
42
|
-
'right.',
|
|
43
|
-
'yes.',
|
|
44
|
-
'done.',
|
|
45
|
-
'got it.',
|
|
46
|
-
'let me go.',
|
|
47
|
-
'let me do it.',
|
|
48
|
-
'check it.',
|
|
49
|
-
];
|
|
50
|
-
/** 查表用的集合。 */
|
|
51
|
-
const FRAGMENT_SET = new Set(FRAGMENTS);
|
|
52
10
|
/**
|
|
53
11
|
* 找出退化的碎片行。
|
|
54
12
|
* @param text - 已生成的思考文本。
|
|
55
|
-
* @
|
|
13
|
+
* @param fragments - 小写化的短句表。
|
|
14
|
+
* @param threshold - 连续命中多少行才判定为复读。
|
|
15
|
+
* @returns 计数成立时返回那一行的原文,否则返回 null。
|
|
56
16
|
*/
|
|
57
|
-
export function findDegenerateLine(text) {
|
|
17
|
+
export function findDegenerateLine(text, fragments, threshold) {
|
|
18
|
+
let run = 0;
|
|
58
19
|
for (const raw of text.split('\n')) {
|
|
59
20
|
const line = raw.trim();
|
|
60
|
-
if (line !== '' &&
|
|
61
|
-
|
|
21
|
+
if (line !== '' && fragments.has(line.toLowerCase())) {
|
|
22
|
+
run += 1;
|
|
23
|
+
if (run >= threshold) {
|
|
24
|
+
return line;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
run = 0;
|
|
62
29
|
}
|
|
63
30
|
}
|
|
64
31
|
return null;
|
package/lib/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
10
10
|
//
|
|
11
11
|
// 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
|
|
12
|
-
//
|
|
12
|
+
// 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
|
|
13
13
|
// 具体、可增删、可审计的。
|
|
14
14
|
//
|
|
15
15
|
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
|
|
21
21
|
//
|
|
22
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
|
+
import { createConfigSource } from './config.js';
|
|
23
24
|
import { createStreamGuard } from './stream-guard.js';
|
|
24
25
|
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
25
26
|
/**
|
|
@@ -30,6 +31,7 @@ export default function repeatGuard(ctx) {
|
|
|
30
31
|
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
31
32
|
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
32
33
|
const state = { pending: new Set() };
|
|
33
|
-
|
|
34
|
+
const readConfig = createConfigSource(ctx);
|
|
35
|
+
ctx.on('llm/stream', createStreamGuard(state, readConfig), { global: true });
|
|
34
36
|
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
|
|
35
37
|
}
|
package/lib/stream-guard.d.ts
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
5
|
*/
|
|
6
6
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
7
|
+
import type { ConfigSource } from './config.js';
|
|
7
8
|
import type { GuardState } from './types.js';
|
|
8
9
|
/** `llm/stream` 的监听器签名。 */
|
|
9
10
|
type StreamListener = (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>;
|
|
10
11
|
/**
|
|
11
12
|
* 造一个 `llm/stream` 监听器。
|
|
12
13
|
* @param state - 跨监听保留的拦截状态。
|
|
14
|
+
* @param readConfig - 取当前配置。
|
|
13
15
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
14
16
|
*/
|
|
15
|
-
export declare function createStreamGuard(state: GuardState): StreamListener;
|
|
17
|
+
export declare function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener;
|
|
16
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 {
|
|
@@ -27,17 +32,27 @@ async function* guardStream(downstream, sessionId, state) {
|
|
|
27
32
|
const chunk = step.value;
|
|
28
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
|
-
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
50
|
+
console.log(`[repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
|
|
37
51
|
yield chunk;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
if (!probe.done) {
|
|
53
|
+
yield probe.value;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
41
56
|
}
|
|
42
57
|
}
|
|
43
58
|
yield chunk;
|
|
@@ -57,14 +72,15 @@ async function* guardStream(downstream, sessionId, state) {
|
|
|
57
72
|
/**
|
|
58
73
|
* 造一个 `llm/stream` 监听器。
|
|
59
74
|
* @param state - 跨监听保留的拦截状态。
|
|
75
|
+
* @param readConfig - 取当前配置。
|
|
60
76
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
61
77
|
*/
|
|
62
|
-
export function createStreamGuard(state) {
|
|
78
|
+
export function createStreamGuard(state, readConfig) {
|
|
63
79
|
return (options, next) => {
|
|
64
80
|
// 辅助调用(上下文压缩、会话标题)不参与检测。
|
|
65
81
|
if (options.purpose !== undefined) {
|
|
66
82
|
return next();
|
|
67
83
|
}
|
|
68
|
-
return guardStream(next(), options.sessionId, state);
|
|
84
|
+
return guardStream(next(), options.sessionId, state, readConfig);
|
|
69
85
|
};
|
|
70
86
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-repeat-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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,10 +23,17 @@
|
|
|
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",
|
|
@@ -31,13 +41,24 @@
|
|
|
31
41
|
"peerDependencies": {
|
|
32
42
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
33
43
|
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
|
34
|
-
"@deepseek-ai/dsh-
|
|
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"
|
|
35
50
|
},
|
|
36
51
|
"devDependencies": {
|
|
37
52
|
"@deepseek-ai/cordis": "4.0.2",
|
|
38
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",
|
|
39
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",
|
|
40
59
|
"@types/node": "^22.0.0",
|
|
60
|
+
"@types/react": "^19.3.0",
|
|
61
|
+
"esbuild": "^0.28.2",
|
|
41
62
|
"typescript": "^7.0.2"
|
|
42
63
|
}
|
|
43
64
|
}
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端设置页:在设置窗口左侧加一项"复读打断"。
|
|
3
|
+
*
|
|
4
|
+
* 版式照着官方设置页来:顶部一行"左标题 + 右保存",下面每项是一行
|
|
5
|
+
* (左标题与描述、右控件),样式全部用 dsh 的设计令牌 `--dsw-*`,
|
|
6
|
+
* 由本模块自己注入 `<style>`,与官方 feature(如 theme 的字号行)同款做法。
|
|
7
|
+
*
|
|
8
|
+
* 本文件由 scripts/build-client.mjs 用 esbuild 单独打包成单文件 bundle
|
|
9
|
+
* (`lib/client.js`),不走 tsc 的 lib 输出。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
13
|
+
// 空导入:只为加载这两处的 declaration merging(往 cordis 的 Context 上补
|
|
14
|
+
// slots 与 settingsScope 两个客户端服务)。
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
17
|
+
import { useEffect, useState, type KeyboardEvent, type ReactElement } from 'react';
|
|
18
|
+
|
|
19
|
+
/** settings 命名空间,必须与宿主侧 config.ts 的 SETTINGS_NS 逐字一致。 */
|
|
20
|
+
const SETTINGS_NS = 'repeat-guard';
|
|
21
|
+
|
|
22
|
+
/** 阈值步进器的取值边界。 */
|
|
23
|
+
const THRESHOLD_MIN = 1;
|
|
24
|
+
const THRESHOLD_MAX = 9;
|
|
25
|
+
|
|
26
|
+
/** 本插件配置节选。 */
|
|
27
|
+
interface RepeatGuardSettings {
|
|
28
|
+
/** 拦截短句表。 */
|
|
29
|
+
fragments?: string[];
|
|
30
|
+
/** 连续命中多少行才拦。 */
|
|
31
|
+
threshold?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 样式。令牌名取自 dsh 前端已发布的设计令牌表。 */
|
|
35
|
+
const CSS = `
|
|
36
|
+
.rg-root{display:flex;flex-direction:column;width:100%}
|
|
37
|
+
.rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
|
|
38
|
+
.rg-headTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:500;line-height:24px}
|
|
39
|
+
.rg-headNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
|
|
40
|
+
.rg-save{cursor:pointer;flex:none;height:32px;padding:0 16px;border:none;border-radius:16px;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-family:inherit;font-size:14px;line-height:22px}
|
|
41
|
+
.rg-save:hover{background:var(--dsw-alias-button-primary-hover)}
|
|
42
|
+
.rg-row{display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
43
|
+
.rg-row--stack{flex-direction:column;align-items:stretch;gap:10px}
|
|
44
|
+
.rg-rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
|
|
45
|
+
.rg-rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
|
|
46
|
+
.rg-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
|
|
47
|
+
.rg-control{display:inline-flex;align-items:center;gap:8px}
|
|
48
|
+
.rg-stepper{position:relative;display:inline-flex;justify-content:center;align-items:center;min-width:72px;height:36px;border-radius:18px;background:var(--dsw-alias-bg-module-platform)}
|
|
49
|
+
.rg-stepperValue{min-width:18px;text-align:center;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
|
|
50
|
+
.rg-stepperUnit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
|
|
51
|
+
.rg-arrows{position:absolute;right:8px;display:flex;flex-direction:column;gap:2px;opacity:0}
|
|
52
|
+
.rg-stepper:hover .rg-arrows,.rg-stepper:focus-within .rg-arrows{opacity:1}
|
|
53
|
+
.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
|
+
.rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
|
|
55
|
+
.rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
|
|
56
|
+
.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
|
+
.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
|
+
.rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
59
|
+
.rg-itemText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
60
|
+
.rg-remove{flex:none;display:inline-flex;justify-content:center;align-items:center;width:24px;height:24px;padding:0;border:none;border-radius:12px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-tertiary)}
|
|
61
|
+
.rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
|
|
62
|
+
.rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
|
|
63
|
+
.rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
|
|
64
|
+
.rg-input{flex:1;min-width:0;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}
|
|
65
|
+
.rg-input:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
|
|
66
|
+
.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
|
+
.rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const STYLE_TAG = 'dsh-repeat-guard/panel.css';
|
|
71
|
+
|
|
72
|
+
/** 注入一次本插件的样式;重复挂载不重复插。 */
|
|
73
|
+
function injectStyle(): void {
|
|
74
|
+
if (typeof document === 'undefined' || document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const tag = document.createElement('style');
|
|
78
|
+
tag.dataset.plugin = 'dsh-repeat-guard';
|
|
79
|
+
tag.dataset.pluginCss = STYLE_TAG;
|
|
80
|
+
tag.textContent = CSS;
|
|
81
|
+
document.head.appendChild(tag);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface StepperProps {
|
|
85
|
+
value: number;
|
|
86
|
+
min: number;
|
|
87
|
+
max: number;
|
|
88
|
+
unit: string;
|
|
89
|
+
label: string;
|
|
90
|
+
onChange: (next: number) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 数字步进器:数值居中,悬停或聚焦时右侧浮出上下箭头。 */
|
|
94
|
+
function Stepper({ value, min, max, unit, label, onChange }: StepperProps): ReactElement {
|
|
95
|
+
return (
|
|
96
|
+
<span className="rg-control">
|
|
97
|
+
<span className="rg-stepper">
|
|
98
|
+
<span className="rg-stepperValue">{value}</span>
|
|
99
|
+
<span className="rg-arrows">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className="rg-arrow"
|
|
103
|
+
aria-label={`增大${label}`}
|
|
104
|
+
disabled={value >= max}
|
|
105
|
+
onClick={() => {
|
|
106
|
+
onChange(value + 1);
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
110
|
+
<path
|
|
111
|
+
d="M1.4 6.2 4.5 3.1l3.1 3.1"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth="1.4"
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
strokeLinejoin="round"
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className="rg-arrow"
|
|
123
|
+
aria-label={`减小${label}`}
|
|
124
|
+
disabled={value <= min}
|
|
125
|
+
onClick={() => {
|
|
126
|
+
onChange(value - 1);
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
130
|
+
<path
|
|
131
|
+
d="M1.4 2.8 4.5 5.9l3.1-3.1"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="1.4"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
/>
|
|
138
|
+
</svg>
|
|
139
|
+
</button>
|
|
140
|
+
</span>
|
|
141
|
+
</span>
|
|
142
|
+
<span className="rg-stepperUnit">{unit}</span>
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface FragmentListProps {
|
|
148
|
+
items: readonly string[];
|
|
149
|
+
draft: string;
|
|
150
|
+
onDraft: (next: string) => void;
|
|
151
|
+
onAdd: () => void;
|
|
152
|
+
onRemove: (index: number) => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 短句表:可滚动的列表,逐条删除,底部一行新增。 */
|
|
156
|
+
function FragmentList({ items, draft, onDraft, onAdd, onRemove }: FragmentListProps): ReactElement {
|
|
157
|
+
function handleKey(event: KeyboardEvent<HTMLInputElement>): void {
|
|
158
|
+
if (event.key === 'Enter') {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
onAdd();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div className="rg-list">
|
|
167
|
+
{items.length === 0 ? <div className="rg-empty">表是空的,不会拦截任何短句。</div> : null}
|
|
168
|
+
{items.map((item, index) => (
|
|
169
|
+
<div className="rg-item" key={`${item}#${String(index)}`}>
|
|
170
|
+
<span className="rg-itemText">{item}</span>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className="rg-remove"
|
|
174
|
+
aria-label={`删除 ${item}`}
|
|
175
|
+
onClick={() => {
|
|
176
|
+
onRemove(index);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
180
|
+
<path
|
|
181
|
+
d="M3 3l6 6M9 3l-6 6"
|
|
182
|
+
fill="none"
|
|
183
|
+
stroke="currentColor"
|
|
184
|
+
strokeWidth="1.4"
|
|
185
|
+
strokeLinecap="round"
|
|
186
|
+
/>
|
|
187
|
+
</svg>
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
<div className="rg-addRow">
|
|
193
|
+
<input
|
|
194
|
+
className="rg-input"
|
|
195
|
+
value={draft}
|
|
196
|
+
placeholder="新增短句,连标点一起写,回车添加"
|
|
197
|
+
onChange={(event) => {
|
|
198
|
+
onDraft(event.target.value);
|
|
199
|
+
}}
|
|
200
|
+
onKeyDown={handleKey}
|
|
201
|
+
/>
|
|
202
|
+
<button type="button" className="rg-button" onClick={onAdd}>
|
|
203
|
+
添加
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface FormProps {
|
|
211
|
+
initial: RepeatGuardSettings;
|
|
212
|
+
onSave: (fragments: readonly string[], threshold: number) => Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 设置表单:标题行 + 阈值行 + 短句表行;改动先留在本地,点保存才写。 */
|
|
216
|
+
function Form({ initial, onSave }: FormProps): ReactElement {
|
|
217
|
+
const [items, setItems] = useState<readonly string[]>(() => initial.fragments ?? []);
|
|
218
|
+
const [count, setCount] = useState(() => initial.threshold ?? 1);
|
|
219
|
+
const [draft, setDraft] = useState('');
|
|
220
|
+
const [note, setNote] = useState('');
|
|
221
|
+
|
|
222
|
+
function add(): void {
|
|
223
|
+
const value = draft.trim();
|
|
224
|
+
if (value === '') {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
setItems([...items, value]);
|
|
228
|
+
setDraft('');
|
|
229
|
+
setNote('');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function remove(index: number): void {
|
|
233
|
+
setItems(items.filter((_, at) => at !== index));
|
|
234
|
+
setNote('');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function save(): void {
|
|
238
|
+
setNote('保存中…');
|
|
239
|
+
onSave(items, count)
|
|
240
|
+
.then(() => {
|
|
241
|
+
setNote('已保存');
|
|
242
|
+
})
|
|
243
|
+
.catch((error: unknown) => {
|
|
244
|
+
setNote(`保存失败:${String(error)}`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className="rg-root">
|
|
250
|
+
<div className="rg-head">
|
|
251
|
+
<span className="rg-headTitle">复读打断</span>
|
|
252
|
+
<span className="rg-headNote">{note}</span>
|
|
253
|
+
<button type="button" className="rg-save" onClick={save}>
|
|
254
|
+
保存
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="rg-row">
|
|
258
|
+
<div className="rg-rowText">
|
|
259
|
+
<div className="rg-rowTitle">连续命中次数</div>
|
|
260
|
+
<div className="rg-rowDesc">连着几个短句行命中才拦截;填 1 表示发现即拦</div>
|
|
261
|
+
</div>
|
|
262
|
+
<Stepper
|
|
263
|
+
value={count}
|
|
264
|
+
min={THRESHOLD_MIN}
|
|
265
|
+
max={THRESHOLD_MAX}
|
|
266
|
+
unit="次"
|
|
267
|
+
label="连续命中次数"
|
|
268
|
+
onChange={(next) => {
|
|
269
|
+
setCount(next);
|
|
270
|
+
setNote('');
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
<div className="rg-row rg-row--stack">
|
|
275
|
+
<div className="rg-rowText">
|
|
276
|
+
<div className="rg-rowTitle">拦截短句</div>
|
|
277
|
+
<div className="rg-rowDesc">一行一句,连标点一起写;只算独占一行的整行匹配</div>
|
|
278
|
+
</div>
|
|
279
|
+
<FragmentList
|
|
280
|
+
items={items}
|
|
281
|
+
draft={draft}
|
|
282
|
+
onDraft={setDraft}
|
|
283
|
+
onAdd={add}
|
|
284
|
+
onRemove={remove}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
|
|
292
|
+
export const inject = ['slots', 'settingsScope'];
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 客户端插件入口。
|
|
296
|
+
* @param ctx - 浏览器侧 cordis 上下文。
|
|
297
|
+
*/
|
|
298
|
+
export function apply(ctx: Context): void {
|
|
299
|
+
injectStyle();
|
|
300
|
+
const scope = ctx.settingsScope.bind<RepeatGuardSettings>({ namespace: SETTINGS_NS });
|
|
301
|
+
|
|
302
|
+
function Panel(): ReactElement {
|
|
303
|
+
const [snapshot, setSnapshot] = useState(() => scope.getSnapshot());
|
|
304
|
+
useEffect(
|
|
305
|
+
() =>
|
|
306
|
+
scope.subscribe(() => {
|
|
307
|
+
setSnapshot(scope.getSnapshot());
|
|
308
|
+
}),
|
|
309
|
+
[],
|
|
310
|
+
);
|
|
311
|
+
const value = snapshot.value;
|
|
312
|
+
if (snapshot.status !== 'ready' || value === undefined) {
|
|
313
|
+
return <div className="rg-empty">{`配置尚未就绪(${snapshot.status})`}</div>;
|
|
314
|
+
}
|
|
315
|
+
return (
|
|
316
|
+
<Form
|
|
317
|
+
initial={value}
|
|
318
|
+
onSave={async (fragments, threshold) => {
|
|
319
|
+
await scope.set('fragments', [...fragments]);
|
|
320
|
+
await scope.set('threshold', threshold);
|
|
321
|
+
}}
|
|
322
|
+
/>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
ctx.slots.inject('settings.section', () =>
|
|
327
|
+
ctx.slots.register(
|
|
328
|
+
{ name: 'settings.section', id: 'repeat-guard', order: 100, label: '复读打断' },
|
|
329
|
+
Panel,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
}
|