dsh-repeat-guard 0.1.4 → 0.2.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/README.md +71 -210
- package/lib/client.js +12 -12
- package/lib/config.d.ts +36 -9
- package/lib/config.js +41 -36
- package/lib/index.d.ts +6 -2
- package/lib/index.js +10 -4
- package/lib/resume.d.ts +9 -0
- package/lib/resume.js +1 -2
- package/package.json +15 -15
- package/src/client.tsx +15 -14
- package/src/config.ts +47 -38
- package/src/index.ts +11 -4
- package/src/resume.ts +13 -2
package/README.md
CHANGED
|
@@ -1,182 +1,100 @@
|
|
|
1
1
|
# dsh-repeat-guard
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
| 项 | 说明 |
|
|
6
|
-
|---|---|
|
|
7
|
-
| 类型 | dsh bundle,含宿主侧插件与客户端设置页 |
|
|
8
|
-
| 监听 | `llm/stream`、`agent/turn-stopping` |
|
|
9
|
-
| 入口 | `lib/index.js`(`src/index.ts`)、`lib/client.js`(`src/client.tsx`) |
|
|
10
|
-
| 可调项 | 拦截短句表、连续命中阈值、行内重复检测、续跑指令与折叠摘要(设置 → 复读打断) |
|
|
11
|
-
| 分发 | npm 包 `dsh-repeat-guard` |
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## 它解决什么问题
|
|
16
|
-
|
|
17
|
-
模型退化时,思考段里会出现整行都是空话的情况,如"好。""执行。""OK." "Let me go."。
|
|
18
|
-
|
|
19
|
-
这类思考本身不含信息,但会一直持续到 token 耗尽,把一次本可以正常完成的任务拖垮。
|
|
20
|
-
|
|
21
|
-
## 检测范围
|
|
22
|
-
|
|
23
|
-
**只看思考段(`reasoning-delta`),正文(`text-delta`)不参与判定,原样透传。**
|
|
24
|
-
|
|
25
|
-
复读退化先出现在思考段,那时正文往往还没开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
26
|
-
|
|
27
|
-
## 判据:查表
|
|
28
|
-
|
|
29
|
-
判定不做任何"形状归纳",只查一张表:**思考里有没有独占一行的表项**。
|
|
30
|
-
|
|
31
|
-
最初试过两条归纳式判据,都被证伪:
|
|
32
|
-
|
|
33
|
-
1. **数碎片重复了几次**("好,好,好,好")。它不看句子结构,只看重复次数,于是"然后,然后,然后,"(逗号被当作片段分隔符)这类正常句子会被判成复读。
|
|
34
|
-
2. **按形状判断空话行**(中文去标点后 ≤2 字、英文 ≤3 词)。形状规则必然外溢:"解。""豫。"这种被换行切开的半截词也满足形状,实测在大段正常思考里频繁误杀。
|
|
35
|
-
|
|
36
|
-
表项是具体的、可增删的、可审计的,不会外溢。规则只有一条:
|
|
3
|
+
> 解决大模型用你的token唱歌的糟糕问题
|
|
37
4
|
|
|
5
|
+
我们发现部分模型(尤其是`deepseek v4.1 flash`)很容易在长上下文时进入复读状态。
|
|
6
|
+
dsh社区中称呼这种情况为“唱歌”,典型的表现如下所示:
|
|
38
7
|
```
|
|
39
|
-
|
|
8
|
+
Let me do it.
|
|
9
|
+
Let me run.
|
|
10
|
+
Let me go.
|
|
11
|
+
Let me do it.
|
|
12
|
+
...
|
|
40
13
|
```
|
|
41
|
-
|
|
42
|
-
| 输入 | 结果 |
|
|
43
|
-
|---|---|
|
|
44
|
-
| `好。` `好的。` `执行。` `写。` `明白。` | 拦(表内) |
|
|
45
|
-
| `OK.` `Sure.` `Let me go.` `Got it.` | 拦(表内) |
|
|
46
|
-
| `查一下。` `这需要我。` | 不拦(表外) |
|
|
47
|
-
| `A.` `1.` | 不拦(表外) |
|
|
48
|
-
| `然后,` `注意:` `好的,` | 不拦(表外) |
|
|
49
|
-
| `好?` `好!` `好` `OK` | 不拦(表外) |
|
|
50
|
-
| `我需要检查实现。\n然后重新编译。` | 不拦(各行都在表外) |
|
|
51
|
-
|
|
52
|
-
表项**连标点一起写**(`'好的。'` 对应思考里的 `好的。`),英文条目一律写小写。分割单位是单个换行,空行会把连续计数打断。
|
|
53
|
-
|
|
54
|
-
### 行内重复检测(默认关)
|
|
55
|
-
|
|
56
|
-
上面那条规则只认"独占一行的整句",所以 `好。好。好。` 这种挤在一行里的重复看不见——对 DeepSeek 够用,但有些模型是单行循环的,所以留了这个开关。
|
|
57
|
-
|
|
58
|
-
打开之后多一条判据:**整行由一个或多个表项首尾相接拼成**,也算命中(`好。好。好。`、`好。ok.好。`)。它仍然不会误杀——`我觉得可以。好。` 的前面那截不是表项、整行拼不出来,放行;`好的,好的` 也放行(表项是带句号的 `好的。`,这里是逗号)。
|
|
59
|
-
|
|
60
|
-
计数单位同时从"行"变成"表项":整行全等算一次,行内每一段各算一次。所以阈值 2 配上它时,`好。好。` 这种一行两段的也会被拦。
|
|
61
|
-
|
|
62
|
-
**表可以在设置里改**(见下面「可调项」);`src/config.ts` 的 `DEFAULT_FRAGMENTS` 是出厂默认值。
|
|
63
|
-
|
|
64
|
-
已知边界:判据只看"有没有这样一行",不看上下文。所以"用户想让我改代码。\n好的。"这种**前面有实质内容、结尾又跟一句空话**的思考也会被拦。这是"按行判定"的直接结果。
|
|
65
|
-
|
|
66
|
-
## 连续命中阈值
|
|
67
|
-
|
|
68
|
-
阈值 `threshold` 是"连着几行命中才拦":
|
|
69
|
-
|
|
70
|
-
| 值 | 行为 |
|
|
71
|
-
|---|---|
|
|
72
|
-
| 1(默认) | 任意一行命中即拦 |
|
|
73
|
-
| n | 连着 n 行都是表项(**句子可以各不相同**)才拦;中间夹一行不命中的就重新计数 |
|
|
74
|
-
|
|
75
|
-
阈值只在**同一次生成内**计数,不跨轮次。
|
|
76
|
-
|
|
77
|
-
## 命中在思考段末尾时不拦
|
|
78
|
-
|
|
79
|
-
命中之后,插件再往下探一格:
|
|
80
|
-
|
|
81
|
-
- 后面还是 `reasoning-delta` → 思考确实在继续复读,**拦**;
|
|
82
|
-
- 后面换成正文、工具调用,或直接收尾(`block-end` / `usage` / `finish` / 流结束)→ 说明命中行本来就是这段思考的最后一句,**不拦**,原样透传。
|
|
83
|
-
|
|
84
|
-
判据是**思考段是否还在往下写**,不是整个响应是否结束——思考以一句"好。"收尾、接着写正文,属正常收尾。
|
|
85
|
-
|
|
86
|
-
## 工作原理
|
|
87
|
-
|
|
88
|
-
1. 包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察 `reasoning-delta`,累积本次响应已产出的思考文本。
|
|
89
|
-
2. 命中退化时,把触发退化的那个 delta 照常放行,补一个 `block-end` 闭合思考块,再补一个 `finish(stop)`,然后结束流。对 agent-loop 而言这就是一次正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
90
|
-
3. 提前结束流时显式向下游传播 `iterator.return()`,否则上游 HTTP 流会继续跑到结束,供应商照常计满 token,且连接悬挂。
|
|
91
|
-
4. 光截断只会让本轮就此结束、停下来等用户输入。所以再挂一个 `agent/turn-stopping` 监听器,在本轮边界提交之前调 `agent.steer(...)` 推一条输入,本轮就会接着再跑一步。
|
|
92
|
-
|
|
93
|
-
辅助调用(上下文压缩、会话标题等带 `purpose` 的调用)不参与检测。
|
|
94
|
-
|
|
95
|
-
三种结局各有日志,便于事后核对误杀:
|
|
96
|
-
|
|
14
|
+
如果在预设或者`AGENTS.md`中约束使用中文思考,则会变成如下的场景:
|
|
97
15
|
```
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
16
|
+
好。
|
|
17
|
+
做。
|
|
18
|
+
开始。
|
|
19
|
+
做。
|
|
20
|
+
嗯。
|
|
21
|
+
做。
|
|
22
|
+
我必须开始工作了。
|
|
23
|
+
好。
|
|
24
|
+
做。
|
|
25
|
+
...
|
|
101
26
|
```
|
|
27
|
+
配合`deepseek v4.1 flash`高达 300tok/s 的恐怖输出速率,模型可能会以极为可观的速度消耗token,而不产生任何有效输出。
|
|
102
28
|
|
|
103
|
-
|
|
29
|
+
这通常是比较隐蔽的,尤其是在用户没有注意模型的思考过程的时候。用户实际上只会看到思考过程快速闪过很短的句子,回答好像很正常。
|
|
104
30
|
|
|
105
|
-
|
|
31
|
+
在上下文长度达到 300k 以上的时候,该现象会变得十分频繁。
|
|
106
32
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
33
|
+
不仅如此,我们也观察到`deepseek v4.1 flash`有着在已经思考到需要工具调用的时候输出一个短句,接下来又进入另一个方向的复杂思考过程的倾向。如下所示:
|
|
34
|
+
```
|
|
35
|
+
我已经收集到了足够的信息,接下来开始操作。先修改xxx文件。
|
|
36
|
+
做。
|
|
37
|
+
但是我还发现仓库里有其它的内容。注意到用户提到xxxxx
|
|
38
|
+
...
|
|
112
39
|
```
|
|
113
40
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
注意:
|
|
117
|
-
|
|
118
|
-
- **做不到"这一步当作没发生"。** 被截断的 assistant 消息在 `step()` 里已经 `session.append` 落盘,dsh 没有回滚一步的机制;`step()` 唯一返回 `null`(循环继续)的出口要求本步真的产生了 tool-call,插件够不着。所以这里的语义是"接着再跑一步",会话里会留下被截断的思考记录。
|
|
119
|
-
- **续跑没有次数配额。** 有标记就推,同一轮里反复退化就反复续跑。
|
|
41
|
+
显然这不是我们想要的行为。既然模型已经清楚下一步的工作是什么,就应该直接开始工具调用。
|
|
120
42
|
|
|
121
|
-
|
|
43
|
+
我们还注意到上述问题只出现于思考过程而不出现于回答内容。
|
|
44
|
+
## 此插件的功能
|
|
122
45
|
|
|
123
|
-
|
|
46
|
+
此插件致力于解决上述模型复读和无端长思考的问题。它会持续检测模型的输出,如果思考过程出现了一个**白名单**中的短句且单独成段,且模型在输出这个短句后既然有继续思考的意图,我们就会强行中断对话,并发送一句提醒,让模型直接开始下一轮的输出。
|
|
124
47
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
| 连续命中次数 | 连着几次命中才拦;默认 1 = 发现即拦 |
|
|
130
|
-
| 行内重复检测 | 打开后"好。好。好。"这种挤在一行里的重复也算命中;默认关 |
|
|
131
|
-
| 续跑指令 | 截断后推给模型的指令正文,整段照发 |
|
|
132
|
-
| 折叠行摘要 | 那条注入消息在会话里的折叠行摘要 |
|
|
48
|
+
例如
|
|
49
|
+
```
|
|
50
|
+
[深度思考]
|
|
51
|
+
直接检查服务器状态和崩溃日志。
|
|
133
52
|
|
|
134
|
-
|
|
53
|
+
关键:先看最新日志和 crash-reports。
|
|
135
54
|
|
|
136
|
-
|
|
55
|
+
执行。 <--打断
|
|
137
56
|
|
|
138
|
-
|
|
57
|
+
[复读拦截]
|
|
58
|
+
你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。
|
|
59
|
+
请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。
|
|
139
60
|
|
|
140
|
-
|
|
61
|
+
[深度思考]
|
|
62
|
+
直接调用工具,检查服务器状态和崩溃日志。
|
|
141
63
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。
|
|
64
|
+
[tool calls]
|
|
65
|
+
ssh -o ConnectTimeout=10 -o BatchMode=yes ...
|
|
66
|
+
...
|
|
146
67
|
```
|
|
147
68
|
|
|
148
|
-
|
|
69
|
+
## 工作原理
|
|
149
70
|
|
|
150
|
-
|
|
71
|
+
逐 chunk 观察 `reasoning-delta`,检查思考文本。
|
|
151
72
|
|
|
152
|
-
|
|
153
|
-
npm install
|
|
154
|
-
npm run build # tsc 产出 lib/*.js,再用 esbuild 打 lib/client.js
|
|
155
|
-
```
|
|
73
|
+
独立段命中白名单时,向模型传递中断请求,掐断模型输出的片段,并补充结束标志,伪装成正常结束的思考过程。
|
|
156
74
|
|
|
157
|
-
|
|
75
|
+
向模型发送一条注入提示词,以恢复`agent-loop`。
|
|
158
76
|
|
|
159
|
-
|
|
160
|
-
- `scripts/build-client.mjs` 用 esbuild 把 `src/client.tsx` 打成**单文件** `lib/client.js`,外面套一层 dsh 客户端模块系统要求的包装:
|
|
77
|
+
用户会通过注入提示词这个步骤看到大模型被中断的过程,因此步骤可能会变得很多。这是不得不付出的代价。
|
|
161
78
|
|
|
162
|
-
|
|
163
|
-
window.__ModuleLoader__.load({ id: "dsh-repeat-guard", factory: function (require) { … } });
|
|
164
|
-
```
|
|
79
|
+
> 虽然我们可以将下一步思考的过程接到上一轮打断的位置以伪装成什么都没发生过的样子,但是一方面这种做法有风险,另一方面不是很有利于缓存命中。更何况,这也能帮助用户判断插件是否在正常工作。
|
|
165
80
|
|
|
166
|
-
|
|
81
|
+
## 可配置选项
|
|
167
82
|
|
|
168
|
-
|
|
83
|
+
在 **设置 → 复读打断** 里查看,保存后生效,无需重启。
|
|
169
84
|
|
|
170
|
-
|
|
85
|
+
| 项 | 含义 |
|
|
86
|
+
|---|---|
|
|
87
|
+
| 累计拦截 | 掐断次数(有清零按钮) |
|
|
88
|
+
| 拦截短句 | 触发打断的白名单,需要加上标点 |
|
|
89
|
+
| 连续命中次数\* | 连续命中的阈值,如果为2就需要连续出现两个短句才触发 |
|
|
90
|
+
| 行内重复检测\* | 打开后"好。好。好。"这种挤在一行里的重复也算命中。 |
|
|
91
|
+
| 续跑指令 | 截断后推给模型的提示词 |
|
|
92
|
+
| 折叠行摘要 | 注入段用户可见的简要信息 |
|
|
171
93
|
|
|
172
|
-
|
|
173
|
-
npm login --registry=https://registry.npmjs.org
|
|
174
|
-
npm publish --registry=https://registry.npmjs.org
|
|
175
|
-
```
|
|
94
|
+
> \* 连续的意思是中间没有长段。如果配置成2就只会拦截循环,默认配置连一个短句后的分支思考过程也会拦下。
|
|
176
95
|
|
|
177
|
-
|
|
96
|
+
> \*deepseek不存在行内重复问题,这个选项是为 qwen 35B 这样的模型准备的。
|
|
178
97
|
|
|
179
|
-
发布前不必手动构建,`prepack` 会跑一次 `npm run build`;tsc 或 esbuild 报错则发布中止。
|
|
180
98
|
|
|
181
99
|
## 安装到 dsh
|
|
182
100
|
|
|
@@ -184,70 +102,13 @@ npm publish --registry=https://registry.npmjs.org
|
|
|
184
102
|
dsh plugin --profile web add dsh-repeat-guard
|
|
185
103
|
```
|
|
186
104
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
验证是否加载:
|
|
196
|
-
|
|
197
|
-
```bash
|
|
198
|
-
journalctl --user -u dsh-web --no-pager | grep -a repeat-guard
|
|
199
|
-
# [repeat-guard] 已加载,复读拦截生效
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
⚠️ 插件加载失败会让 **dsh 启动直接失败**,而 dsh-web 是本机 GUI 的唯一通道。改完先离线验证再重启:用假上下文 import 产物调一次入口,或喂一段真实 chunk 序列。
|
|
203
|
-
|
|
204
|
-
## 依赖
|
|
205
|
-
|
|
206
|
-
dsh 会把 bundle 声明的 `dependencies` 与 `peerDependencies` 从安装目录软链进 profile(`dsh-app-boot` 的 `healProfileModuleFallback`,依赖名取自 `profileDependencyNames(manifest)`,注释原文是 "dependency names that may be imported by a loader-visible plugin")。软链落在 `~/.dsh/profiles/node_modules/`,Node 按常规向上查找即可解析到。
|
|
207
|
-
|
|
208
|
-
dsh 的 profile 同时带一份 `.npmrc`,写着 `auto-install-peers=false`(注释:core packages come from the CLI dependency tree; a profile must never resolve its own copy)——所以声明 `peerDependencies` 不会让 profile 自己再装一份宿主包。
|
|
209
|
-
|
|
210
|
-
所以:
|
|
211
|
-
|
|
212
|
-
- **宿主提供的包写 `peerDependencies`**:`@deepseek-ai/cordis`、`dsh-llm`、`dsh-agent`、`dsh-settings`、`schemastery`、`dsh-client-ui-renderer`、`dsh-client-ui-settings`,以及客户端侧由平台基座提供的 `react`。它们不在 profile 里重复安装,用 dsh 自带的那份。
|
|
213
|
-
- **类型一律从官方引**,不在本地重抄。`Context`、`StreamChunk`、`GenerateOptions`、`Agent`、`SettingsScope` 都是 dsh 导出的;本地抄一份只会在 dsh 升级后于运行时暴露字段对不上,官方声明则会在 `tsc` 阶段直接报错。
|
|
214
|
-
- `src/types.ts` 只放本插件自有的类型(当前是 `GuardState`)。
|
|
215
|
-
- 部分 dsh 包的类型只通过 module augmentation 生效(如 `Context.settings`、`Context.slots`、`Context.settingsScope`),要显式 `import type {} from '…'` 触发加载,否则 `tsc` 会报"属性不存在"。
|
|
216
|
-
- dsh 的 loader 是原生 ESM import,**没有转译层**,运行时读到的永远是编译产物——改完 `src/` 必须重新构建。`lib/` 只随 npm 包发布,不进版本库。
|
|
217
|
-
|
|
218
|
-
## 代码约定
|
|
219
|
-
|
|
220
|
-
| 项 | 约定 |
|
|
221
|
-
|---|---|
|
|
222
|
-
| 缩进 / 引号 / 分号 | 2 空格、单引号、语句末分号 |
|
|
223
|
-
| 行宽 | 目标 100 字符,上限 120(中英混排按字符数计) |
|
|
224
|
-
| 控制语句 | **一律带花括号**,单行 `if`、`continue`、`break` 也不例外,不写 `if (x) return;` |
|
|
225
|
-
| 文件 | 一个文件只干一件事:类型声明 / 配置 / 判定 / 续跑 / 单个监听器 / 装配,各占一个文件 |
|
|
226
|
-
| 函数 | 不超过 50 行;超了就先看能不能按职责拆开 |
|
|
227
|
-
| 注释 | 一律中文;导出函数与判定函数配 `@param` / `@returns`,文件内小工具函数用单行注释即可 |
|
|
228
|
-
|
|
229
|
-
排版细节已固化在 `.editorconfig`。花括号、文件职责、函数行数这三条没有配置项可表达,只能靠人工遵守。
|
|
230
|
-
|
|
231
|
-
## 目录结构
|
|
232
|
-
|
|
233
|
-
```
|
|
234
|
-
.
|
|
235
|
-
├── src/
|
|
236
|
-
│ ├── index.ts 宿主侧入口:装配状态,注册两个监听器(只做装配)
|
|
237
|
-
│ ├── types.ts 本插件自有的类型(dsh 的接口一律从 @deepseek-ai/* 引)
|
|
238
|
-
│ ├── config.ts 配置:settings 命名空间、默认值、schema、取当前值
|
|
239
|
-
│ ├── detect.ts 复读判定:查表 + 连续计数,不碰会话状态
|
|
240
|
-
│ ├── resume.ts 续跑:注入文案、消息构造、推送
|
|
241
|
-
│ ├── stream-guard.ts llm/stream 监听器:思考段检测与掐断
|
|
242
|
-
│ ├── turn-stopping-guard.ts agent/turn-stopping 监听器:让本轮继续
|
|
243
|
-
│ └── client.tsx 客户端设置页:注册 settings.section
|
|
244
|
-
├── scripts/
|
|
245
|
-
│ └── build-client.mjs esbuild 打包客户端半 → lib/client.js
|
|
246
|
-
├── lib/ 构建产物(已 gitignore),dsh 加载 lib/index.js 与 lib/client.js
|
|
247
|
-
├── cordis.patch.yml bundle 层声明:把本插件挂进 profile 树
|
|
248
|
-
├── package.json
|
|
249
|
-
└── tsconfig.json
|
|
250
|
-
```
|
|
105
|
+
> **兼容性**:`0.2.0` 起要求 dsh **0.1.7 及以上**——0.1.7 起宿主会在装载前校验插件的
|
|
106
|
+
> peer 声明,配置表单也换成了 entry Config 模型,旧写法在那上面不再工作。
|
|
107
|
+
> dsh `0.1.0-rc.8` ~ `0.1.6` 请装 `0.1.5`:
|
|
108
|
+
>
|
|
109
|
+
> ```bash
|
|
110
|
+
> dsh plugin --profile web add dsh-repeat-guard@0.1.5
|
|
111
|
+
> ```
|
|
251
112
|
|
|
252
113
|
## 许可
|
|
253
114
|
|
package/lib/client.js
CHANGED
|
@@ -33,7 +33,7 @@ var __dshClientBundle = (() => {
|
|
|
33
33
|
});
|
|
34
34
|
var import_react = __require("react");
|
|
35
35
|
var import_jsx_runtime = __require("react/jsx-runtime");
|
|
36
|
-
var
|
|
36
|
+
var ENTRY_ID = "dsh-repeat-guard";
|
|
37
37
|
var THRESHOLD_MIN = 1;
|
|
38
38
|
var THRESHOLD_MAX = 9;
|
|
39
39
|
var 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`;
|
|
@@ -363,15 +363,15 @@ var __dshClientBundle = (() => {
|
|
|
363
363
|
] })
|
|
364
364
|
] });
|
|
365
365
|
}
|
|
366
|
-
var inject = ["slots", "
|
|
366
|
+
var inject = ["slots", "configForms"];
|
|
367
367
|
function apply(ctx) {
|
|
368
368
|
injectStyle();
|
|
369
|
-
const
|
|
369
|
+
const form = ctx.configForms.get(ENTRY_ID);
|
|
370
370
|
function Panel() {
|
|
371
|
-
const [snapshot, setSnapshot] = (0, import_react.useState)(() =>
|
|
371
|
+
const [snapshot, setSnapshot] = (0, import_react.useState)(() => form.getSnapshot());
|
|
372
372
|
(0, import_react.useEffect)(
|
|
373
|
-
() =>
|
|
374
|
-
setSnapshot(
|
|
373
|
+
() => form.subscribe(() => {
|
|
374
|
+
setSnapshot(form.getSnapshot());
|
|
375
375
|
}),
|
|
376
376
|
[]
|
|
377
377
|
);
|
|
@@ -385,14 +385,14 @@ var __dshClientBundle = (() => {
|
|
|
385
385
|
initial: value,
|
|
386
386
|
count: value.count,
|
|
387
387
|
onReset: async () => {
|
|
388
|
-
await
|
|
388
|
+
await form.set("count", 0);
|
|
389
389
|
},
|
|
390
390
|
onSave: async (next) => {
|
|
391
|
-
await
|
|
392
|
-
await
|
|
393
|
-
await
|
|
394
|
-
await
|
|
395
|
-
await
|
|
391
|
+
await form.set("fragments", next.fragments);
|
|
392
|
+
await form.set("threshold", next.threshold);
|
|
393
|
+
await form.set("inlineRepeat", next.inlineRepeat);
|
|
394
|
+
await form.set("resumeText", next.resumeText);
|
|
395
|
+
await form.set("resumeSummary", next.resumeSummary);
|
|
396
396
|
}
|
|
397
397
|
}
|
|
398
398
|
);
|
package/lib/config.d.ts
CHANGED
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
* 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
|
|
3
3
|
* 外加一个运行时计数。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* dsh 0.1.7 起,配置就是插件自己的 entry Config——标记 `.volatile()` 的字段由宿主
|
|
6
|
+
* 的设置表单读写,写回的是 profile 的 cordis patch。宿主侧不再注册命名空间,
|
|
7
|
+
* 而是持有这些稳定引用,需要时 `.get()` 现取,所以改完立即生效,不用重启。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* 计数也寄在同一份配置里:宿主侧每次掐断写一次,客户端读同一个值、清零就是把它
|
|
10
|
+
* 写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
|
|
10
11
|
*/
|
|
11
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
import z from '@deepseek-ai/schemastery';
|
|
14
|
+
/**
|
|
15
|
+
* profile 里本插件的条目 id。设置表单按它定位配置,宿主写回也要用它,
|
|
16
|
+
* 所以必须与 cordis.patch.yml 里的 `id` 逐字一致。
|
|
17
|
+
*/
|
|
18
|
+
export declare const ENTRY_ID = "dsh-repeat-guard";
|
|
14
19
|
/** 默认的拦截短句表,标点照原样写。 */
|
|
15
20
|
export declare const DEFAULT_FRAGMENTS: readonly string[];
|
|
16
21
|
/** 默认阈值:1 表示发现即拦。 */
|
|
@@ -21,6 +26,27 @@ export declare const DEFAULT_INLINE_REPEAT = false;
|
|
|
21
26
|
export declare const DEFAULT_RESUME_TEXT: string;
|
|
22
27
|
/** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
|
|
23
28
|
export declare const DEFAULT_RESUME_SUMMARY = "\u590D\u8BFB\u5DF2\u622A\u65AD\uFF1A\u8BF7\u7EE7\u7EED\u6267\u884C";
|
|
29
|
+
/**
|
|
30
|
+
* 插件 Config schema。只有标了 `.volatile()` 的字段会进设置表单并支持编辑,
|
|
31
|
+
* 其余字段仍是普通配置(只由 cordis 配置文件决定)。
|
|
32
|
+
*/
|
|
33
|
+
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
34
|
+
fragments: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
|
|
35
|
+
threshold: z<number, number, "volatile-defined">;
|
|
36
|
+
inlineRepeat: z<boolean, boolean, "volatile-defined">;
|
|
37
|
+
resumeText: z<string, string, "volatile-defined">;
|
|
38
|
+
resumeSummary: z<string, string, "volatile-defined">;
|
|
39
|
+
count: z<number, number, "volatile-defined">;
|
|
40
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
41
|
+
fragments: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
|
|
42
|
+
threshold: z<number, number, "volatile-defined">;
|
|
43
|
+
inlineRepeat: z<boolean, boolean, "volatile-defined">;
|
|
44
|
+
resumeText: z<string, string, "volatile-defined">;
|
|
45
|
+
resumeSummary: z<string, string, "volatile-defined">;
|
|
46
|
+
count: z<number, number, "volatile-defined">;
|
|
47
|
+
}>>, "plain">;
|
|
48
|
+
/** loader 解析后交给插件的配置:volatile 字段是稳定引用,取值要 `.get()`。 */
|
|
49
|
+
export type RepeatGuardConfigSchema = Schemastery.TypeT<typeof Config>;
|
|
24
50
|
/** 判定与续跑用得上的一份配置。 */
|
|
25
51
|
export interface RepeatGuardConfig {
|
|
26
52
|
/** 短句表,已全部小写化。 */
|
|
@@ -40,12 +66,13 @@ export type ConfigSource = () => RepeatGuardConfig;
|
|
|
40
66
|
export interface GuardRuntime {
|
|
41
67
|
/** 取当前配置。 */
|
|
42
68
|
readonly read: ConfigSource;
|
|
43
|
-
/**
|
|
69
|
+
/** 记一次拦截,把计数写回配置。 */
|
|
44
70
|
readonly countHit: () => void;
|
|
45
71
|
}
|
|
46
72
|
/**
|
|
47
|
-
*
|
|
73
|
+
* 把 volatile 引用折叠成一份快照,返回宿主侧要用的运行时句柄。
|
|
48
74
|
* @param ctx - 宿主 cordis 上下文。
|
|
75
|
+
* @param config - loader 解析后的本插件配置。
|
|
49
76
|
* @returns 取配置与记数两个入口。
|
|
50
77
|
*/
|
|
51
|
-
export declare function createRuntime(ctx: Context): GuardRuntime;
|
|
78
|
+
export declare function createRuntime(ctx: Context, config: RepeatGuardConfigSchema): GuardRuntime;
|
package/lib/config.js
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
* 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
|
|
3
3
|
* 外加一个运行时计数。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* dsh 0.1.7 起,配置就是插件自己的 entry Config——标记 `.volatile()` 的字段由宿主
|
|
6
|
+
* 的设置表单读写,写回的是 profile 的 cordis patch。宿主侧不再注册命名空间,
|
|
7
|
+
* 而是持有这些稳定引用,需要时 `.get()` 现取,所以改完立即生效,不用重启。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* 计数也寄在同一份配置里:宿主侧每次掐断写一次,客户端读同一个值、清零就是把它
|
|
10
|
+
* 写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
|
|
10
11
|
*/
|
|
11
12
|
import z from '@deepseek-ai/schemastery';
|
|
12
|
-
/**
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* profile 里本插件的条目 id。设置表单按它定位配置,宿主写回也要用它,
|
|
15
|
+
* 所以必须与 cordis.patch.yml 里的 `id` 逐字一致。
|
|
16
|
+
*/
|
|
17
|
+
export const ENTRY_ID = 'dsh-repeat-guard';
|
|
14
18
|
/** 默认的拦截短句表,标点照原样写。 */
|
|
15
19
|
export const DEFAULT_FRAGMENTS = [
|
|
16
20
|
'好。',
|
|
@@ -58,13 +62,17 @@ export const DEFAULT_RESUME_TEXT = [
|
|
|
58
62
|
].join('\n');
|
|
59
63
|
/** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
|
|
60
64
|
export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 插件 Config schema。只有标了 `.volatile()` 的字段会进设置表单并支持编辑,
|
|
67
|
+
* 其余字段仍是普通配置(只由 cordis 配置文件决定)。
|
|
68
|
+
*/
|
|
69
|
+
export const Config = z.object({
|
|
70
|
+
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]).volatile(),
|
|
71
|
+
threshold: z.number().default(DEFAULT_THRESHOLD).volatile(),
|
|
72
|
+
inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT).volatile(),
|
|
73
|
+
resumeText: z.string().default(DEFAULT_RESUME_TEXT).volatile(),
|
|
74
|
+
resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY).volatile(),
|
|
75
|
+
count: z.number().default(0).volatile(),
|
|
68
76
|
});
|
|
69
77
|
function compile(value) {
|
|
70
78
|
return {
|
|
@@ -76,41 +84,38 @@ function compile(value) {
|
|
|
76
84
|
};
|
|
77
85
|
}
|
|
78
86
|
/**
|
|
79
|
-
*
|
|
87
|
+
* 把 volatile 引用折叠成一份快照,返回宿主侧要用的运行时句柄。
|
|
80
88
|
* @param ctx - 宿主 cordis 上下文。
|
|
89
|
+
* @param config - loader 解析后的本插件配置。
|
|
81
90
|
* @returns 取配置与记数两个入口。
|
|
82
91
|
*/
|
|
83
|
-
export function createRuntime(ctx) {
|
|
84
|
-
|
|
85
|
-
fragments:
|
|
86
|
-
threshold:
|
|
87
|
-
inlineRepeat:
|
|
88
|
-
resumeText:
|
|
89
|
-
resumeSummary:
|
|
92
|
+
export function createRuntime(ctx, config) {
|
|
93
|
+
const read = () => compile({
|
|
94
|
+
fragments: config.fragments.get(),
|
|
95
|
+
threshold: config.threshold.get(),
|
|
96
|
+
inlineRepeat: config.inlineRepeat.get(),
|
|
97
|
+
resumeText: config.resumeText.get(),
|
|
98
|
+
resumeSummary: config.resumeSummary.get(),
|
|
90
99
|
});
|
|
91
|
-
let
|
|
100
|
+
let write;
|
|
92
101
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
const settings = settingsCtx.settings;
|
|
103
|
+
// 本插件自带设置页,关掉宿主按 schema 自动生成的页面。
|
|
104
|
+
settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber));
|
|
105
|
+
write = (count) => {
|
|
106
|
+
// 现取 revision:表单刚写过的话,旧 revision 会被 SETTINGS_CONFLICT 拒掉。
|
|
107
|
+
const revision = settings.describe().find((entry) => entry.ns === ENTRY_ID)?.revision;
|
|
108
|
+
settings.update(ENTRY_ID, { count }, revision).catch((error) => {
|
|
99
109
|
console.log(`[repeat-guard] 写设置失败:${String(error)}`);
|
|
100
110
|
});
|
|
101
111
|
};
|
|
102
|
-
sync();
|
|
103
|
-
scope.watch(sync);
|
|
104
112
|
// 计数口径是"自本次启动或上次清零以来"。
|
|
105
|
-
write(
|
|
106
|
-
bump = () => {
|
|
107
|
-
write({ count: scope.get().count + 1 });
|
|
108
|
-
};
|
|
113
|
+
write(0);
|
|
109
114
|
});
|
|
110
115
|
return {
|
|
111
|
-
read
|
|
116
|
+
read,
|
|
112
117
|
countHit: () => {
|
|
113
|
-
|
|
118
|
+
write?.(config.count.get() + 1);
|
|
114
119
|
},
|
|
115
120
|
};
|
|
116
121
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { Config, type RepeatGuardConfigSchema } from './config.js';
|
|
3
|
+
export { Config };
|
|
2
4
|
/**
|
|
3
|
-
* 函数式插件入口。ctx 为 cordis
|
|
5
|
+
* 函数式插件入口。ctx 为 cordis 上下文,config 是 loader 解析后的配置,
|
|
6
|
+
* 注册的监听随插件卸载自动释放。
|
|
4
7
|
* @param ctx - 宿主 cordis 上下文。
|
|
8
|
+
* @param config - 本插件的配置;volatile 字段是稳定引用,取值要 `.get()`。
|
|
5
9
|
*/
|
|
6
|
-
export
|
|
10
|
+
export declare function apply(ctx: Context, config: RepeatGuardConfigSchema): void;
|
package/lib/index.js
CHANGED
|
@@ -20,18 +20,24 @@
|
|
|
20
20
|
// `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
|
|
21
21
|
//
|
|
22
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
|
-
import { createRuntime } from './config.js';
|
|
23
|
+
import { Config, createRuntime } from './config.js';
|
|
24
24
|
import { createStreamGuard } from './stream-guard.js';
|
|
25
25
|
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
26
|
+
// 插件模块的导出形态:loader 的 unwrapExports 优先取 `default`,而 Config 必须挂在
|
|
27
|
+
// 拿到的那一个对象上。所以这里不写 default 导出,而是与官方插件一样导出
|
|
28
|
+
// `Config` + `apply` 两个具名,让 loader 直接拿到带 schema 的插件对象。
|
|
29
|
+
export { Config };
|
|
26
30
|
/**
|
|
27
|
-
* 函数式插件入口。ctx 为 cordis
|
|
31
|
+
* 函数式插件入口。ctx 为 cordis 上下文,config 是 loader 解析后的配置,
|
|
32
|
+
* 注册的监听随插件卸载自动释放。
|
|
28
33
|
* @param ctx - 宿主 cordis 上下文。
|
|
34
|
+
* @param config - 本插件的配置;volatile 字段是稳定引用,取值要 `.get()`。
|
|
29
35
|
*/
|
|
30
|
-
export
|
|
36
|
+
export function apply(ctx, config) {
|
|
31
37
|
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
32
38
|
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
33
39
|
const state = { pending: new Set() };
|
|
34
|
-
const runtime = createRuntime(ctx);
|
|
40
|
+
const runtime = createRuntime(ctx, config);
|
|
35
41
|
ctx.on('llm/stream', createStreamGuard(state, runtime.read, runtime.countHit), { global: true });
|
|
36
42
|
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state, runtime.read));
|
|
37
43
|
}
|
package/lib/resume.d.ts
CHANGED
|
@@ -15,8 +15,17 @@
|
|
|
15
15
|
* 推什么内容由配置决定(见 config.ts 的 resumeText / resumeSummary)。
|
|
16
16
|
*/
|
|
17
17
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
18
|
+
import type { ContextFormed } from '@deepseek-ai/dsh-llm';
|
|
18
19
|
import type { RepeatGuardConfig } from './config.js';
|
|
19
20
|
import type { GuardState } from './types.js';
|
|
21
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
22
|
+
interface MessageSourceMap {
|
|
23
|
+
/** 本插件推给模型的续跑提示。 */
|
|
24
|
+
'repeat-guard': {
|
|
25
|
+
kind: 'repeat-guard';
|
|
26
|
+
} & ContextFormed;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
20
29
|
/**
|
|
21
30
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
22
31
|
* @param state - 跨监听保留的拦截状态。
|
package/lib/resume.js
CHANGED
|
@@ -33,8 +33,7 @@ export function reviveTurn(state, agent, config) {
|
|
|
33
33
|
// `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
34
34
|
// 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
35
35
|
source: {
|
|
36
|
-
kind: '
|
|
37
|
-
plugin: 'repeat-guard',
|
|
36
|
+
kind: 'repeat-guard',
|
|
38
37
|
form: 'notice',
|
|
39
38
|
summary: config.resumeSummary,
|
|
40
39
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-repeat-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "流式输出退化拦截:检测碎片复读,掐断本次生成并提醒模型直接执行工具调用",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -39,23 +39,23 @@
|
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"author": "yuqing",
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
43
|
-
"@deepseek-ai/dsh-agent": "
|
|
44
|
-
"@deepseek-ai/dsh-client-ui-renderer": "
|
|
45
|
-
"@deepseek-ai/dsh-client-ui-settings": "
|
|
46
|
-
"@deepseek-ai/dsh-llm": "
|
|
47
|
-
"@deepseek-ai/dsh-settings": "
|
|
48
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
43
|
+
"@deepseek-ai/dsh-agent": ">=0.1.7-0 <0.1.8-0",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-renderer": ">=0.1.7-0 <0.1.8-0",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.7-0 <0.1.8-0",
|
|
46
|
+
"@deepseek-ai/dsh-llm": ">=0.1.7-0 <0.1.8-0",
|
|
47
|
+
"@deepseek-ai/dsh-settings": ">=0.1.7-0 <0.1.8-0",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
49
49
|
"react": "^18.2.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@deepseek-ai/cordis": "4.0.
|
|
53
|
-
"@deepseek-ai/dsh-agent": "0.1.
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.1.
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.1.
|
|
56
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
57
|
-
"@deepseek-ai/dsh-settings": "0.1.
|
|
58
|
-
"@deepseek-ai/schemastery": "3.18.
|
|
52
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
53
|
+
"@deepseek-ai/dsh-agent": "0.1.7-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-llm": "0.1.7-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-settings": "0.1.7-rc.2",
|
|
58
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
59
59
|
"@types/node": "^22.0.0",
|
|
60
60
|
"@types/react": "^19.3.0",
|
|
61
61
|
"esbuild": "^0.28.2",
|
package/src/client.tsx
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
13
13
|
// 空导入:只为加载这两处的 declaration merging(往 cordis 的 Context 上补
|
|
14
|
-
// slots 与
|
|
14
|
+
// slots 与 configForms 两个客户端服务)。
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
17
17
|
import { useEffect, useState, type KeyboardEvent, type ReactElement } from 'react';
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
19
|
+
/** profile 条目 id,必须与宿主侧 config.ts 的 ENTRY_ID 逐字一致。 */
|
|
20
|
+
const ENTRY_ID = 'dsh-repeat-guard';
|
|
21
21
|
|
|
22
22
|
/** 阈值步进器的取值边界。 */
|
|
23
23
|
const THRESHOLD_MIN = 1;
|
|
@@ -408,7 +408,7 @@ function Form({ initial, count, onReset, onSave }: FormProps): ReactElement {
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
/** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
|
|
411
|
-
export const inject = ['slots', '
|
|
411
|
+
export const inject = ['slots', 'configForms'];
|
|
412
412
|
|
|
413
413
|
/**
|
|
414
414
|
* 客户端插件入口。
|
|
@@ -416,14 +416,15 @@ export const inject = ['slots', 'settingsScope'];
|
|
|
416
416
|
*/
|
|
417
417
|
export function apply(ctx: Context): void {
|
|
418
418
|
injectStyle();
|
|
419
|
-
|
|
419
|
+
// 0.1.7 起表单按 profile 条目 id 定位;快照与写入接口同形。
|
|
420
|
+
const form = ctx.configForms.get<RepeatGuardSettings>(ENTRY_ID);
|
|
420
421
|
|
|
421
422
|
function Panel(): ReactElement {
|
|
422
|
-
const [snapshot, setSnapshot] = useState(() =>
|
|
423
|
+
const [snapshot, setSnapshot] = useState(() => form.getSnapshot());
|
|
423
424
|
useEffect(
|
|
424
425
|
() =>
|
|
425
|
-
|
|
426
|
-
setSnapshot(
|
|
426
|
+
form.subscribe(() => {
|
|
427
|
+
setSnapshot(form.getSnapshot());
|
|
427
428
|
}),
|
|
428
429
|
[],
|
|
429
430
|
);
|
|
@@ -436,14 +437,14 @@ export function apply(ctx: Context): void {
|
|
|
436
437
|
initial={value}
|
|
437
438
|
count={value.count}
|
|
438
439
|
onReset={async () => {
|
|
439
|
-
await
|
|
440
|
+
await form.set('count', 0);
|
|
440
441
|
}}
|
|
441
442
|
onSave={async (next) => {
|
|
442
|
-
await
|
|
443
|
-
await
|
|
444
|
-
await
|
|
445
|
-
await
|
|
446
|
-
await
|
|
443
|
+
await form.set('fragments', next.fragments);
|
|
444
|
+
await form.set('threshold', next.threshold);
|
|
445
|
+
await form.set('inlineRepeat', next.inlineRepeat);
|
|
446
|
+
await form.set('resumeText', next.resumeText);
|
|
447
|
+
await form.set('resumeSummary', next.resumeSummary);
|
|
447
448
|
}}
|
|
448
449
|
/>
|
|
449
450
|
);
|
package/src/config.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* 插件配置:拦截短句表、连续命中阈值、行内重复检测、续跑注入的正文与摘要,
|
|
3
3
|
* 外加一个运行时计数。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* dsh 0.1.7 起,配置就是插件自己的 entry Config——标记 `.volatile()` 的字段由宿主
|
|
6
|
+
* 的设置表单读写,写回的是 profile 的 cordis patch。宿主侧不再注册命名空间,
|
|
7
|
+
* 而是持有这些稳定引用,需要时 `.get()` 现取,所以改完立即生效,不用重启。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* 计数也寄在同一份配置里:宿主侧每次掐断写一次,客户端读同一个值、清零就是把它
|
|
10
|
+
* 写成 0。插件加载时先把它归零,所以口径是"自本次启动或上次清零以来"。
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -14,8 +15,11 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
14
15
|
import type {} from '@deepseek-ai/dsh-settings';
|
|
15
16
|
import z from '@deepseek-ai/schemastery';
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* profile 里本插件的条目 id。设置表单按它定位配置,宿主写回也要用它,
|
|
20
|
+
* 所以必须与 cordis.patch.yml 里的 `id` 逐字一致。
|
|
21
|
+
*/
|
|
22
|
+
export const ENTRY_ID = 'dsh-repeat-guard';
|
|
19
23
|
|
|
20
24
|
/** 默认的拦截短句表,标点照原样写。 */
|
|
21
25
|
export const DEFAULT_FRAGMENTS: readonly string[] = [
|
|
@@ -69,15 +73,22 @@ export const DEFAULT_RESUME_TEXT = [
|
|
|
69
73
|
/** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
|
|
70
74
|
export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/**
|
|
77
|
+
* 插件 Config schema。只有标了 `.volatile()` 的字段会进设置表单并支持编辑,
|
|
78
|
+
* 其余字段仍是普通配置(只由 cordis 配置文件决定)。
|
|
79
|
+
*/
|
|
80
|
+
export const Config = z.object({
|
|
81
|
+
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]).volatile(),
|
|
82
|
+
threshold: z.number().default(DEFAULT_THRESHOLD).volatile(),
|
|
83
|
+
inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT).volatile(),
|
|
84
|
+
resumeText: z.string().default(DEFAULT_RESUME_TEXT).volatile(),
|
|
85
|
+
resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY).volatile(),
|
|
86
|
+
count: z.number().default(0).volatile(),
|
|
79
87
|
});
|
|
80
88
|
|
|
89
|
+
/** loader 解析后交给插件的配置:volatile 字段是稳定引用,取值要 `.get()`。 */
|
|
90
|
+
export type RepeatGuardConfigSchema = Schemastery.TypeT<typeof Config>;
|
|
91
|
+
|
|
81
92
|
/** 判定与续跑用得上的一份配置。 */
|
|
82
93
|
export interface RepeatGuardConfig {
|
|
83
94
|
/** 短句表,已全部小写化。 */
|
|
@@ -99,7 +110,7 @@ export type ConfigSource = () => RepeatGuardConfig;
|
|
|
99
110
|
export interface GuardRuntime {
|
|
100
111
|
/** 取当前配置。 */
|
|
101
112
|
readonly read: ConfigSource;
|
|
102
|
-
/**
|
|
113
|
+
/** 记一次拦截,把计数写回配置。 */
|
|
103
114
|
readonly countHit: () => void;
|
|
104
115
|
}
|
|
105
116
|
|
|
@@ -120,43 +131,41 @@ function compile(value: {
|
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
/**
|
|
123
|
-
*
|
|
134
|
+
* 把 volatile 引用折叠成一份快照,返回宿主侧要用的运行时句柄。
|
|
124
135
|
* @param ctx - 宿主 cordis 上下文。
|
|
136
|
+
* @param config - loader 解析后的本插件配置。
|
|
125
137
|
* @returns 取配置与记数两个入口。
|
|
126
138
|
*/
|
|
127
|
-
export function createRuntime(ctx: Context): GuardRuntime {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
export function createRuntime(ctx: Context, config: RepeatGuardConfigSchema): GuardRuntime {
|
|
140
|
+
const read = (): RepeatGuardConfig =>
|
|
141
|
+
compile({
|
|
142
|
+
fragments: config.fragments.get(),
|
|
143
|
+
threshold: config.threshold.get(),
|
|
144
|
+
inlineRepeat: config.inlineRepeat.get(),
|
|
145
|
+
resumeText: config.resumeText.get(),
|
|
146
|
+
resumeSummary: config.resumeSummary.get(),
|
|
147
|
+
});
|
|
136
148
|
|
|
149
|
+
let write: ((count: number) => void) | undefined;
|
|
137
150
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
const settings = settingsCtx.settings;
|
|
152
|
+
// 本插件自带设置页,关掉宿主按 schema 自动生成的页面。
|
|
153
|
+
settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber));
|
|
154
|
+
write = (count: number): void => {
|
|
155
|
+
// 现取 revision:表单刚写过的话,旧 revision 会被 SETTINGS_CONFLICT 拒掉。
|
|
156
|
+
const revision = settings.describe().find((entry) => entry.ns === ENTRY_ID)?.revision;
|
|
157
|
+
settings.update(ENTRY_ID, { count }, revision).catch((error: unknown) => {
|
|
144
158
|
console.log(`[repeat-guard] 写设置失败:${String(error)}`);
|
|
145
159
|
});
|
|
146
160
|
};
|
|
147
|
-
sync();
|
|
148
|
-
scope.watch(sync);
|
|
149
161
|
// 计数口径是"自本次启动或上次清零以来"。
|
|
150
|
-
write(
|
|
151
|
-
bump = (): void => {
|
|
152
|
-
write({ count: scope.get().count + 1 });
|
|
153
|
-
};
|
|
162
|
+
write(0);
|
|
154
163
|
});
|
|
155
164
|
|
|
156
165
|
return {
|
|
157
|
-
read
|
|
166
|
+
read,
|
|
158
167
|
countHit: () => {
|
|
159
|
-
|
|
168
|
+
write?.(config.count.get() + 1);
|
|
160
169
|
},
|
|
161
170
|
};
|
|
162
171
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,20 +22,27 @@
|
|
|
22
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
23
|
|
|
24
24
|
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
-
import { createRuntime } from './config.js';
|
|
25
|
+
import { Config, createRuntime, type RepeatGuardConfigSchema } 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';
|
|
29
29
|
|
|
30
|
+
// 插件模块的导出形态:loader 的 unwrapExports 优先取 `default`,而 Config 必须挂在
|
|
31
|
+
// 拿到的那一个对象上。所以这里不写 default 导出,而是与官方插件一样导出
|
|
32
|
+
// `Config` + `apply` 两个具名,让 loader 直接拿到带 schema 的插件对象。
|
|
33
|
+
export { Config };
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
|
-
* 函数式插件入口。ctx 为 cordis
|
|
36
|
+
* 函数式插件入口。ctx 为 cordis 上下文,config 是 loader 解析后的配置,
|
|
37
|
+
* 注册的监听随插件卸载自动释放。
|
|
32
38
|
* @param ctx - 宿主 cordis 上下文。
|
|
39
|
+
* @param config - 本插件的配置;volatile 字段是稳定引用,取值要 `.get()`。
|
|
33
40
|
*/
|
|
34
|
-
export
|
|
41
|
+
export function apply(ctx: Context, config: RepeatGuardConfigSchema): void {
|
|
35
42
|
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
36
43
|
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
37
44
|
const state: GuardState = { pending: new Set() };
|
|
38
|
-
const runtime = createRuntime(ctx);
|
|
45
|
+
const runtime = createRuntime(ctx, config);
|
|
39
46
|
ctx.on('llm/stream', createStreamGuard(state, runtime.read, runtime.countHit), { global: true });
|
|
40
47
|
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state, runtime.read));
|
|
41
48
|
}
|
package/src/resume.ts
CHANGED
|
@@ -17,9 +17,21 @@
|
|
|
17
17
|
|
|
18
18
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
19
19
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
20
|
+
import type { ContextFormed } from '@deepseek-ai/dsh-llm';
|
|
20
21
|
import type { RepeatGuardConfig } from './config.js';
|
|
21
22
|
import type { GuardState } from './types.js';
|
|
22
23
|
|
|
24
|
+
// dsh 0.1.7 起 `MessageSourceMap` 没有通用的 `plugin` kind:每个生产者在自己的模块里
|
|
25
|
+
// 声明自己的 kind(merge-extensible),`form` 另由 ContextFormed 提供。
|
|
26
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
27
|
+
interface MessageSourceMap {
|
|
28
|
+
/** 本插件推给模型的续跑提示。 */
|
|
29
|
+
'repeat-guard': {
|
|
30
|
+
kind: 'repeat-guard';
|
|
31
|
+
} & ContextFormed;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/**
|
|
24
36
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
25
37
|
* @param state - 跨监听保留的拦截状态。
|
|
@@ -39,8 +51,7 @@ export function reviveTurn(state: GuardState, agent: Agent, config: RepeatGuardC
|
|
|
39
51
|
// `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
40
52
|
// 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
41
53
|
source: {
|
|
42
|
-
kind: '
|
|
43
|
-
plugin: 'repeat-guard',
|
|
54
|
+
kind: 'repeat-guard',
|
|
44
55
|
form: 'notice',
|
|
45
56
|
summary: config.resumeSummary,
|
|
46
57
|
},
|