dsh-repeat-guard 0.2.0 → 0.2.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/LICENSE +21 -21
- package/README.md +121 -115
- package/cordis.patch.yml +7 -7
- package/lib/resume.d.ts +2 -2
- package/lib/resume.js +1 -1
- package/package.json +64 -64
- package/src/client.tsx +459 -459
- package/src/config.ts +171 -171
- package/src/detect.ts +86 -86
- package/src/index.ts +48 -48
- package/src/resume.ts +62 -60
- package/src/stream-guard.ts +118 -118
- package/src/turn-stopping-guard.ts +23 -23
- package/src/types.ts +13 -13
package/src/client.tsx
CHANGED
|
@@ -1,459 +1,459 @@
|
|
|
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 与 configForms 两个客户端服务)。
|
|
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
|
-
/** profile 条目 id,必须与宿主侧 config.ts 的 ENTRY_ID 逐字一致。 */
|
|
20
|
-
const ENTRY_ID = 'dsh-repeat-guard';
|
|
21
|
-
|
|
22
|
-
/** 阈值步进器的取值边界。 */
|
|
23
|
-
const THRESHOLD_MIN = 1;
|
|
24
|
-
const THRESHOLD_MAX = 9;
|
|
25
|
-
|
|
26
|
-
/** 宿主侧注册的配置。字段都带 schema 默认值,所以在这里是必有的。 */
|
|
27
|
-
interface RepeatGuardSettings {
|
|
28
|
-
/** 拦截短句表。 */
|
|
29
|
-
fragments: string[];
|
|
30
|
-
/** 连续命中多少次才拦。 */
|
|
31
|
-
threshold: number;
|
|
32
|
-
/** 是否把"整行由多个表项拼成"也算命中。 */
|
|
33
|
-
inlineRepeat: boolean;
|
|
34
|
-
/** 截断后推给模型的指令正文。 */
|
|
35
|
-
resumeText: string;
|
|
36
|
-
/** 注入消息的折叠行摘要。 */
|
|
37
|
-
resumeSummary: string;
|
|
38
|
-
/** 累计拦截次数,由宿主侧维护。 */
|
|
39
|
-
count: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** 输入框与文本域的公共外观。 */
|
|
43
|
-
const FIELD = `height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px`;
|
|
44
|
-
|
|
45
|
-
/** 样式。令牌名取自 dsh 前端已发布的设计令牌表。 */
|
|
46
|
-
const CSS = `
|
|
47
|
-
.rg-root{display:flex;flex-direction:column;width:100%}
|
|
48
|
-
.rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
|
|
49
|
-
.rg-headTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:500;line-height:24px}
|
|
50
|
-
.rg-headNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
|
|
51
|
-
.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}
|
|
52
|
-
.rg-save:hover{background:var(--dsw-alias-button-primary-hover)}
|
|
53
|
-
.rg-row{display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
54
|
-
.rg-row--stack{flex-direction:column;align-items:stretch;gap:10px}
|
|
55
|
-
.rg-rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
|
|
56
|
-
.rg-rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
|
|
57
|
-
.rg-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
|
|
58
|
-
.rg-control{display:inline-flex;align-items:center;gap:8px}
|
|
59
|
-
.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)}
|
|
60
|
-
.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}
|
|
61
|
-
.rg-stepperUnit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
|
|
62
|
-
.rg-arrows{position:absolute;right:8px;display:flex;flex-direction:column;gap:2px;opacity:0}
|
|
63
|
-
.rg-stepper:hover .rg-arrows,.rg-stepper:focus-within .rg-arrows{opacity:1}
|
|
64
|
-
.rg-arrow{display:inline-flex;justify-content:center;align-items:center;width:17px;height:12px;padding:0;border:none;border-radius:3px;cursor:pointer;color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent)}
|
|
65
|
-
.rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
|
|
66
|
-
.rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
|
|
67
|
-
.rg-switch{position:relative;flex:none;width:40px;height:24px;padding:0;border:none;border-radius:12px;cursor:pointer;background:var(--dsw-alias-bg-module-platform);transition:background .15s ease}
|
|
68
|
-
.rg-switch::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .15s ease}
|
|
69
|
-
.rg-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}
|
|
70
|
-
.rg-switch[aria-checked="true"]::after{transform:translateX(16px)}
|
|
71
|
-
.rg-count{display:inline-flex;align-items:center;justify-content:center;min-width:72px;height:36px;padding:0 14px;box-sizing:border-box;border-radius:18px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-variant-numeric:tabular-nums}
|
|
72
|
-
.rg-list{display:flex;flex-direction:column;gap:2px;width:100%;max-height:300px;overflow-y:auto;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);box-sizing:border-box}
|
|
73
|
-
.rg-item{display:flex;align-items:center;gap:8px;padding:5px 6px 5px 12px;border-radius:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}
|
|
74
|
-
.rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
75
|
-
.rg-itemText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
76
|
-
.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)}
|
|
77
|
-
.rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
|
|
78
|
-
.rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
|
|
79
|
-
.rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
|
|
80
|
-
.rg-input{flex:1;min-width:0;${FIELD}}
|
|
81
|
-
.rg-summaryInput{flex:none;width:260px;${FIELD}}
|
|
82
|
-
.rg-textarea{width:100%;min-height:84px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px;resize:vertical}
|
|
83
|
-
.rg-input:focus,.rg-summaryInput:focus,.rg-textarea:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
|
|
84
|
-
.rg-button{cursor:pointer;flex:none;height:32px;padding:0 14px;border:none;border-radius:16px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
|
|
85
|
-
.rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
|
|
86
|
-
`;
|
|
87
|
-
|
|
88
|
-
const STYLE_TAG = 'dsh-repeat-guard/panel.css';
|
|
89
|
-
|
|
90
|
-
/** 注入一次本插件的样式;重复挂载不重复插。 */
|
|
91
|
-
function injectStyle(): void {
|
|
92
|
-
if (typeof document === 'undefined' || document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const tag = document.createElement('style');
|
|
96
|
-
tag.dataset.plugin = 'dsh-repeat-guard';
|
|
97
|
-
tag.dataset.pluginCss = STYLE_TAG;
|
|
98
|
-
tag.textContent = CSS;
|
|
99
|
-
document.head.appendChild(tag);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
interface StepperProps {
|
|
103
|
-
value: number;
|
|
104
|
-
min: number;
|
|
105
|
-
max: number;
|
|
106
|
-
unit: string;
|
|
107
|
-
label: string;
|
|
108
|
-
onChange: (next: number) => void;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** 数字步进器:数值居中,悬停或聚焦时右侧浮出上下箭头。 */
|
|
112
|
-
function Stepper({ value, min, max, unit, label, onChange }: StepperProps): ReactElement {
|
|
113
|
-
return (
|
|
114
|
-
<span className="rg-control">
|
|
115
|
-
<span className="rg-stepper">
|
|
116
|
-
<span className="rg-stepperValue">{value}</span>
|
|
117
|
-
<span className="rg-arrows">
|
|
118
|
-
<button
|
|
119
|
-
type="button"
|
|
120
|
-
className="rg-arrow"
|
|
121
|
-
aria-label={`增大${label}`}
|
|
122
|
-
disabled={value >= max}
|
|
123
|
-
onClick={() => {
|
|
124
|
-
onChange(value + 1);
|
|
125
|
-
}}
|
|
126
|
-
>
|
|
127
|
-
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
128
|
-
<path
|
|
129
|
-
d="M1.4 6.2 4.5 3.1l3.1 3.1"
|
|
130
|
-
fill="none"
|
|
131
|
-
stroke="currentColor"
|
|
132
|
-
strokeWidth="1.4"
|
|
133
|
-
strokeLinecap="round"
|
|
134
|
-
strokeLinejoin="round"
|
|
135
|
-
/>
|
|
136
|
-
</svg>
|
|
137
|
-
</button>
|
|
138
|
-
<button
|
|
139
|
-
type="button"
|
|
140
|
-
className="rg-arrow"
|
|
141
|
-
aria-label={`减小${label}`}
|
|
142
|
-
disabled={value <= min}
|
|
143
|
-
onClick={() => {
|
|
144
|
-
onChange(value - 1);
|
|
145
|
-
}}
|
|
146
|
-
>
|
|
147
|
-
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
148
|
-
<path
|
|
149
|
-
d="M1.4 2.8 4.5 5.9l3.1-3.1"
|
|
150
|
-
fill="none"
|
|
151
|
-
stroke="currentColor"
|
|
152
|
-
strokeWidth="1.4"
|
|
153
|
-
strokeLinecap="round"
|
|
154
|
-
strokeLinejoin="round"
|
|
155
|
-
/>
|
|
156
|
-
</svg>
|
|
157
|
-
</button>
|
|
158
|
-
</span>
|
|
159
|
-
</span>
|
|
160
|
-
<span className="rg-stepperUnit">{unit}</span>
|
|
161
|
-
</span>
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
interface SwitchProps {
|
|
166
|
-
checked: boolean;
|
|
167
|
-
label: string;
|
|
168
|
-
onChange: (next: boolean) => void;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** 开关。 */
|
|
172
|
-
function Switch({ checked, label, onChange }: SwitchProps): ReactElement {
|
|
173
|
-
return (
|
|
174
|
-
<button
|
|
175
|
-
type="button"
|
|
176
|
-
role="switch"
|
|
177
|
-
aria-checked={checked}
|
|
178
|
-
aria-label={label}
|
|
179
|
-
className="rg-switch"
|
|
180
|
-
onClick={() => {
|
|
181
|
-
onChange(!checked);
|
|
182
|
-
}}
|
|
183
|
-
/>
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
interface FragmentListProps {
|
|
188
|
-
items: readonly string[];
|
|
189
|
-
draft: string;
|
|
190
|
-
onDraft: (next: string) => void;
|
|
191
|
-
onAdd: () => void;
|
|
192
|
-
onRemove: (index: number) => void;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** 短句表:可滚动的列表,逐条删除,底部一行新增。 */
|
|
196
|
-
function FragmentList({ items, draft, onDraft, onAdd, onRemove }: FragmentListProps): ReactElement {
|
|
197
|
-
function handleKey(event: KeyboardEvent<HTMLInputElement>): void {
|
|
198
|
-
if (event.key === 'Enter') {
|
|
199
|
-
event.preventDefault();
|
|
200
|
-
onAdd();
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return (
|
|
205
|
-
<>
|
|
206
|
-
<div className="rg-list">
|
|
207
|
-
{items.length === 0 ? <div className="rg-empty">表是空的,不会拦截任何短句。</div> : null}
|
|
208
|
-
{items.map((item, index) => (
|
|
209
|
-
<div className="rg-item" key={`${item}#${String(index)}`}>
|
|
210
|
-
<span className="rg-itemText">{item}</span>
|
|
211
|
-
<button
|
|
212
|
-
type="button"
|
|
213
|
-
className="rg-remove"
|
|
214
|
-
aria-label={`删除 ${item}`}
|
|
215
|
-
onClick={() => {
|
|
216
|
-
onRemove(index);
|
|
217
|
-
}}
|
|
218
|
-
>
|
|
219
|
-
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
220
|
-
<path
|
|
221
|
-
d="M3 3l6 6M9 3l-6 6"
|
|
222
|
-
fill="none"
|
|
223
|
-
stroke="currentColor"
|
|
224
|
-
strokeWidth="1.4"
|
|
225
|
-
strokeLinecap="round"
|
|
226
|
-
/>
|
|
227
|
-
</svg>
|
|
228
|
-
</button>
|
|
229
|
-
</div>
|
|
230
|
-
))}
|
|
231
|
-
</div>
|
|
232
|
-
<div className="rg-addRow">
|
|
233
|
-
<input
|
|
234
|
-
className="rg-input"
|
|
235
|
-
value={draft}
|
|
236
|
-
placeholder="新增短句,连标点一起写,回车添加"
|
|
237
|
-
onChange={(event) => {
|
|
238
|
-
onDraft(event.target.value);
|
|
239
|
-
}}
|
|
240
|
-
onKeyDown={handleKey}
|
|
241
|
-
/>
|
|
242
|
-
<button type="button" className="rg-button" onClick={onAdd}>
|
|
243
|
-
添加
|
|
244
|
-
</button>
|
|
245
|
-
</div>
|
|
246
|
-
</>
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
interface FormProps {
|
|
251
|
-
initial: RepeatGuardSettings;
|
|
252
|
-
/** 宿主侧实时计数;由外层订阅刷新,不参与本地编辑。 */
|
|
253
|
-
count: number;
|
|
254
|
-
onReset: () => Promise<void>;
|
|
255
|
-
onSave: (next: RepeatGuardSettings) => Promise<void>;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** 设置表单:标题行 + 五行设置;改动先留在本地,点保存才写。 */
|
|
259
|
-
function Form({ initial, count, onReset, onSave }: FormProps): ReactElement {
|
|
260
|
-
const [items, setItems] = useState<readonly string[]>(initial.fragments);
|
|
261
|
-
const [limit, setLimit] = useState(initial.threshold);
|
|
262
|
-
const [inlineRepeat, setInlineRepeat] = useState(initial.inlineRepeat);
|
|
263
|
-
const [resumeText, setResumeText] = useState(initial.resumeText);
|
|
264
|
-
const [resumeSummary, setResumeSummary] = useState(initial.resumeSummary);
|
|
265
|
-
const [draft, setDraft] = useState('');
|
|
266
|
-
const [note, setNote] = useState('');
|
|
267
|
-
|
|
268
|
-
function add(): void {
|
|
269
|
-
const value = draft.trim();
|
|
270
|
-
if (value === '') {
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
setItems([...items, value]);
|
|
274
|
-
setDraft('');
|
|
275
|
-
setNote('');
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function remove(index: number): void {
|
|
279
|
-
setItems(items.filter((_, at) => at !== index));
|
|
280
|
-
setNote('');
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function save(): void {
|
|
284
|
-
setNote('保存中…');
|
|
285
|
-
onSave({
|
|
286
|
-
fragments: [...items],
|
|
287
|
-
threshold: limit,
|
|
288
|
-
inlineRepeat,
|
|
289
|
-
resumeText,
|
|
290
|
-
resumeSummary,
|
|
291
|
-
count,
|
|
292
|
-
})
|
|
293
|
-
.then(() => {
|
|
294
|
-
setNote('已保存');
|
|
295
|
-
})
|
|
296
|
-
.catch((error: unknown) => {
|
|
297
|
-
setNote(`保存失败:${String(error)}`);
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function reset(): void {
|
|
302
|
-
onReset()
|
|
303
|
-
.then(() => {
|
|
304
|
-
setNote('计数已清零');
|
|
305
|
-
})
|
|
306
|
-
.catch((error: unknown) => {
|
|
307
|
-
setNote(`清零失败:${String(error)}`);
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return (
|
|
312
|
-
<div className="rg-root">
|
|
313
|
-
<div className="rg-head">
|
|
314
|
-
<span className="rg-headTitle">复读打断</span>
|
|
315
|
-
<span className="rg-headNote">{note}</span>
|
|
316
|
-
<button type="button" className="rg-save" onClick={save}>
|
|
317
|
-
保存
|
|
318
|
-
</button>
|
|
319
|
-
</div>
|
|
320
|
-
<div className="rg-row">
|
|
321
|
-
<div className="rg-rowText">
|
|
322
|
-
<div className="rg-rowTitle">累计拦截</div>
|
|
323
|
-
<div className="rg-rowDesc">重复打断计数</div>
|
|
324
|
-
</div>
|
|
325
|
-
<span className="rg-control">
|
|
326
|
-
<span className="rg-count">{count} 次</span>
|
|
327
|
-
<button type="button" className="rg-button" onClick={reset}>
|
|
328
|
-
清零
|
|
329
|
-
</button>
|
|
330
|
-
</span>
|
|
331
|
-
</div>
|
|
332
|
-
<div className="rg-row">
|
|
333
|
-
<div className="rg-rowText">
|
|
334
|
-
<div className="rg-rowTitle">连续命中次数</div>
|
|
335
|
-
<div className="rg-rowDesc">循环次数上限;填 1 表示发现即拦</div>
|
|
336
|
-
</div>
|
|
337
|
-
<Stepper
|
|
338
|
-
value={limit}
|
|
339
|
-
min={THRESHOLD_MIN}
|
|
340
|
-
max={THRESHOLD_MAX}
|
|
341
|
-
unit="次"
|
|
342
|
-
label="连续命中次数"
|
|
343
|
-
onChange={(next) => {
|
|
344
|
-
setLimit(next);
|
|
345
|
-
setNote('');
|
|
346
|
-
}}
|
|
347
|
-
/>
|
|
348
|
-
</div>
|
|
349
|
-
<div className="rg-row">
|
|
350
|
-
<div className="rg-rowText">
|
|
351
|
-
<div className="rg-rowTitle">行内重复检测</div>
|
|
352
|
-
<div className="rg-rowDesc">
|
|
353
|
-
拦截类似"好。好。好。"的行内循环;关闭时只检测独立段落。
|
|
354
|
-
</div>
|
|
355
|
-
</div>
|
|
356
|
-
<Switch
|
|
357
|
-
checked={inlineRepeat}
|
|
358
|
-
label="行内重复检测"
|
|
359
|
-
onChange={(next) => {
|
|
360
|
-
setInlineRepeat(next);
|
|
361
|
-
setNote('');
|
|
362
|
-
}}
|
|
363
|
-
/>
|
|
364
|
-
</div>
|
|
365
|
-
<div className="rg-row rg-row--stack">
|
|
366
|
-
<div className="rg-rowText">
|
|
367
|
-
<div className="rg-rowTitle">拦截短句</div>
|
|
368
|
-
<div className="rg-rowDesc">标点不可省略</div>
|
|
369
|
-
</div>
|
|
370
|
-
<FragmentList
|
|
371
|
-
items={items}
|
|
372
|
-
draft={draft}
|
|
373
|
-
onDraft={setDraft}
|
|
374
|
-
onAdd={add}
|
|
375
|
-
onRemove={remove}
|
|
376
|
-
/>
|
|
377
|
-
</div>
|
|
378
|
-
<div className="rg-row rg-row--stack">
|
|
379
|
-
<div className="rg-rowText">
|
|
380
|
-
<div className="rg-rowTitle">拦截提示词</div>
|
|
381
|
-
<div className="rg-rowDesc">截断后上下文注入的提示词</div>
|
|
382
|
-
</div>
|
|
383
|
-
<textarea
|
|
384
|
-
className="rg-textarea"
|
|
385
|
-
value={resumeText}
|
|
386
|
-
onChange={(event) => {
|
|
387
|
-
setResumeText(event.target.value);
|
|
388
|
-
setNote('');
|
|
389
|
-
}}
|
|
390
|
-
/>
|
|
391
|
-
</div>
|
|
392
|
-
<div className="rg-row">
|
|
393
|
-
<div className="rg-rowText">
|
|
394
|
-
<div className="rg-rowTitle">拦截摘要</div>
|
|
395
|
-
<div className="rg-rowDesc">上下文注入的简介信息,仅用户可见</div>
|
|
396
|
-
</div>
|
|
397
|
-
<input
|
|
398
|
-
className="rg-summaryInput"
|
|
399
|
-
value={resumeSummary}
|
|
400
|
-
onChange={(event) => {
|
|
401
|
-
setResumeSummary(event.target.value);
|
|
402
|
-
setNote('');
|
|
403
|
-
}}
|
|
404
|
-
/>
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
|
|
411
|
-
export const inject = ['slots', 'configForms'];
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* 客户端插件入口。
|
|
415
|
-
* @param ctx - 浏览器侧 cordis 上下文。
|
|
416
|
-
*/
|
|
417
|
-
export function apply(ctx: Context): void {
|
|
418
|
-
injectStyle();
|
|
419
|
-
// 0.1.7 起表单按 profile 条目 id 定位;快照与写入接口同形。
|
|
420
|
-
const form = ctx.configForms.get<RepeatGuardSettings>(ENTRY_ID);
|
|
421
|
-
|
|
422
|
-
function Panel(): ReactElement {
|
|
423
|
-
const [snapshot, setSnapshot] = useState(() => form.getSnapshot());
|
|
424
|
-
useEffect(
|
|
425
|
-
() =>
|
|
426
|
-
form.subscribe(() => {
|
|
427
|
-
setSnapshot(form.getSnapshot());
|
|
428
|
-
}),
|
|
429
|
-
[],
|
|
430
|
-
);
|
|
431
|
-
const value = snapshot.value;
|
|
432
|
-
if (snapshot.status !== 'ready' || value === undefined) {
|
|
433
|
-
return <div className="rg-empty">{`配置尚未就绪(${snapshot.status})`}</div>;
|
|
434
|
-
}
|
|
435
|
-
return (
|
|
436
|
-
<Form
|
|
437
|
-
initial={value}
|
|
438
|
-
count={value.count}
|
|
439
|
-
onReset={async () => {
|
|
440
|
-
await form.set('count', 0);
|
|
441
|
-
}}
|
|
442
|
-
onSave={async (next) => {
|
|
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);
|
|
448
|
-
}}
|
|
449
|
-
/>
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
ctx.slots.inject('settings.section', () =>
|
|
454
|
-
ctx.slots.register(
|
|
455
|
-
{ name: 'settings.section', id: 'repeat-guard', order: 100, label: '复读打断' },
|
|
456
|
-
Panel,
|
|
457
|
-
),
|
|
458
|
-
);
|
|
459
|
-
}
|
|
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 与 configForms 两个客户端服务)。
|
|
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
|
+
/** profile 条目 id,必须与宿主侧 config.ts 的 ENTRY_ID 逐字一致。 */
|
|
20
|
+
const ENTRY_ID = 'dsh-repeat-guard';
|
|
21
|
+
|
|
22
|
+
/** 阈值步进器的取值边界。 */
|
|
23
|
+
const THRESHOLD_MIN = 1;
|
|
24
|
+
const THRESHOLD_MAX = 9;
|
|
25
|
+
|
|
26
|
+
/** 宿主侧注册的配置。字段都带 schema 默认值,所以在这里是必有的。 */
|
|
27
|
+
interface RepeatGuardSettings {
|
|
28
|
+
/** 拦截短句表。 */
|
|
29
|
+
fragments: string[];
|
|
30
|
+
/** 连续命中多少次才拦。 */
|
|
31
|
+
threshold: number;
|
|
32
|
+
/** 是否把"整行由多个表项拼成"也算命中。 */
|
|
33
|
+
inlineRepeat: boolean;
|
|
34
|
+
/** 截断后推给模型的指令正文。 */
|
|
35
|
+
resumeText: string;
|
|
36
|
+
/** 注入消息的折叠行摘要。 */
|
|
37
|
+
resumeSummary: string;
|
|
38
|
+
/** 累计拦截次数,由宿主侧维护。 */
|
|
39
|
+
count: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 输入框与文本域的公共外观。 */
|
|
43
|
+
const FIELD = `height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px`;
|
|
44
|
+
|
|
45
|
+
/** 样式。令牌名取自 dsh 前端已发布的设计令牌表。 */
|
|
46
|
+
const CSS = `
|
|
47
|
+
.rg-root{display:flex;flex-direction:column;width:100%}
|
|
48
|
+
.rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
|
|
49
|
+
.rg-headTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:500;line-height:24px}
|
|
50
|
+
.rg-headNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
|
|
51
|
+
.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}
|
|
52
|
+
.rg-save:hover{background:var(--dsw-alias-button-primary-hover)}
|
|
53
|
+
.rg-row{display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
54
|
+
.rg-row--stack{flex-direction:column;align-items:stretch;gap:10px}
|
|
55
|
+
.rg-rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
|
|
56
|
+
.rg-rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
|
|
57
|
+
.rg-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
|
|
58
|
+
.rg-control{display:inline-flex;align-items:center;gap:8px}
|
|
59
|
+
.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)}
|
|
60
|
+
.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}
|
|
61
|
+
.rg-stepperUnit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
|
|
62
|
+
.rg-arrows{position:absolute;right:8px;display:flex;flex-direction:column;gap:2px;opacity:0}
|
|
63
|
+
.rg-stepper:hover .rg-arrows,.rg-stepper:focus-within .rg-arrows{opacity:1}
|
|
64
|
+
.rg-arrow{display:inline-flex;justify-content:center;align-items:center;width:17px;height:12px;padding:0;border:none;border-radius:3px;cursor:pointer;color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent)}
|
|
65
|
+
.rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
|
|
66
|
+
.rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
|
|
67
|
+
.rg-switch{position:relative;flex:none;width:40px;height:24px;padding:0;border:none;border-radius:12px;cursor:pointer;background:var(--dsw-alias-bg-module-platform);transition:background .15s ease}
|
|
68
|
+
.rg-switch::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .15s ease}
|
|
69
|
+
.rg-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary)}
|
|
70
|
+
.rg-switch[aria-checked="true"]::after{transform:translateX(16px)}
|
|
71
|
+
.rg-count{display:inline-flex;align-items:center;justify-content:center;min-width:72px;height:36px;padding:0 14px;box-sizing:border-box;border-radius:18px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-variant-numeric:tabular-nums}
|
|
72
|
+
.rg-list{display:flex;flex-direction:column;gap:2px;width:100%;max-height:300px;overflow-y:auto;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);box-sizing:border-box}
|
|
73
|
+
.rg-item{display:flex;align-items:center;gap:8px;padding:5px 6px 5px 12px;border-radius:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}
|
|
74
|
+
.rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
75
|
+
.rg-itemText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
76
|
+
.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)}
|
|
77
|
+
.rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
|
|
78
|
+
.rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
|
|
79
|
+
.rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
|
|
80
|
+
.rg-input{flex:1;min-width:0;${FIELD}}
|
|
81
|
+
.rg-summaryInput{flex:none;width:260px;${FIELD}}
|
|
82
|
+
.rg-textarea{width:100%;min-height:84px;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px;resize:vertical}
|
|
83
|
+
.rg-input:focus,.rg-summaryInput:focus,.rg-textarea:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
|
|
84
|
+
.rg-button{cursor:pointer;flex:none;height:32px;padding:0 14px;border:none;border-radius:16px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
|
|
85
|
+
.rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const STYLE_TAG = 'dsh-repeat-guard/panel.css';
|
|
89
|
+
|
|
90
|
+
/** 注入一次本插件的样式;重复挂载不重复插。 */
|
|
91
|
+
function injectStyle(): void {
|
|
92
|
+
if (typeof document === 'undefined' || document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const tag = document.createElement('style');
|
|
96
|
+
tag.dataset.plugin = 'dsh-repeat-guard';
|
|
97
|
+
tag.dataset.pluginCss = STYLE_TAG;
|
|
98
|
+
tag.textContent = CSS;
|
|
99
|
+
document.head.appendChild(tag);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface StepperProps {
|
|
103
|
+
value: number;
|
|
104
|
+
min: number;
|
|
105
|
+
max: number;
|
|
106
|
+
unit: string;
|
|
107
|
+
label: string;
|
|
108
|
+
onChange: (next: number) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 数字步进器:数值居中,悬停或聚焦时右侧浮出上下箭头。 */
|
|
112
|
+
function Stepper({ value, min, max, unit, label, onChange }: StepperProps): ReactElement {
|
|
113
|
+
return (
|
|
114
|
+
<span className="rg-control">
|
|
115
|
+
<span className="rg-stepper">
|
|
116
|
+
<span className="rg-stepperValue">{value}</span>
|
|
117
|
+
<span className="rg-arrows">
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className="rg-arrow"
|
|
121
|
+
aria-label={`增大${label}`}
|
|
122
|
+
disabled={value >= max}
|
|
123
|
+
onClick={() => {
|
|
124
|
+
onChange(value + 1);
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
128
|
+
<path
|
|
129
|
+
d="M1.4 6.2 4.5 3.1l3.1 3.1"
|
|
130
|
+
fill="none"
|
|
131
|
+
stroke="currentColor"
|
|
132
|
+
strokeWidth="1.4"
|
|
133
|
+
strokeLinecap="round"
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
/>
|
|
136
|
+
</svg>
|
|
137
|
+
</button>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className="rg-arrow"
|
|
141
|
+
aria-label={`减小${label}`}
|
|
142
|
+
disabled={value <= min}
|
|
143
|
+
onClick={() => {
|
|
144
|
+
onChange(value - 1);
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
148
|
+
<path
|
|
149
|
+
d="M1.4 2.8 4.5 5.9l3.1-3.1"
|
|
150
|
+
fill="none"
|
|
151
|
+
stroke="currentColor"
|
|
152
|
+
strokeWidth="1.4"
|
|
153
|
+
strokeLinecap="round"
|
|
154
|
+
strokeLinejoin="round"
|
|
155
|
+
/>
|
|
156
|
+
</svg>
|
|
157
|
+
</button>
|
|
158
|
+
</span>
|
|
159
|
+
</span>
|
|
160
|
+
<span className="rg-stepperUnit">{unit}</span>
|
|
161
|
+
</span>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface SwitchProps {
|
|
166
|
+
checked: boolean;
|
|
167
|
+
label: string;
|
|
168
|
+
onChange: (next: boolean) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 开关。 */
|
|
172
|
+
function Switch({ checked, label, onChange }: SwitchProps): ReactElement {
|
|
173
|
+
return (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
role="switch"
|
|
177
|
+
aria-checked={checked}
|
|
178
|
+
aria-label={label}
|
|
179
|
+
className="rg-switch"
|
|
180
|
+
onClick={() => {
|
|
181
|
+
onChange(!checked);
|
|
182
|
+
}}
|
|
183
|
+
/>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface FragmentListProps {
|
|
188
|
+
items: readonly string[];
|
|
189
|
+
draft: string;
|
|
190
|
+
onDraft: (next: string) => void;
|
|
191
|
+
onAdd: () => void;
|
|
192
|
+
onRemove: (index: number) => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 短句表:可滚动的列表,逐条删除,底部一行新增。 */
|
|
196
|
+
function FragmentList({ items, draft, onDraft, onAdd, onRemove }: FragmentListProps): ReactElement {
|
|
197
|
+
function handleKey(event: KeyboardEvent<HTMLInputElement>): void {
|
|
198
|
+
if (event.key === 'Enter') {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
onAdd();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
<div className="rg-list">
|
|
207
|
+
{items.length === 0 ? <div className="rg-empty">表是空的,不会拦截任何短句。</div> : null}
|
|
208
|
+
{items.map((item, index) => (
|
|
209
|
+
<div className="rg-item" key={`${item}#${String(index)}`}>
|
|
210
|
+
<span className="rg-itemText">{item}</span>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
className="rg-remove"
|
|
214
|
+
aria-label={`删除 ${item}`}
|
|
215
|
+
onClick={() => {
|
|
216
|
+
onRemove(index);
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
220
|
+
<path
|
|
221
|
+
d="M3 3l6 6M9 3l-6 6"
|
|
222
|
+
fill="none"
|
|
223
|
+
stroke="currentColor"
|
|
224
|
+
strokeWidth="1.4"
|
|
225
|
+
strokeLinecap="round"
|
|
226
|
+
/>
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="rg-addRow">
|
|
233
|
+
<input
|
|
234
|
+
className="rg-input"
|
|
235
|
+
value={draft}
|
|
236
|
+
placeholder="新增短句,连标点一起写,回车添加"
|
|
237
|
+
onChange={(event) => {
|
|
238
|
+
onDraft(event.target.value);
|
|
239
|
+
}}
|
|
240
|
+
onKeyDown={handleKey}
|
|
241
|
+
/>
|
|
242
|
+
<button type="button" className="rg-button" onClick={onAdd}>
|
|
243
|
+
添加
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
</>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface FormProps {
|
|
251
|
+
initial: RepeatGuardSettings;
|
|
252
|
+
/** 宿主侧实时计数;由外层订阅刷新,不参与本地编辑。 */
|
|
253
|
+
count: number;
|
|
254
|
+
onReset: () => Promise<void>;
|
|
255
|
+
onSave: (next: RepeatGuardSettings) => Promise<void>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 设置表单:标题行 + 五行设置;改动先留在本地,点保存才写。 */
|
|
259
|
+
function Form({ initial, count, onReset, onSave }: FormProps): ReactElement {
|
|
260
|
+
const [items, setItems] = useState<readonly string[]>(initial.fragments);
|
|
261
|
+
const [limit, setLimit] = useState(initial.threshold);
|
|
262
|
+
const [inlineRepeat, setInlineRepeat] = useState(initial.inlineRepeat);
|
|
263
|
+
const [resumeText, setResumeText] = useState(initial.resumeText);
|
|
264
|
+
const [resumeSummary, setResumeSummary] = useState(initial.resumeSummary);
|
|
265
|
+
const [draft, setDraft] = useState('');
|
|
266
|
+
const [note, setNote] = useState('');
|
|
267
|
+
|
|
268
|
+
function add(): void {
|
|
269
|
+
const value = draft.trim();
|
|
270
|
+
if (value === '') {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
setItems([...items, value]);
|
|
274
|
+
setDraft('');
|
|
275
|
+
setNote('');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function remove(index: number): void {
|
|
279
|
+
setItems(items.filter((_, at) => at !== index));
|
|
280
|
+
setNote('');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function save(): void {
|
|
284
|
+
setNote('保存中…');
|
|
285
|
+
onSave({
|
|
286
|
+
fragments: [...items],
|
|
287
|
+
threshold: limit,
|
|
288
|
+
inlineRepeat,
|
|
289
|
+
resumeText,
|
|
290
|
+
resumeSummary,
|
|
291
|
+
count,
|
|
292
|
+
})
|
|
293
|
+
.then(() => {
|
|
294
|
+
setNote('已保存');
|
|
295
|
+
})
|
|
296
|
+
.catch((error: unknown) => {
|
|
297
|
+
setNote(`保存失败:${String(error)}`);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function reset(): void {
|
|
302
|
+
onReset()
|
|
303
|
+
.then(() => {
|
|
304
|
+
setNote('计数已清零');
|
|
305
|
+
})
|
|
306
|
+
.catch((error: unknown) => {
|
|
307
|
+
setNote(`清零失败:${String(error)}`);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div className="rg-root">
|
|
313
|
+
<div className="rg-head">
|
|
314
|
+
<span className="rg-headTitle">复读打断</span>
|
|
315
|
+
<span className="rg-headNote">{note}</span>
|
|
316
|
+
<button type="button" className="rg-save" onClick={save}>
|
|
317
|
+
保存
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
<div className="rg-row">
|
|
321
|
+
<div className="rg-rowText">
|
|
322
|
+
<div className="rg-rowTitle">累计拦截</div>
|
|
323
|
+
<div className="rg-rowDesc">重复打断计数</div>
|
|
324
|
+
</div>
|
|
325
|
+
<span className="rg-control">
|
|
326
|
+
<span className="rg-count">{count} 次</span>
|
|
327
|
+
<button type="button" className="rg-button" onClick={reset}>
|
|
328
|
+
清零
|
|
329
|
+
</button>
|
|
330
|
+
</span>
|
|
331
|
+
</div>
|
|
332
|
+
<div className="rg-row">
|
|
333
|
+
<div className="rg-rowText">
|
|
334
|
+
<div className="rg-rowTitle">连续命中次数</div>
|
|
335
|
+
<div className="rg-rowDesc">循环次数上限;填 1 表示发现即拦</div>
|
|
336
|
+
</div>
|
|
337
|
+
<Stepper
|
|
338
|
+
value={limit}
|
|
339
|
+
min={THRESHOLD_MIN}
|
|
340
|
+
max={THRESHOLD_MAX}
|
|
341
|
+
unit="次"
|
|
342
|
+
label="连续命中次数"
|
|
343
|
+
onChange={(next) => {
|
|
344
|
+
setLimit(next);
|
|
345
|
+
setNote('');
|
|
346
|
+
}}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
<div className="rg-row">
|
|
350
|
+
<div className="rg-rowText">
|
|
351
|
+
<div className="rg-rowTitle">行内重复检测</div>
|
|
352
|
+
<div className="rg-rowDesc">
|
|
353
|
+
拦截类似"好。好。好。"的行内循环;关闭时只检测独立段落。
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
<Switch
|
|
357
|
+
checked={inlineRepeat}
|
|
358
|
+
label="行内重复检测"
|
|
359
|
+
onChange={(next) => {
|
|
360
|
+
setInlineRepeat(next);
|
|
361
|
+
setNote('');
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
<div className="rg-row rg-row--stack">
|
|
366
|
+
<div className="rg-rowText">
|
|
367
|
+
<div className="rg-rowTitle">拦截短句</div>
|
|
368
|
+
<div className="rg-rowDesc">标点不可省略</div>
|
|
369
|
+
</div>
|
|
370
|
+
<FragmentList
|
|
371
|
+
items={items}
|
|
372
|
+
draft={draft}
|
|
373
|
+
onDraft={setDraft}
|
|
374
|
+
onAdd={add}
|
|
375
|
+
onRemove={remove}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
<div className="rg-row rg-row--stack">
|
|
379
|
+
<div className="rg-rowText">
|
|
380
|
+
<div className="rg-rowTitle">拦截提示词</div>
|
|
381
|
+
<div className="rg-rowDesc">截断后上下文注入的提示词</div>
|
|
382
|
+
</div>
|
|
383
|
+
<textarea
|
|
384
|
+
className="rg-textarea"
|
|
385
|
+
value={resumeText}
|
|
386
|
+
onChange={(event) => {
|
|
387
|
+
setResumeText(event.target.value);
|
|
388
|
+
setNote('');
|
|
389
|
+
}}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="rg-row">
|
|
393
|
+
<div className="rg-rowText">
|
|
394
|
+
<div className="rg-rowTitle">拦截摘要</div>
|
|
395
|
+
<div className="rg-rowDesc">上下文注入的简介信息,仅用户可见</div>
|
|
396
|
+
</div>
|
|
397
|
+
<input
|
|
398
|
+
className="rg-summaryInput"
|
|
399
|
+
value={resumeSummary}
|
|
400
|
+
onChange={(event) => {
|
|
401
|
+
setResumeSummary(event.target.value);
|
|
402
|
+
setNote('');
|
|
403
|
+
}}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
|
|
411
|
+
export const inject = ['slots', 'configForms'];
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 客户端插件入口。
|
|
415
|
+
* @param ctx - 浏览器侧 cordis 上下文。
|
|
416
|
+
*/
|
|
417
|
+
export function apply(ctx: Context): void {
|
|
418
|
+
injectStyle();
|
|
419
|
+
// 0.1.7 起表单按 profile 条目 id 定位;快照与写入接口同形。
|
|
420
|
+
const form = ctx.configForms.get<RepeatGuardSettings>(ENTRY_ID);
|
|
421
|
+
|
|
422
|
+
function Panel(): ReactElement {
|
|
423
|
+
const [snapshot, setSnapshot] = useState(() => form.getSnapshot());
|
|
424
|
+
useEffect(
|
|
425
|
+
() =>
|
|
426
|
+
form.subscribe(() => {
|
|
427
|
+
setSnapshot(form.getSnapshot());
|
|
428
|
+
}),
|
|
429
|
+
[],
|
|
430
|
+
);
|
|
431
|
+
const value = snapshot.value;
|
|
432
|
+
if (snapshot.status !== 'ready' || value === undefined) {
|
|
433
|
+
return <div className="rg-empty">{`配置尚未就绪(${snapshot.status})`}</div>;
|
|
434
|
+
}
|
|
435
|
+
return (
|
|
436
|
+
<Form
|
|
437
|
+
initial={value}
|
|
438
|
+
count={value.count}
|
|
439
|
+
onReset={async () => {
|
|
440
|
+
await form.set('count', 0);
|
|
441
|
+
}}
|
|
442
|
+
onSave={async (next) => {
|
|
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);
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
ctx.slots.inject('settings.section', () =>
|
|
454
|
+
ctx.slots.register(
|
|
455
|
+
{ name: 'settings.section', id: 'repeat-guard', order: 100, label: '复读打断' },
|
|
456
|
+
Panel,
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
}
|