dsh-repeat-guard 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,235 @@ 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 CSS = `
40
+ .rg-root{display:flex;flex-direction:column;width:100%}
41
+ .rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
42
+ .rg-headTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:500;line-height:24px}
43
+ .rg-headNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
44
+ .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}
45
+ .rg-save:hover{background:var(--dsw-alias-button-primary-hover)}
46
+ .rg-row{display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
47
+ .rg-row--stack{flex-direction:column;align-items:stretch;gap:10px}
48
+ .rg-rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
49
+ .rg-rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
50
+ .rg-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
51
+ .rg-control{display:inline-flex;align-items:center;gap:8px}
52
+ .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)}
53
+ .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}
54
+ .rg-stepperUnit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
55
+ .rg-arrows{position:absolute;right:8px;display:flex;flex-direction:column;gap:2px;opacity:0}
56
+ .rg-stepper:hover .rg-arrows,.rg-stepper:focus-within .rg-arrows{opacity:1}
57
+ .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)}
58
+ .rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
59
+ .rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
60
+ .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}
61
+ .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)}
62
+ .rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
63
+ .rg-itemText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
64
+ .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)}
65
+ .rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
66
+ .rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
67
+ .rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
68
+ .rg-input{flex:1;min-width:0;height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
69
+ .rg-input:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
70
+ .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}
71
+ .rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
72
+ `;
73
+ var STYLE_TAG = "dsh-repeat-guard/panel.css";
74
+ function injectStyle() {
75
+ if (typeof document === "undefined" || document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) {
76
+ return;
77
+ }
78
+ const tag = document.createElement("style");
79
+ tag.dataset.plugin = "dsh-repeat-guard";
80
+ tag.dataset.pluginCss = STYLE_TAG;
81
+ tag.textContent = CSS;
82
+ document.head.appendChild(tag);
83
+ }
84
+ function Stepper({ value, min, max, unit, label, onChange }) {
85
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-control", children: [
86
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-stepper", children: [
87
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-stepperValue", children: value }),
88
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "rg-arrows", children: [
57
89
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
58
- "input",
90
+ "button",
59
91
  {
60
- type: "number",
61
- value: threshold,
62
- onChange: (event) => {
63
- setThreshold(event.target.value);
64
- }
92
+ type: "button",
93
+ className: "rg-arrow",
94
+ "aria-label": `\u589E\u5927${label}`,
95
+ disabled: value >= max,
96
+ onClick: () => {
97
+ onChange(value + 1);
98
+ },
99
+ 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)(
100
+ "path",
101
+ {
102
+ d: "M1.4 6.2 4.5 3.1l3.1 3.1",
103
+ fill: "none",
104
+ stroke: "currentColor",
105
+ strokeWidth: "1.4",
106
+ strokeLinecap: "round",
107
+ strokeLinejoin: "round"
108
+ }
109
+ ) })
65
110
  }
66
111
  ),
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
112
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
72
- "textarea",
113
+ "button",
73
114
  {
74
- rows: 16,
75
- style: { width: "100%", fontFamily: "monospace" },
76
- value: fragments,
77
- onChange: (event) => {
78
- setFragments(event.target.value);
79
- }
115
+ type: "button",
116
+ className: "rg-arrow",
117
+ "aria-label": `\u51CF\u5C0F${label}`,
118
+ disabled: value <= min,
119
+ onClick: () => {
120
+ onChange(value - 1);
121
+ },
122
+ 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)(
123
+ "path",
124
+ {
125
+ d: "M1.4 2.8 4.5 5.9l3.1-3.1",
126
+ fill: "none",
127
+ stroke: "currentColor",
128
+ strokeWidth: "1.4",
129
+ strokeLinecap: "round",
130
+ strokeLinejoin: "round"
131
+ }
132
+ ) })
80
133
  }
81
134
  )
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
135
  ] })
87
- ] });
136
+ ] }),
137
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-stepperUnit", children: unit })
138
+ ] });
139
+ }
140
+ function FragmentList({ items, draft, onDraft, onAdd, onRemove }) {
141
+ function handleKey(event) {
142
+ if (event.key === "Enter") {
143
+ event.preventDefault();
144
+ onAdd();
145
+ }
88
146
  }
147
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
148
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-list", children: [
149
+ 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,
150
+ items.map((item, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-item", children: [
151
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-itemText", children: item }),
152
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
153
+ "button",
154
+ {
155
+ type: "button",
156
+ className: "rg-remove",
157
+ "aria-label": `\u5220\u9664 ${item}`,
158
+ onClick: () => {
159
+ onRemove(index);
160
+ },
161
+ 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)(
162
+ "path",
163
+ {
164
+ d: "M3 3l6 6M9 3l-6 6",
165
+ fill: "none",
166
+ stroke: "currentColor",
167
+ strokeWidth: "1.4",
168
+ strokeLinecap: "round"
169
+ }
170
+ ) })
171
+ }
172
+ )
173
+ ] }, `${item}#${String(index)}`))
174
+ ] }),
175
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-addRow", children: [
176
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
177
+ "input",
178
+ {
179
+ className: "rg-input",
180
+ value: draft,
181
+ placeholder: "\u65B0\u589E\u77ED\u53E5\uFF0C\u8FDE\u6807\u70B9\u4E00\u8D77\u5199\uFF0C\u56DE\u8F66\u6DFB\u52A0",
182
+ onChange: (event) => {
183
+ onDraft(event.target.value);
184
+ },
185
+ onKeyDown: handleKey
186
+ }
187
+ ),
188
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-button", onClick: onAdd, children: "\u6DFB\u52A0" })
189
+ ] })
190
+ ] });
191
+ }
192
+ function Form({ initial, onSave }) {
193
+ const [items, setItems] = (0, import_react.useState)(() => initial.fragments ?? []);
194
+ const [count, setCount] = (0, import_react.useState)(() => initial.threshold ?? 1);
195
+ const [draft, setDraft] = (0, import_react.useState)("");
196
+ const [note, setNote] = (0, import_react.useState)("");
197
+ function add() {
198
+ const value = draft.trim();
199
+ if (value === "") {
200
+ return;
201
+ }
202
+ setItems([...items, value]);
203
+ setDraft("");
204
+ setNote("");
205
+ }
206
+ function remove(index) {
207
+ setItems(items.filter((_, at) => at !== index));
208
+ setNote("");
209
+ }
210
+ function save() {
211
+ setNote("\u4FDD\u5B58\u4E2D\u2026");
212
+ onSave(items, count).then(() => {
213
+ setNote("\u5DF2\u4FDD\u5B58");
214
+ }).catch((error) => {
215
+ setNote(`\u4FDD\u5B58\u5931\u8D25\uFF1A${String(error)}`);
216
+ });
217
+ }
218
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-root", children: [
219
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-head", children: [
220
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-headTitle", children: "\u590D\u8BFB\u6253\u65AD" }),
221
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "rg-headNote", children: note }),
222
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "rg-save", onClick: save, children: "\u4FDD\u5B58" })
223
+ ] }),
224
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row", children: [
225
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
226
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570" }),
227
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u8FDE\u7740\u51E0\u4E2A\u77ED\u53E5\u884C\u547D\u4E2D\u624D\u62E6\u622A\uFF1B\u586B 1 \u8868\u793A\u53D1\u73B0\u5373\u62E6" })
228
+ ] }),
229
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
230
+ Stepper,
231
+ {
232
+ value: count,
233
+ min: THRESHOLD_MIN,
234
+ max: THRESHOLD_MAX,
235
+ unit: "\u6B21",
236
+ label: "\u8FDE\u7EED\u547D\u4E2D\u6B21\u6570",
237
+ onChange: (next) => {
238
+ setCount(next);
239
+ setNote("");
240
+ }
241
+ }
242
+ )
243
+ ] }),
244
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-row rg-row--stack", children: [
245
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rg-rowText", children: [
246
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowTitle", children: "\u62E6\u622A\u77ED\u53E5" }),
247
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-rowDesc", children: "\u4E00\u884C\u4E00\u53E5\uFF0C\u8FDE\u6807\u70B9\u4E00\u8D77\u5199\uFF1B\u53EA\u7B97\u72EC\u5360\u4E00\u884C\u7684\u6574\u884C\u5339\u914D" })
248
+ ] }),
249
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
250
+ FragmentList,
251
+ {
252
+ items,
253
+ draft,
254
+ onDraft: setDraft,
255
+ onAdd: add,
256
+ onRemove: remove
257
+ }
258
+ )
259
+ ] })
260
+ ] });
261
+ }
262
+ var inject = ["slots", "settingsScope"];
263
+ function apply(ctx) {
264
+ injectStyle();
265
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
89
266
  function Panel() {
90
267
  const [snapshot, setSnapshot] = (0, import_react.useState)(() => scope.getSnapshot());
91
268
  (0, import_react.useEffect)(
@@ -96,9 +273,18 @@ var __dshClientBundle = (() => {
96
273
  );
97
274
  const value = snapshot.value;
98
275
  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` });
276
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "rg-empty", children: `\u914D\u7F6E\u5C1A\u672A\u5C31\u7EEA\uFF08${snapshot.status}\uFF09` });
100
277
  }
101
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Form, { initial: value });
278
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
279
+ Form,
280
+ {
281
+ initial: value,
282
+ onSave: async (fragments, threshold) => {
283
+ await scope.set("fragments", [...fragments]);
284
+ await scope.set("threshold", threshold);
285
+ }
286
+ }
287
+ );
102
288
  }
103
289
  ctx.slots.inject(
104
290
  "settings.section",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-repeat-guard",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "流式输出退化拦截:检测碎片复读,掐断本次生成并提醒模型直接执行工具调用",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/client.tsx 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 输出。
@@ -13,11 +14,15 @@ import type { Context } from '@deepseek-ai/cordis';
13
14
  // slots 与 settingsScope 两个客户端服务)。
14
15
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
15
16
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
16
- import { useEffect, useState, type ReactElement } from 'react';
17
+ import { useEffect, useState, type KeyboardEvent, type ReactElement } from 'react';
17
18
 
18
19
  /** settings 命名空间,必须与宿主侧 config.ts 的 SETTINGS_NS 逐字一致。 */
19
20
  const SETTINGS_NS = 'repeat-guard';
20
21
 
22
+ /** 阈值步进器的取值边界。 */
23
+ const THRESHOLD_MIN = 1;
24
+ const THRESHOLD_MAX = 9;
25
+
21
26
  /** 本插件配置节选。 */
22
27
  interface RepeatGuardSettings {
23
28
  /** 拦截短句表。 */
@@ -26,6 +31,263 @@ interface RepeatGuardSettings {
26
31
  threshold?: number;
27
32
  }
28
33
 
34
+ /** 样式。令牌名取自 dsh 前端已发布的设计令牌表。 */
35
+ const CSS = `
36
+ .rg-root{display:flex;flex-direction:column;width:100%}
37
+ .rg-head{display:flex;align-items:center;gap:12px;padding:2px 0 14px}
38
+ .rg-headTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:500;line-height:24px}
39
+ .rg-headNote{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
40
+ .rg-save{cursor:pointer;flex:none;height:32px;padding:0 16px;border:none;border-radius:16px;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-family:inherit;font-size:14px;line-height:22px}
41
+ .rg-save:hover{background:var(--dsw-alias-button-primary-hover)}
42
+ .rg-row{display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
43
+ .rg-row--stack{flex-direction:column;align-items:stretch;gap:10px}
44
+ .rg-rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
45
+ .rg-rowTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
46
+ .rg-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
47
+ .rg-control{display:inline-flex;align-items:center;gap:8px}
48
+ .rg-stepper{position:relative;display:inline-flex;justify-content:center;align-items:center;min-width:72px;height:36px;border-radius:18px;background:var(--dsw-alias-bg-module-platform)}
49
+ .rg-stepperValue{min-width:18px;text-align:center;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
50
+ .rg-stepperUnit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
51
+ .rg-arrows{position:absolute;right:8px;display:flex;flex-direction:column;gap:2px;opacity:0}
52
+ .rg-stepper:hover .rg-arrows,.rg-stepper:focus-within .rg-arrows{opacity:1}
53
+ .rg-arrow{display:inline-flex;justify-content:center;align-items:center;width:17px;height:12px;padding:0;border:none;border-radius:3px;cursor:pointer;color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent)}
54
+ .rg-arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
55
+ .rg-arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
56
+ .rg-list{display:flex;flex-direction:column;gap:2px;width:100%;max-height:300px;overflow-y:auto;padding:4px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);box-sizing:border-box}
57
+ .rg-item{display:flex;align-items:center;gap:8px;padding:5px 6px 5px 12px;border-radius:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary)}
58
+ .rg-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
59
+ .rg-itemText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
60
+ .rg-remove{flex:none;display:inline-flex;justify-content:center;align-items:center;width:24px;height:24px;padding:0;border:none;border-radius:12px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-tertiary)}
61
+ .rg-remove:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
62
+ .rg-empty{padding:10px 12px;color:var(--dsw-alias-label-caption);font-size:13px;line-height:20px}
63
+ .rg-addRow{display:flex;align-items:center;gap:8px;width:100%}
64
+ .rg-input{flex:1;min-width:0;height:36px;box-sizing:border-box;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
65
+ .rg-input:focus{outline:none;border-color:var(--dsw-alias-border-l4)}
66
+ .rg-button{cursor:pointer;flex:none;height:32px;padding:0 14px;border:none;border-radius:16px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:22px}
67
+ .rg-button:hover{background:var(--dsw-alias-interactive-bg-active)}
68
+ `;
69
+
70
+ const STYLE_TAG = 'dsh-repeat-guard/panel.css';
71
+
72
+ /** 注入一次本插件的样式;重复挂载不重复插。 */
73
+ function injectStyle(): void {
74
+ if (typeof document === 'undefined' || document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) {
75
+ return;
76
+ }
77
+ const tag = document.createElement('style');
78
+ tag.dataset.plugin = 'dsh-repeat-guard';
79
+ tag.dataset.pluginCss = STYLE_TAG;
80
+ tag.textContent = CSS;
81
+ document.head.appendChild(tag);
82
+ }
83
+
84
+ interface StepperProps {
85
+ value: number;
86
+ min: number;
87
+ max: number;
88
+ unit: string;
89
+ label: string;
90
+ onChange: (next: number) => void;
91
+ }
92
+
93
+ /** 数字步进器:数值居中,悬停或聚焦时右侧浮出上下箭头。 */
94
+ function Stepper({ value, min, max, unit, label, onChange }: StepperProps): ReactElement {
95
+ return (
96
+ <span className="rg-control">
97
+ <span className="rg-stepper">
98
+ <span className="rg-stepperValue">{value}</span>
99
+ <span className="rg-arrows">
100
+ <button
101
+ type="button"
102
+ className="rg-arrow"
103
+ aria-label={`增大${label}`}
104
+ disabled={value >= max}
105
+ onClick={() => {
106
+ onChange(value + 1);
107
+ }}
108
+ >
109
+ <svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
110
+ <path
111
+ d="M1.4 6.2 4.5 3.1l3.1 3.1"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ strokeWidth="1.4"
115
+ strokeLinecap="round"
116
+ strokeLinejoin="round"
117
+ />
118
+ </svg>
119
+ </button>
120
+ <button
121
+ type="button"
122
+ className="rg-arrow"
123
+ aria-label={`减小${label}`}
124
+ disabled={value <= min}
125
+ onClick={() => {
126
+ onChange(value - 1);
127
+ }}
128
+ >
129
+ <svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
130
+ <path
131
+ d="M1.4 2.8 4.5 5.9l3.1-3.1"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="1.4"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ />
138
+ </svg>
139
+ </button>
140
+ </span>
141
+ </span>
142
+ <span className="rg-stepperUnit">{unit}</span>
143
+ </span>
144
+ );
145
+ }
146
+
147
+ interface FragmentListProps {
148
+ items: readonly string[];
149
+ draft: string;
150
+ onDraft: (next: string) => void;
151
+ onAdd: () => void;
152
+ onRemove: (index: number) => void;
153
+ }
154
+
155
+ /** 短句表:可滚动的列表,逐条删除,底部一行新增。 */
156
+ function FragmentList({ items, draft, onDraft, onAdd, onRemove }: FragmentListProps): ReactElement {
157
+ function handleKey(event: KeyboardEvent<HTMLInputElement>): void {
158
+ if (event.key === 'Enter') {
159
+ event.preventDefault();
160
+ onAdd();
161
+ }
162
+ }
163
+
164
+ return (
165
+ <>
166
+ <div className="rg-list">
167
+ {items.length === 0 ? <div className="rg-empty">表是空的,不会拦截任何短句。</div> : null}
168
+ {items.map((item, index) => (
169
+ <div className="rg-item" key={`${item}#${String(index)}`}>
170
+ <span className="rg-itemText">{item}</span>
171
+ <button
172
+ type="button"
173
+ className="rg-remove"
174
+ aria-label={`删除 ${item}`}
175
+ onClick={() => {
176
+ onRemove(index);
177
+ }}
178
+ >
179
+ <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
180
+ <path
181
+ d="M3 3l6 6M9 3l-6 6"
182
+ fill="none"
183
+ stroke="currentColor"
184
+ strokeWidth="1.4"
185
+ strokeLinecap="round"
186
+ />
187
+ </svg>
188
+ </button>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ <div className="rg-addRow">
193
+ <input
194
+ className="rg-input"
195
+ value={draft}
196
+ placeholder="新增短句,连标点一起写,回车添加"
197
+ onChange={(event) => {
198
+ onDraft(event.target.value);
199
+ }}
200
+ onKeyDown={handleKey}
201
+ />
202
+ <button type="button" className="rg-button" onClick={onAdd}>
203
+ 添加
204
+ </button>
205
+ </div>
206
+ </>
207
+ );
208
+ }
209
+
210
+ interface FormProps {
211
+ initial: RepeatGuardSettings;
212
+ onSave: (fragments: readonly string[], threshold: number) => Promise<void>;
213
+ }
214
+
215
+ /** 设置表单:标题行 + 阈值行 + 短句表行;改动先留在本地,点保存才写。 */
216
+ function Form({ initial, onSave }: FormProps): ReactElement {
217
+ const [items, setItems] = useState<readonly string[]>(() => initial.fragments ?? []);
218
+ const [count, setCount] = useState(() => initial.threshold ?? 1);
219
+ const [draft, setDraft] = useState('');
220
+ const [note, setNote] = useState('');
221
+
222
+ function add(): void {
223
+ const value = draft.trim();
224
+ if (value === '') {
225
+ return;
226
+ }
227
+ setItems([...items, value]);
228
+ setDraft('');
229
+ setNote('');
230
+ }
231
+
232
+ function remove(index: number): void {
233
+ setItems(items.filter((_, at) => at !== index));
234
+ setNote('');
235
+ }
236
+
237
+ function save(): void {
238
+ setNote('保存中…');
239
+ onSave(items, count)
240
+ .then(() => {
241
+ setNote('已保存');
242
+ })
243
+ .catch((error: unknown) => {
244
+ setNote(`保存失败:${String(error)}`);
245
+ });
246
+ }
247
+
248
+ return (
249
+ <div className="rg-root">
250
+ <div className="rg-head">
251
+ <span className="rg-headTitle">复读打断</span>
252
+ <span className="rg-headNote">{note}</span>
253
+ <button type="button" className="rg-save" onClick={save}>
254
+ 保存
255
+ </button>
256
+ </div>
257
+ <div className="rg-row">
258
+ <div className="rg-rowText">
259
+ <div className="rg-rowTitle">连续命中次数</div>
260
+ <div className="rg-rowDesc">连着几个短句行命中才拦截;填 1 表示发现即拦</div>
261
+ </div>
262
+ <Stepper
263
+ value={count}
264
+ min={THRESHOLD_MIN}
265
+ max={THRESHOLD_MAX}
266
+ unit="次"
267
+ label="连续命中次数"
268
+ onChange={(next) => {
269
+ setCount(next);
270
+ setNote('');
271
+ }}
272
+ />
273
+ </div>
274
+ <div className="rg-row rg-row--stack">
275
+ <div className="rg-rowText">
276
+ <div className="rg-rowTitle">拦截短句</div>
277
+ <div className="rg-rowDesc">一行一句,连标点一起写;只算独占一行的整行匹配</div>
278
+ </div>
279
+ <FragmentList
280
+ items={items}
281
+ draft={draft}
282
+ onDraft={setDraft}
283
+ onAdd={add}
284
+ onRemove={remove}
285
+ />
286
+ </div>
287
+ </div>
288
+ );
289
+ }
290
+
29
291
  /** 注册设置页需要的服务;这两个是 cordis 服务名,不是包名。 */
30
292
  export const inject = ['slots', 'settingsScope'];
31
293
 
@@ -34,65 +296,9 @@ export const inject = ['slots', 'settingsScope'];
34
296
  * @param ctx - 浏览器侧 cordis 上下文。
35
297
  */
36
298
  export function apply(ctx: Context): void {
299
+ injectStyle();
37
300
  const scope = ctx.settingsScope.bind<RepeatGuardSettings>({ namespace: SETTINGS_NS });
38
301
 
39
- function Form({ initial }: { initial: RepeatGuardSettings }): ReactElement {
40
- const [fragments, setFragments] = useState(() => (initial.fragments ?? []).join('\n'));
41
- const [threshold, setThreshold] = useState(() => String(initial.threshold ?? 1));
42
- const [message, setMessage] = useState('');
43
-
44
- function save(): void {
45
- const list = fragments
46
- .split('\n')
47
- .map((line) => line.trim())
48
- .filter((line) => line !== '');
49
- const count = Number(threshold);
50
- setMessage('保存中…');
51
- scope
52
- .set('fragments', list)
53
- .then(() => scope.set('threshold', count))
54
- .then(() => {
55
- setMessage('已保存');
56
- })
57
- .catch((error: unknown) => {
58
- setMessage(`保存失败:${String(error)}`);
59
- });
60
- }
61
-
62
- return (
63
- <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
64
- <label>
65
- <span>连续命中次数</span>
66
- <input
67
- type="number"
68
- value={threshold}
69
- onChange={(event) => {
70
- setThreshold(event.target.value);
71
- }}
72
- />
73
- <span>填 1 表示命中即拦;填 n 表示连着 n 行都命中才拦。</span>
74
- </label>
75
- <label>
76
- <span>拦截短句(一行一句,连标点一起写)</span>
77
- <textarea
78
- rows={16}
79
- style={{ width: '100%', fontFamily: 'monospace' }}
80
- value={fragments}
81
- onChange={(event) => {
82
- setFragments(event.target.value);
83
- }}
84
- />
85
- </label>
86
- <div>
87
- <button type="button" onClick={save}>
88
- 保存
89
- </button>
90
- <span>{message}</span>
91
- </div>
92
- </div>
93
- );
94
- }
95
-
96
302
  function Panel(): ReactElement {
97
303
  const [snapshot, setSnapshot] = useState(() => scope.getSnapshot());
98
304
  useEffect(
@@ -104,9 +310,17 @@ export function apply(ctx: Context): void {
104
310
  );
105
311
  const value = snapshot.value;
106
312
  if (snapshot.status !== 'ready' || value === undefined) {
107
- return <p>{`配置尚未就绪(${snapshot.status})`}</p>;
313
+ return <div className="rg-empty">{`配置尚未就绪(${snapshot.status})`}</div>;
108
314
  }
109
- return <Form initial={value} />;
315
+ return (
316
+ <Form
317
+ initial={value}
318
+ onSave={async (fragments, threshold) => {
319
+ await scope.set('fragments', [...fragments]);
320
+ await scope.set('threshold', threshold);
321
+ }}
322
+ />
323
+ );
110
324
  }
111
325
 
112
326
  ctx.slots.inject('settings.section', () =>