dsh-repeat-guard 0.1.2 → 0.1.4

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  | 类型 | dsh bundle,含宿主侧插件与客户端设置页 |
8
8
  | 监听 | `llm/stream`、`agent/turn-stopping` |
9
9
  | 入口 | `lib/index.js`(`src/index.ts`)、`lib/client.js`(`src/client.tsx`) |
10
- | 可调项 | 拦截短句表、连续命中阈值(设置 → 复读打断) |
10
+ | 可调项 | 拦截短句表、连续命中阈值、行内重复检测、续跑指令与折叠摘要(设置 → 复读打断) |
11
11
  | 分发 | npm 包 `dsh-repeat-guard` |
12
12
 
13
13
  ---
@@ -51,6 +51,14 @@
51
51
 
52
52
  表项**连标点一起写**(`'好的。'` 对应思考里的 `好的。`),英文条目一律写小写。分割单位是单个换行,空行会把连续计数打断。
53
53
 
54
+ ### 行内重复检测(默认关)
55
+
56
+ 上面那条规则只认"独占一行的整句",所以 `好。好。好。` 这种挤在一行里的重复看不见——对 DeepSeek 够用,但有些模型是单行循环的,所以留了这个开关。
57
+
58
+ 打开之后多一条判据:**整行由一个或多个表项首尾相接拼成**,也算命中(`好。好。好。`、`好。ok.好。`)。它仍然不会误杀——`我觉得可以。好。` 的前面那截不是表项、整行拼不出来,放行;`好的,好的` 也放行(表项是带句号的 `好的。`,这里是逗号)。
59
+
60
+ 计数单位同时从"行"变成"表项":整行全等算一次,行内每一段各算一次。所以阈值 2 配上它时,`好。好。` 这种一行两段的也会被拦。
61
+
54
62
  **表可以在设置里改**(见下面「可调项」);`src/config.ts` 的 `DEFAULT_FRAGMENTS` 是出厂默认值。
55
63
 
56
64
  已知边界:判据只看"有没有这样一行",不看上下文。所以"用户想让我改代码。\n好的。"这种**前面有实质内容、结尾又跟一句空话**的思考也会被拦。这是"按行判定"的直接结果。
@@ -116,20 +124,28 @@ if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读收件箱
116
124
 
117
125
  | 项 | 含义 |
118
126
  |---|---|
127
+ | 累计拦截 | 自本次启动或上次清零以来掐断过多少次(只读,旁边有清零按钮) |
119
128
  | 拦截短句 | 判为复读的空话短句表,一行一句,连标点一起写 |
120
- | 连续命中次数 | 连着几行命中才拦;默认 1 = 发现即拦 |
129
+ | 连续命中次数 | 连着几次命中才拦;默认 1 = 发现即拦 |
130
+ | 行内重复检测 | 打开后"好。好。好。"这种挤在一行里的重复也算命中;默认关 |
131
+ | 续跑指令 | 截断后推给模型的指令正文,整段照发 |
132
+ | 折叠行摘要 | 那条注入消息在会话里的折叠行摘要 |
133
+
134
+ 配置存在 dsh 的 settings 服务里(命名空间 `repeat-guard`),落盘在 `~/.dsh/settings.yaml` 的 `repeat-guard:` 节下。
121
135
 
122
- 配置存在 dsh 的 settings 服务里(命名空间 `repeat-guard`),落盘位置由 dsh 决定。
136
+ **累计计数也寄在这个命名空间里**——宿主侧每掐断一次写一次,客户端读同一个值,清零就是把它写成 0。这样不用另开 host↔client 通道,代价是它跟着配置一起落盘;插件加载时会先把它归零,所以口径是"自本次启动或上次清零以来"。计数只在**真的掐断**时 +1,命中却落在思考段末尾(未拦截)的那种不计。
123
137
 
124
138
  ## 续跑时推给模型的输入
125
139
 
140
+ 默认正文(可在设置里改):
141
+
126
142
  ```
127
143
  [复读拦截]
128
144
  你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。
129
145
  请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。
130
146
  ```
131
147
 
132
- 这条消息带 `source.kind = 'plugin'`,客户端会渲染成折叠的 context 行,不是用户气泡。
148
+ 这条消息带 `source.kind = 'plugin'`,客户端按 `source.summary`(同样可配置)渲染成折叠的 context 行,不是用户气泡。
133
149
 
134
150
  ## 构建
135
151
 
package/lib/client.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * 客户端设置页:在设置窗口左侧加一项"复读打断"。
3
3
  *
4
- * 面板里编辑两样东西——拦截短句表(一行一句)与连续命中阈值,两者都写宿主侧
5
- * 注册的 settings 命名空间,保存后宿主侧立即按新配置判定。
4
+ * 版式照着官方设置页来:顶部一行"左标题 + 右保存",下面每项是一行
5
+ * (左标题与描述、右控件),样式全部用 dsh 的设计令牌 `--dsw-*`,
6
+ * 由本模块自己注入 `<style>`,与官方 feature(如 theme 的字号行)同款做法。
6
7
  *
7
8
  * 本文件由 scripts/build-client.mjs 用 esbuild 单独打包成单文件 bundle
8
9
  * (`lib/client.js`),不走 tsc 的 lib 输出。
package/lib/client.js CHANGED
@@ -34,58 +34,339 @@ var __dshClientBundle = (() => {
34
34
  var import_react = __require("react");
35
35
  var import_jsx_runtime = __require("react/jsx-runtime");
36
36
  var SETTINGS_NS = "repeat-guard";
37
- var inject = ["slots", "settingsScope"];
38
- function apply(ctx) {
39
- const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
40
- function Form({ initial }) {
41
- const [fragments, setFragments] = (0, import_react.useState)(() => (initial.fragments ?? []).join("\n"));
42
- const [threshold, setThreshold] = (0, import_react.useState)(() => String(initial.threshold ?? 1));
43
- const [message, setMessage] = (0, import_react.useState)("");
44
- function save() {
45
- const list = fragments.split("\n").map((line) => line.trim()).filter((line) => line !== "");
46
- const count = Number(threshold);
47
- setMessage("\u4FDD\u5B58\u4E2D\u2026");
48
- scope.set("fragments", list).then(() => scope.set("threshold", count)).then(() => {
49
- setMessage("\u5DF2\u4FDD\u5B58");
50
- }).catch((error) => {
51
- setMessage(`\u4FDD\u5B58\u5931\u8D25\uFF1A${String(error)}`);
52
- });
53
- }
54
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", flexDirection: "column", gap: "12px" }, children: [
55
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
56
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570" }),
37
+ var THRESHOLD_MIN = 1;
38
+ var THRESHOLD_MAX = 9;
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`;
40
+ var CSS = `
41
+ .rg-root{display:flex;flex-direction:column;width:100%}
42
+ .rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
43
+ .rg-headTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:500;line-height:24px}
44
+ .rg-headNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
45
+ .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}
46
+ .rg-save:hover{background:var(--dsw-alias-button-primary-hover)}
47
+ .rg-row{display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
48
+ .rg-row--stack{flex-direction:column;align-items:stretch;gap:10px}
49
+ .rg-rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
50
+ .rg-rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
51
+ .rg-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
52
+ .rg-control{display:inline-flex;align-items:center;gap:8px}
53
+ .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)}
54
+ .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}
55
+ .rg-stepperUnit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
56
+ .rg-arrows{position:absolute;right:8px;display:flex;flex-direction:column;gap:2px;opacity:0}
57
+ .rg-stepper:hover .rg-arrows,.rg-stepper:focus-within .rg-arrows{opacity:1}
58
+ .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)}
59
+ .rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
60
+ .rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
61
+ .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}
62
+ .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}
63
+ .rg-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}
64
+ .rg-switch[aria-checked="true"]::after{transform:translateX(16px)}
65
+ .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}
66
+ .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}
67
+ .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)}
68
+ .rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
69
+ .rg-itemText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
70
+ .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)}
71
+ .rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
72
+ .rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
73
+ .rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
74
+ .rg-input{flex:1;min-width:0;${FIELD}}
75
+ .rg-summaryInput{flex:none;width:260px;${FIELD}}
76
+ .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}
77
+ .rg-input:focus,.rg-summaryInput:focus,.rg-textarea:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
78
+ .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}
79
+ .rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
80
+ `;
81
+ var STYLE_TAG = "dsh-repeat-guard/panel.css";
82
+ function injectStyle() {
83
+ if (typeof document === "undefined" || document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) {
84
+ return;
85
+ }
86
+ const tag = document.createElement("style");
87
+ tag.dataset.plugin = "dsh-repeat-guard";
88
+ tag.dataset.pluginCss = STYLE_TAG;
89
+ tag.textContent = CSS;
90
+ document.head.appendChild(tag);
91
+ }
92
+ function Stepper({ value, min, max, unit, label, onChange }) {
93
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-control", children: [
94
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-stepper", children: [
95
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-stepperValue", children: value }),
96
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-arrows", children: [
57
97
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
58
- "input",
98
+ "button",
59
99
  {
60
- type: "number",
61
- value: threshold,
62
- onChange: (event) => {
63
- setThreshold(event.target.value);
64
- }
100
+ type: "button",
101
+ className: "rg-arrow",
102
+ "aria-label": `\u589E\u5927${label}`,
103
+ disabled: value >= max,
104
+ onClick: () => {
105
+ onChange(value + 1);
106
+ },
107
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "9", height: "9", viewBox: "0 0 9 9", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
108
+ "path",
109
+ {
110
+ d: "M1.4 6.2 4.5 3.1l3.1 3.1",
111
+ fill: "none",
112
+ stroke: "currentColor",
113
+ strokeWidth: "1.4",
114
+ strokeLinecap: "round",
115
+ strokeLinejoin: "round"
116
+ }
117
+ ) })
65
118
  }
66
119
  ),
67
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u586B 1 \u8868\u793A\u547D\u4E2D\u5373\u62E6\uFF1B\u586B n \u8868\u793A\u8FDE\u7740 n \u884C\u90FD\u547D\u4E2D\u624D\u62E6\u3002" })
68
- ] }),
69
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
70
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u62E6\u622A\u77ED\u53E5\uFF08\u4E00\u884C\u4E00\u53E5\uFF0C\u8FDE\u6807\u70B9\u4E00\u8D77\u5199\uFF09" }),
71
120
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
72
- "textarea",
121
+ "button",
73
122
  {
74
- rows: 16,
75
- style: { width: "100%", fontFamily: "monospace" },
76
- value: fragments,
77
- onChange: (event) => {
78
- setFragments(event.target.value);
79
- }
123
+ type: "button",
124
+ className: "rg-arrow",
125
+ "aria-label": `\u51CF\u5C0F${label}`,
126
+ disabled: value <= min,
127
+ onClick: () => {
128
+ onChange(value - 1);
129
+ },
130
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "9", height: "9", viewBox: "0 0 9 9", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
131
+ "path",
132
+ {
133
+ d: "M1.4 2.8 4.5 5.9l3.1-3.1",
134
+ fill: "none",
135
+ stroke: "currentColor",
136
+ strokeWidth: "1.4",
137
+ strokeLinecap: "round",
138
+ strokeLinejoin: "round"
139
+ }
140
+ ) })
80
141
  }
81
142
  )
82
- ] }),
83
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
84
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: save, children: "\u4FDD\u5B58" }),
85
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: message })
86
143
  ] })
87
- ] });
144
+ ] }),
145
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-stepperUnit", children: unit })
146
+ ] });
147
+ }
148
+ function Switch({ checked, label, onChange }) {
149
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
150
+ "button",
151
+ {
152
+ type: "button",
153
+ role: "switch",
154
+ "aria-checked": checked,
155
+ "aria-label": label,
156
+ className: "rg-switch",
157
+ onClick: () => {
158
+ onChange(!checked);
159
+ }
160
+ }
161
+ );
162
+ }
163
+ function FragmentList({ items, draft, onDraft, onAdd, onRemove }) {
164
+ function handleKey(event) {
165
+ if (event.key === "Enter") {
166
+ event.preventDefault();
167
+ onAdd();
168
+ }
169
+ }
170
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
171
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-list", children: [
172
+ items.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-empty", children: "\u8868\u662F\u7A7A\u7684\uFF0C\u4E0D\u4F1A\u62E6\u622A\u4EFB\u4F55\u77ED\u53E5\u3002" }) : null,
173
+ items.map((item, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-item", children: [
174
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-itemText", children: item }),
175
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
176
+ "button",
177
+ {
178
+ type: "button",
179
+ className: "rg-remove",
180
+ "aria-label": `\u5220\u9664 ${item}`,
181
+ onClick: () => {
182
+ onRemove(index);
183
+ },
184
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
185
+ "path",
186
+ {
187
+ d: "M3 3l6 6M9 3l-6 6",
188
+ fill: "none",
189
+ stroke: "currentColor",
190
+ strokeWidth: "1.4",
191
+ strokeLinecap: "round"
192
+ }
193
+ ) })
194
+ }
195
+ )
196
+ ] }, `${item}#${String(index)}`))
197
+ ] }),
198
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-addRow", children: [
199
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
200
+ "input",
201
+ {
202
+ className: "rg-input",
203
+ value: draft,
204
+ placeholder: "\u65B0\u589E\u77ED\u53E5\uFF0C\u8FDE\u6807\u70B9\u4E00\u8D77\u5199\uFF0C\u56DE\u8F66\u6DFB\u52A0",
205
+ onChange: (event) => {
206
+ onDraft(event.target.value);
207
+ },
208
+ onKeyDown: handleKey
209
+ }
210
+ ),
211
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-button", onClick: onAdd, children: "\u6DFB\u52A0" })
212
+ ] })
213
+ ] });
214
+ }
215
+ function Form({ initial, count, onReset, onSave }) {
216
+ const [items, setItems] = (0, import_react.useState)(initial.fragments);
217
+ const [limit, setLimit] = (0, import_react.useState)(initial.threshold);
218
+ const [inlineRepeat, setInlineRepeat] = (0, import_react.useState)(initial.inlineRepeat);
219
+ const [resumeText, setResumeText] = (0, import_react.useState)(initial.resumeText);
220
+ const [resumeSummary, setResumeSummary] = (0, import_react.useState)(initial.resumeSummary);
221
+ const [draft, setDraft] = (0, import_react.useState)("");
222
+ const [note, setNote] = (0, import_react.useState)("");
223
+ function add() {
224
+ const value = draft.trim();
225
+ if (value === "") {
226
+ return;
227
+ }
228
+ setItems([...items, value]);
229
+ setDraft("");
230
+ setNote("");
88
231
  }
232
+ function remove(index) {
233
+ setItems(items.filter((_, at) => at !== index));
234
+ setNote("");
235
+ }
236
+ function save() {
237
+ setNote("\u4FDD\u5B58\u4E2D\u2026");
238
+ onSave({
239
+ fragments: [...items],
240
+ threshold: limit,
241
+ inlineRepeat,
242
+ resumeText,
243
+ resumeSummary,
244
+ count
245
+ }).then(() => {
246
+ setNote("\u5DF2\u4FDD\u5B58");
247
+ }).catch((error) => {
248
+ setNote(`\u4FDD\u5B58\u5931\u8D25\uFF1A${String(error)}`);
249
+ });
250
+ }
251
+ function reset() {
252
+ onReset().then(() => {
253
+ setNote("\u8BA1\u6570\u5DF2\u6E05\u96F6");
254
+ }).catch((error) => {
255
+ setNote(`\u6E05\u96F6\u5931\u8D25\uFF1A${String(error)}`);
256
+ });
257
+ }
258
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-root", children: [
259
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-head", children: [
260
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-headTitle", children: "\u590D\u8BFB\u6253\u65AD" }),
261
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-headNote", children: note }),
262
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-save", onClick: save, children: "\u4FDD\u5B58" })
263
+ ] }),
264
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
265
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
266
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u7D2F\u8BA1\u62E6\u622A" }),
267
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u91CD\u590D\u6253\u65AD\u8BA1\u6570" })
268
+ ] }),
269
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-control", children: [
270
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-count", children: [
271
+ count,
272
+ " \u6B21"
273
+ ] }),
274
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-button", onClick: reset, children: "\u6E05\u96F6" })
275
+ ] })
276
+ ] }),
277
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
278
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
279
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570" }),
280
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u5FAA\u73AF\u6B21\u6570\u4E0A\u9650\uFF1B\u586B 1 \u8868\u793A\u53D1\u73B0\u5373\u62E6" })
281
+ ] }),
282
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
283
+ Stepper,
284
+ {
285
+ value: limit,
286
+ min: THRESHOLD_MIN,
287
+ max: THRESHOLD_MAX,
288
+ unit: "\u6B21",
289
+ label: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570",
290
+ onChange: (next) => {
291
+ setLimit(next);
292
+ setNote("");
293
+ }
294
+ }
295
+ )
296
+ ] }),
297
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
298
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
299
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u884C\u5185\u91CD\u590D\u68C0\u6D4B" }),
300
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: '\u62E6\u622A\u7C7B\u4F3C"\u597D\u3002\u597D\u3002\u597D\u3002"\u7684\u884C\u5185\u5FAA\u73AF\uFF1B\u5173\u95ED\u65F6\u53EA\u68C0\u6D4B\u72EC\u7ACB\u6BB5\u843D\u3002' })
301
+ ] }),
302
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
303
+ Switch,
304
+ {
305
+ checked: inlineRepeat,
306
+ label: "\u884C\u5185\u91CD\u590D\u68C0\u6D4B",
307
+ onChange: (next) => {
308
+ setInlineRepeat(next);
309
+ setNote("");
310
+ }
311
+ }
312
+ )
313
+ ] }),
314
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row rg-row--stack", children: [
315
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
316
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u77ED\u53E5" }),
317
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u6807\u70B9\u4E0D\u53EF\u7701\u7565" })
318
+ ] }),
319
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
320
+ FragmentList,
321
+ {
322
+ items,
323
+ draft,
324
+ onDraft: setDraft,
325
+ onAdd: add,
326
+ onRemove: remove
327
+ }
328
+ )
329
+ ] }),
330
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row rg-row--stack", children: [
331
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
332
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u63D0\u793A\u8BCD" }),
333
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u622A\u65AD\u540E\u4E0A\u4E0B\u6587\u6CE8\u5165\u7684\u63D0\u793A\u8BCD" })
334
+ ] }),
335
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
336
+ "textarea",
337
+ {
338
+ className: "rg-textarea",
339
+ value: resumeText,
340
+ onChange: (event) => {
341
+ setResumeText(event.target.value);
342
+ setNote("");
343
+ }
344
+ }
345
+ )
346
+ ] }),
347
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
348
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
349
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u6458\u8981" }),
350
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u4E0A\u4E0B\u6587\u6CE8\u5165\u7684\u7B80\u4ECB\u4FE1\u606F\uFF0C\u4EC5\u7528\u6237\u53EF\u89C1" })
351
+ ] }),
352
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
353
+ "input",
354
+ {
355
+ className: "rg-summaryInput",
356
+ value: resumeSummary,
357
+ onChange: (event) => {
358
+ setResumeSummary(event.target.value);
359
+ setNote("");
360
+ }
361
+ }
362
+ )
363
+ ] })
364
+ ] });
365
+ }
366
+ var inject = ["slots", "settingsScope"];
367
+ function apply(ctx) {
368
+ injectStyle();
369
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
89
370
  function Panel() {
90
371
  const [snapshot, setSnapshot] = (0, import_react.useState)(() => scope.getSnapshot());
91
372
  (0, import_react.useEffect)(
@@ -96,9 +377,25 @@ var __dshClientBundle = (() => {
96
377
  );
97
378
  const value = snapshot.value;
98
379
  if (snapshot.status !== "ready" || value === void 0) {
99
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: `\u914D\u7F6E\u5C1A\u672A\u5C31\u7EEA\uFF08${snapshot.status}\uFF09` });
380
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-empty", children: `\u914D\u7F6E\u5C1A\u672A\u5C31\u7EEA\uFF08${snapshot.status}\uFF09` });
100
381
  }
101
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Form, { initial: value });
382
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
383
+ Form,
384
+ {
385
+ initial: value,
386
+ count: value.count,
387
+ onReset: async () => {
388
+ await scope.set("count", 0);
389
+ },
390
+ onSave: async (next) => {
391
+ await scope.set("fragments", next.fragments);
392
+ await scope.set("threshold", next.threshold);
393
+ await scope.set("inlineRepeat", next.inlineRepeat);
394
+ await scope.set("resumeText", next.resumeText);
395
+ await scope.set("resumeSummary", next.resumeSummary);
396
+ }
397
+ }
398
+ );
102
399
  }
103
400
  ctx.slots.inject(
104
401
  "settings.section",
package/lib/config.d.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
  import type { Context } from '@deepseek-ai/cordis';
8
12
  /** settings 命名空间。客户端设置页必须写同一个值。 */
@@ -11,18 +15,37 @@ export declare const SETTINGS_NS = "repeat-guard";
11
15
  export declare const DEFAULT_FRAGMENTS: readonly string[];
12
16
  /** 默认阈值:1 表示发现即拦。 */
13
17
  export declare const DEFAULT_THRESHOLD = 1;
14
- /** 一份生效中的配置,已编译成判定时直接可用的形式。 */
18
+ /** 默认关闭行内重复检测:只认独占一行的整句。 */
19
+ export declare const DEFAULT_INLINE_REPEAT = false;
20
+ /** 默认的续跑指令正文,截断后推给模型。 */
21
+ export declare const DEFAULT_RESUME_TEXT: string;
22
+ /** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
23
+ export declare const DEFAULT_RESUME_SUMMARY = "\u590D\u8BFB\u5DF2\u622A\u65AD\uFF1A\u8BF7\u7EE7\u7EED\u6267\u884C";
24
+ /** 判定与续跑用得上的一份配置。 */
15
25
  export interface RepeatGuardConfig {
16
26
  /** 短句表,已全部小写化。 */
17
27
  readonly fragments: ReadonlySet<string>;
18
- /** 连续命中多少行才拦。 */
28
+ /** 连续命中多少次才拦。 */
19
29
  readonly threshold: number;
30
+ /** 是否把"整行由多个表项拼成"也算命中。 */
31
+ readonly inlineRepeat: boolean;
32
+ /** 截断后推给模型的指令正文。 */
33
+ readonly resumeText: string;
34
+ /** 注入消息的折叠行摘要。 */
35
+ readonly resumeSummary: string;
20
36
  }
21
37
  /** 取当前配置。 */
22
38
  export type ConfigSource = () => RepeatGuardConfig;
39
+ /** 宿主侧要用到的两件事:取配置、记一次拦截。 */
40
+ export interface GuardRuntime {
41
+ /** 取当前配置。 */
42
+ readonly read: ConfigSource;
43
+ /** 记一次拦截,把计数写回 settings。 */
44
+ readonly countHit: () => void;
45
+ }
23
46
  /**
24
- * 注册配置命名空间,并返回取当前配置的函数。
47
+ * 注册配置命名空间,返回宿主侧要用的运行时句柄。
25
48
  * @param ctx - 宿主 cordis 上下文。
26
- * @returns 每次调用都返回最新配置。
49
+ * @returns 取配置与记数两个入口。
27
50
  */
28
- export declare function createConfigSource(ctx: Context): ConfigSource;
51
+ export declare function createRuntime(ctx: Context): GuardRuntime;
package/lib/config.js 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
  import z from '@deepseek-ai/schemastery';
8
12
  /** settings 命名空间。客户端设置页必须写同一个值。 */
@@ -44,30 +48,69 @@ export const DEFAULT_FRAGMENTS = [
44
48
  ];
45
49
  /** 默认阈值:1 表示发现即拦。 */
46
50
  export const DEFAULT_THRESHOLD = 1;
51
+ /** 默认关闭行内重复检测:只认独占一行的整句。 */
52
+ export const DEFAULT_INLINE_REPEAT = false;
53
+ /** 默认的续跑指令正文,截断后推给模型。 */
54
+ export const DEFAULT_RESUME_TEXT = [
55
+ '[复读拦截]',
56
+ '你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
57
+ '请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
58
+ ].join('\n');
59
+ /** 默认的折叠行摘要;客户端拿它渲染折叠的 context 行,不是用户气泡。 */
60
+ export const DEFAULT_RESUME_SUMMARY = '复读已截断:请继续执行';
47
61
  const SCHEMA = z.object({
48
62
  fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
49
63
  threshold: z.number().default(DEFAULT_THRESHOLD),
64
+ inlineRepeat: z.boolean().default(DEFAULT_INLINE_REPEAT),
65
+ resumeText: z.string().default(DEFAULT_RESUME_TEXT),
66
+ resumeSummary: z.string().default(DEFAULT_RESUME_SUMMARY),
67
+ count: z.number().default(0),
50
68
  });
51
69
  function compile(value) {
52
70
  return {
53
71
  fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
54
72
  threshold: value.threshold,
73
+ inlineRepeat: value.inlineRepeat,
74
+ resumeText: value.resumeText,
75
+ resumeSummary: value.resumeSummary,
55
76
  };
56
77
  }
57
78
  /**
58
- * 注册配置命名空间,并返回取当前配置的函数。
79
+ * 注册配置命名空间,返回宿主侧要用的运行时句柄。
59
80
  * @param ctx - 宿主 cordis 上下文。
60
- * @returns 每次调用都返回最新配置。
81
+ * @returns 取配置与记数两个入口。
61
82
  */
62
- export function createConfigSource(ctx) {
63
- let current = compile({ fragments: DEFAULT_FRAGMENTS, threshold: DEFAULT_THRESHOLD });
83
+ export function createRuntime(ctx) {
84
+ let current = compile({
85
+ fragments: DEFAULT_FRAGMENTS,
86
+ threshold: DEFAULT_THRESHOLD,
87
+ inlineRepeat: DEFAULT_INLINE_REPEAT,
88
+ resumeText: DEFAULT_RESUME_TEXT,
89
+ resumeSummary: DEFAULT_RESUME_SUMMARY,
90
+ });
91
+ let bump;
64
92
  ctx.inject(['settings'], (settingsCtx) => {
65
93
  const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
66
94
  const sync = () => {
67
95
  current = compile(scope.get());
68
96
  };
97
+ const write = (patch) => {
98
+ scope.update(patch).catch((error) => {
99
+ console.log(`[repeat-guard] 写设置失败:${String(error)}`);
100
+ });
101
+ };
69
102
  sync();
70
103
  scope.watch(sync);
104
+ // 计数口径是"自本次启动或上次清零以来"。
105
+ write({ count: 0 });
106
+ bump = () => {
107
+ write({ count: scope.get().count + 1 });
108
+ };
71
109
  });
72
- return () => current;
110
+ return {
111
+ read: () => current,
112
+ countHit: () => {
113
+ bump?.();
114
+ },
115
+ };
73
116
  }