@dsh-plus/notify-email 0.1.6 → 0.1.8

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/index.d.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import "@deepseek-ai/dsh-settings";
3
2
  import { Context, Service } from "@deepseek-ai/cordis";
4
-
5
3
  //#region src/config.d.ts
6
-
7
4
  declare const Config: z<Schemastery.ObjectS<{
8
5
  enabled: z<boolean, boolean>;
9
6
  smtp: z<Schemastery.ObjectS<{
package/lib/index.js CHANGED
@@ -1,26 +1,20 @@
1
- import z from "@deepseek-ai/schemastery";
2
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  import { Context, Service } from "@deepseek-ai/cordis";
4
4
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
5
5
  import { appendFile, mkdir } from "node:fs/promises";
6
6
  import { dirname } from "node:path";
7
7
  import nodemailer from "nodemailer";
8
-
9
- //#region src/ns.ts
10
- /**
11
- * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
12
- * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
13
- * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
14
- * 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
15
- * 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
16
- * @module notify-email/ns
17
- */
18
- const SETTINGS_NS$1 = "dsh-plus-notify-email";
19
-
20
8
  //#endregion
21
9
  //#region src/config.ts
10
+ /**
11
+ * 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
12
+ * 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
13
+ * 共用同一 schemastery schema。SMTP 密码标记 secret 角色,任何读取通道不得回传。
14
+ * @module notify-email/config
15
+ */
22
16
  /** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
23
- const SETTINGS_NS = settingsNamespace(SETTINGS_NS$1);
17
+ const SETTINGS_NS = settingsNamespace("dsh-plus-notify-email");
24
18
  const SmtpSchema = z.object({
25
19
  host: z.string().description("SMTP 服务器主机名").default(""),
26
20
  port: z.natural().max(65535).description("SMTP 端口").default(465),
@@ -58,9 +52,14 @@ const Config = z.object({
58
52
  maxBodyChars: z.natural().min(200).description("邮件正文内容截断长度").default(4e3),
59
53
  dryRun: z.boolean().description("仅记录日志不真实发送(开发调试用)").default(false)
60
54
  });
61
-
62
55
  //#endregion
63
56
  //#region src/audit.ts
57
+ /**
58
+ * 通知投递审计:每次发送尝试追加一行 JSONL 到 $DSH_HOME/logs/notify-email.jsonl。
59
+ * 让 dry-run 可观测(dev 实例 logger 不落盘),也为生产排查「为什么没收到邮件」留痕。
60
+ * 记录正文截断 2000 字符;永远不含 SMTP 凭据。写失败只告警不阻断。
61
+ * @module notify-email/audit
62
+ */
64
63
  const BODY_EXCERPT_CHARS = 2e3;
65
64
  function buildAuditRecord(subject, text, to, result) {
66
65
  return {
@@ -80,9 +79,51 @@ function createJsonlAuditSink(path, onError) {
80
79
  });
81
80
  };
82
81
  }
83
-
82
+ //#endregion
83
+ //#region src/config-api.ts
84
+ const ROUTE_TEST = "/dsh-plus/notify-email/test";
85
+ function sendJson(res, status, body) {
86
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
87
+ res.end(JSON.stringify(body));
88
+ }
89
+ async function handleTest(service, res) {
90
+ const result = await service.sendTest();
91
+ sendJson(res, result.ok ? 200 : 502, result);
92
+ }
93
+ /** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
94
+ function registerTestApi(ctx, service) {
95
+ const logger = ctx.logger("notify-email");
96
+ const guard = (handler) => {
97
+ return async (req, res) => {
98
+ try {
99
+ await handler(req, res);
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
103
+ if (!res.headersSent) sendJson(res, 400, { error: message });
104
+ else res.end();
105
+ }
106
+ };
107
+ };
108
+ ctx.webServer.register({
109
+ kind: "exact",
110
+ path: ROUTE_TEST,
111
+ handler: guard(async (req, res) => {
112
+ if (req.method !== "POST") {
113
+ sendJson(res, 405, { error: "method not allowed" });
114
+ return;
115
+ }
116
+ await handleTest(service, res);
117
+ })
118
+ });
119
+ }
84
120
  //#endregion
85
121
  //#region src/mailer.ts
122
+ /**
123
+ * 邮件发送:nodemailer 封装 + dry-run 兜底。
124
+ * Transport 抽象为函数注入,单元测试以假 transport 断言参数,零网络。
125
+ * @module notify-email/mailer
126
+ */
86
127
  /** nodemailer 连接/问候/套接字超时(毫秒),防 SMTP 不可达时挂住通知链路。 */
87
128
  const CONNECTION_TIMEOUT_MS = 1e4;
88
129
  const GREETING_TIMEOUT_MS = 1e4;
@@ -160,46 +201,6 @@ var Mailer = class {
160
201
  return result;
161
202
  }
162
203
  };
163
-
164
- //#endregion
165
- //#region src/config-api.ts
166
- const ROUTE_TEST = "/dsh-plus/notify-email/test";
167
- function sendJson(res, status, body) {
168
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
169
- res.end(JSON.stringify(body));
170
- }
171
- async function handleTest(service, res) {
172
- const result = await service.sendTest();
173
- sendJson(res, result.ok ? 200 : 502, result);
174
- }
175
- /** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
176
- function registerTestApi(ctx, service) {
177
- const logger = ctx.logger("notify-email");
178
- const guard = (handler) => {
179
- return async (req, res) => {
180
- try {
181
- await handler(req, res);
182
- } catch (error) {
183
- const message = error instanceof Error ? error.message : String(error);
184
- logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
185
- if (!res.headersSent) sendJson(res, 400, { error: message });
186
- else res.end();
187
- }
188
- };
189
- };
190
- ctx.webServer.register({
191
- kind: "exact",
192
- path: ROUTE_TEST,
193
- handler: guard(async (req, res) => {
194
- if (req.method !== "POST") {
195
- sendJson(res, 405, { error: "method not allowed" });
196
- return;
197
- }
198
- await handleTest(service, res);
199
- })
200
- });
201
- }
202
-
203
204
  //#endregion
204
205
  //#region src/triggers/render.ts
205
206
  function truncate(text, maxChars) {
@@ -264,7 +265,6 @@ function renderAbortedNotice(info) {
264
265
  text: header("任务被用户取消", info)
265
266
  };
266
267
  }
267
-
268
268
  //#endregion
269
269
  //#region src/triggers/builtin.ts
270
270
  /** 官方决策工具名 → 渲染函数 + 对应 toggle。 */
@@ -289,7 +289,6 @@ function createTurnEndTrigger(resolveConfig) {
289
289
  }
290
290
  };
291
291
  }
292
-
293
292
  //#endregion
294
293
  //#region src/watchers/decision.ts
295
294
  function installDecisionWatcher(ctx, service) {
@@ -310,11 +309,10 @@ function installDecisionWatcher(ctx, service) {
310
309
  return next();
311
310
  });
312
311
  }
313
-
314
312
  //#endregion
315
313
  //#region src/watchers/turn-end.ts
316
314
  /** 只有这三类 turn 结束对用户有可决策价值;blocked/max-tokens/interrupted 不通知。 */
317
- const NOTIFY_KINDS = new Set([
315
+ const NOTIFY_KINDS = /* @__PURE__ */ new Set([
318
316
  "completed",
319
317
  "error",
320
318
  "aborted"
@@ -397,9 +395,16 @@ function installTurnEndWatcher(ctx, service, resolveConfig) {
397
395
  watches.clear();
398
396
  }, "notify-email: turn-end timers");
399
397
  }
400
-
401
398
  //#endregion
402
399
  //#region src/service.ts
400
+ /**
401
+ * NotifyEmailService:通知编排中枢。
402
+ * - 配置:installSettingsSection 接入 settings 用户层($DSH_HOME/settings.yaml,热生效),
403
+ * 无 settings provider 时退化为 cordis 行级 config。
404
+ * - 触发器:registerTrigger 是第三方插件的扩展接口;内置官方适配器同路径注册。
405
+ * - 投递:首个非空 EmailNotice 经 Mailer 发送;单个触发器抛错不影响其余。
406
+ * @module @dsh-plus/notify-email
407
+ */
403
408
  var NotifyEmailService = class extends Service {
404
409
  static [Context.inject] = ["agents", "tools"];
405
410
  triggers = [];
@@ -415,7 +420,8 @@ var NotifyEmailService = class extends Service {
415
420
  onChange: () => {}
416
421
  });
417
422
  const logger = ctx.logger("notify-email");
418
- this.mailer = new Mailer(() => this.current(), logger, void 0, createJsonlAuditSink(dshHomePath("logs", "notify-email.jsonl"), (message) => logger.warn(`audit write failed: ${message}`)));
423
+ const audit = createJsonlAuditSink(dshHomePath("logs", "notify-email.jsonl"), (message) => logger.warn(`audit write failed: ${message}`));
424
+ this.mailer = new Mailer(() => this.current(), logger, void 0, audit);
419
425
  this.registerTrigger(createDecisionTrigger(() => this.current()));
420
426
  this.registerTrigger(createTurnEndTrigger(() => this.current()));
421
427
  installDecisionWatcher(ctx, this);
@@ -456,7 +462,7 @@ var NotifyEmailService = class extends Service {
456
462
  async sendNotice(notice) {
457
463
  return this.mailer.send(notice);
458
464
  }
459
- async dispatch(kind, payload, produce) {
465
+ async dispatch(kind, _payload, produce) {
460
466
  for (const trigger of [...this.triggers]) {
461
467
  let notice;
462
468
  try {
@@ -472,7 +478,6 @@ var NotifyEmailService = class extends Service {
472
478
  }
473
479
  }
474
480
  };
475
-
476
481
  //#endregion
477
482
  //#region src/index.ts
478
483
  const name = "dsh-plus-notify-email";
@@ -480,6 +485,5 @@ const inject = ["agents", "tools"];
480
485
  function apply(ctx, config) {
481
486
  ctx.plugin(NotifyEmailService, config);
482
487
  }
483
-
484
488
  //#endregion
485
- export { Config, NotifyEmailService, apply, inject, name };
489
+ export { Config, NotifyEmailService, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/notify-email",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,13 +42,13 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/cordis": "4.0.1",
45
- "@deepseek-ai/dsh-agent": "0.1.0-rc.8",
46
- "@deepseek-ai/dsh-home-paths": "0.1.0-rc.8",
47
- "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
48
- "@deepseek-ai/dsh-llm": "0.1.0-rc.8",
49
- "@deepseek-ai/dsh-session": "0.1.0-rc.8",
50
- "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
51
- "@deepseek-ai/dsh-tools": "0.1.0-rc.8",
45
+ "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
46
+ "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
47
+ "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
48
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
49
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2",
50
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
51
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
52
52
  "@deepseek-ai/schemastery": "3.18.1",
53
53
  "@types/nodemailer": "8.0.1",
54
54
  "@types/react": "~18.3.1",
@@ -7,7 +7,7 @@
7
7
  * 保存经 settings.update 深合并(空 pass 剔除 = 保持不变)。
8
8
  * @module notify-email/client/card
9
9
  */
10
- import { useEffect, useMemo, useSyncExternalStore, useState, type ReactElement } from 'react'
10
+ import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
11
11
 
12
12
  import { SETTINGS_NS } from '../ns.ts'
13
13
  import { sendTest } from './api.ts'
@@ -97,7 +97,10 @@ function toPatch(draft: Draft): Record<string, unknown> {
97
97
  pass: draft.pass,
98
98
  from: draft.from.trim(),
99
99
  },
100
- to: draft.toText.split(',').map((s) => s.trim()).filter((s) => s.length > 0),
100
+ to: draft.toText
101
+ .split(',')
102
+ .map((s) => s.trim())
103
+ .filter((s) => s.length > 0),
101
104
  triggers: { ...draft.triggers },
102
105
  idleDebounceMs: Number(draft.idleDebounceMs),
103
106
  maxBodyChars: Number(draft.maxBodyChars),
@@ -128,14 +131,17 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
128
131
  }, [value, draft])
129
132
 
130
133
  // passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断。
134
+ // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
131
135
  useEffect(() => {
132
136
  let alive = true
133
- api.describe({})
137
+ api
138
+ .describe({})
134
139
  .then((response) => {
135
140
  if (!alive || !response.result.ok) return
136
141
  const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS)
137
142
  setPassConfigured(
138
- view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ?? false,
143
+ view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
144
+ false,
139
145
  )
140
146
  })
141
147
  .catch(() => {})
@@ -146,20 +152,27 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
146
152
 
147
153
  const dirty = useMemo(
148
154
  () =>
149
- value !== undefined && draft !== null &&
155
+ value !== undefined &&
156
+ draft !== null &&
150
157
  JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
151
158
  [value, draft],
152
159
  )
153
160
  const invalid = useMemo(
154
161
  () =>
155
162
  draft !== null &&
156
- (!isPositiveInt(draft.port) || !isPositiveInt(draft.idleDebounceMs) ||
157
- !isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200),
163
+ (!isPositiveInt(draft.port) ||
164
+ !isPositiveInt(draft.idleDebounceMs) ||
165
+ !isPositiveInt(draft.maxBodyChars) ||
166
+ Number(draft.maxBodyChars) < 200),
158
167
  [draft],
159
168
  )
160
169
 
161
170
  if (value === undefined || draft === null) {
162
- return <li className="dne-card"><p className="dne-readOnly">{t('loading')}</p></li>
171
+ return (
172
+ <li className="dne-card">
173
+ <p className="dne-readOnly">{t('loading')}</p>
174
+ </li>
175
+ )
163
176
  }
164
177
  const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
165
178
  setDraft({ ...draft, [key]: editValue })
@@ -175,11 +188,12 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
175
188
  delete (patch['smtp'] as Record<string, unknown>)['pass']
176
189
  }
177
190
  const revision = scope.getSnapshot().revision
178
- api.update({
179
- ns: SETTINGS_NS,
180
- patch,
181
- ...(revision !== undefined ? { expectedRevision: revision } : {}),
182
- })
191
+ api
192
+ .update({
193
+ ns: SETTINGS_NS,
194
+ patch,
195
+ ...(revision !== undefined ? { expectedRevision: revision } : {}),
196
+ })
183
197
  .then(async (response) => {
184
198
  if (!response.result.ok) {
185
199
  throw new Error(response.result.error?.message ?? t('saveFailed'))
@@ -200,14 +214,20 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
200
214
  sendTest()
201
215
  .then((result) => {
202
216
  if (result.ok) {
203
- setStatus({ kind: 'ok', text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk') })
217
+ setStatus({
218
+ kind: 'ok',
219
+ text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk'),
220
+ })
204
221
  } else {
205
222
  const why = result.detail === 'incomplete' ? t('incomplete') : result.detail
206
223
  setStatus({ kind: 'error', text: `${t('testFailed')}${why}` })
207
224
  }
208
225
  })
209
226
  .catch((error: unknown) => {
210
- setStatus({ kind: 'error', text: `${t('testFailed')}${error instanceof Error ? error.message : ''}` })
227
+ setStatus({
228
+ kind: 'error',
229
+ text: `${t('testFailed')}${error instanceof Error ? error.message : ''}`,
230
+ })
211
231
  })
212
232
  .finally(() => setTesting(false))
213
233
  }
@@ -231,58 +251,155 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
231
251
  </button>
232
252
  {open ? (
233
253
  <div className="dne-body">
234
- {disabled ? <p className="dne-readOnly" role="status">{t('readOnly')}</p> : null}
235
- <CheckRow id="dne-enabled" label={t('enabled')} checked={draft.enabled} disabled={disabled}
236
- onEdit={(v) => edit('enabled', v)} />
237
- <TextField id="dne-host" label={t('host')} hint={t('hostHint')} value={draft.host}
238
- disabled={disabled} onEdit={(v) => edit('host', v)} />
239
- <TextField id="dne-port" label={t('port')} hint={t('portHint')} value={draft.port} numeric
240
- disabled={disabled} invalid={!isPositiveInt(draft.port)} invalidLabel={t('invalidNumber')}
241
- onEdit={(v) => edit('port', v)} />
242
- <CheckRow id="dne-secure" label={t('secure')} checked={draft.secure} disabled={disabled}
243
- onEdit={(v) => edit('secure', v)} />
244
- <TextField id="dne-user" label={t('user')} hint={t('userHint')} value={draft.user}
245
- disabled={disabled} onEdit={(v) => edit('user', v)} />
246
- <TextField id="dne-pass" label={t('pass')} hint={t('passHint')} value={draft.pass} password
254
+ {disabled ? (
255
+ <p className="dne-readOnly" role="status">
256
+ {t('readOnly')}
257
+ </p>
258
+ ) : null}
259
+ <CheckRow
260
+ id="dne-enabled"
261
+ label={t('enabled')}
262
+ checked={draft.enabled}
263
+ disabled={disabled}
264
+ onEdit={(v) => edit('enabled', v)}
265
+ />
266
+ <TextField
267
+ id="dne-host"
268
+ label={t('host')}
269
+ hint={t('hostHint')}
270
+ value={draft.host}
271
+ disabled={disabled}
272
+ onEdit={(v) => edit('host', v)}
273
+ />
274
+ <TextField
275
+ id="dne-port"
276
+ label={t('port')}
277
+ hint={t('portHint')}
278
+ value={draft.port}
279
+ numeric
280
+ disabled={disabled}
281
+ invalid={!isPositiveInt(draft.port)}
282
+ invalidLabel={t('invalidNumber')}
283
+ onEdit={(v) => edit('port', v)}
284
+ />
285
+ <CheckRow
286
+ id="dne-secure"
287
+ label={t('secure')}
288
+ checked={draft.secure}
289
+ disabled={disabled}
290
+ onEdit={(v) => edit('secure', v)}
291
+ />
292
+ <TextField
293
+ id="dne-user"
294
+ label={t('user')}
295
+ hint={t('userHint')}
296
+ value={draft.user}
247
297
  disabled={disabled}
248
- badge={{ text: passConfigured ? t('passSet') : t('passUnset'), set: passConfigured }}
249
- onEdit={(v) => edit('pass', v)} />
250
- <TextField id="dne-from" label={t('from')} hint={t('fromHint')} value={draft.from}
251
- disabled={disabled} onEdit={(v) => edit('from', v)} />
252
- <TextField id="dne-to" label={t('to')} hint={t('toHint')} value={draft.toText}
253
- disabled={disabled} onEdit={(v) => edit('toText', v)} />
298
+ onEdit={(v) => edit('user', v)}
299
+ />
300
+ <TextField
301
+ id="dne-pass"
302
+ label={t('pass')}
303
+ hint={t('passHint')}
304
+ value={draft.pass}
305
+ password
306
+ disabled={disabled}
307
+ badge={{
308
+ text: passConfigured ? t('passSet') : t('passUnset'),
309
+ set: passConfigured,
310
+ }}
311
+ onEdit={(v) => edit('pass', v)}
312
+ />
313
+ <TextField
314
+ id="dne-from"
315
+ label={t('from')}
316
+ hint={t('fromHint')}
317
+ value={draft.from}
318
+ disabled={disabled}
319
+ onEdit={(v) => edit('from', v)}
320
+ />
321
+ <TextField
322
+ id="dne-to"
323
+ label={t('to')}
324
+ hint={t('toHint')}
325
+ value={draft.toText}
326
+ disabled={disabled}
327
+ onEdit={(v) => edit('toText', v)}
328
+ />
254
329
  <p className="dne-groupLabel">{t('triggerGroup')}</p>
255
330
  {TRIGGER_KEYS.map((key) => (
256
- <CheckRow key={key} id={`dne-${key}`} label={t(key)} checked={draft.triggers[key]}
257
- disabled={disabled} onEdit={(v) => editTrigger(key, v)} />
331
+ <CheckRow
332
+ key={key}
333
+ id={`dne-${key}`}
334
+ label={t(key)}
335
+ checked={draft.triggers[key]}
336
+ disabled={disabled}
337
+ onEdit={(v) => editTrigger(key, v)}
338
+ />
258
339
  ))}
259
- <TextField id="dne-debounce" label={t('idleDebounceMs')} hint={t('idleDebounceMsHint')}
260
- value={draft.idleDebounceMs} numeric disabled={disabled}
261
- invalid={!isPositiveInt(draft.idleDebounceMs)} invalidLabel={t('invalidNumber')}
262
- onEdit={(v) => edit('idleDebounceMs', v)} />
263
- <TextField id="dne-maxchars" label={t('maxBodyChars')} hint={t('maxBodyCharsHint')}
264
- value={draft.maxBodyChars} numeric disabled={disabled}
340
+ <TextField
341
+ id="dne-debounce"
342
+ label={t('idleDebounceMs')}
343
+ hint={t('idleDebounceMsHint')}
344
+ value={draft.idleDebounceMs}
345
+ numeric
346
+ disabled={disabled}
347
+ invalid={!isPositiveInt(draft.idleDebounceMs)}
348
+ invalidLabel={t('invalidNumber')}
349
+ onEdit={(v) => edit('idleDebounceMs', v)}
350
+ />
351
+ <TextField
352
+ id="dne-maxchars"
353
+ label={t('maxBodyChars')}
354
+ hint={t('maxBodyCharsHint')}
355
+ value={draft.maxBodyChars}
356
+ numeric
357
+ disabled={disabled}
265
358
  invalid={!isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200}
266
359
  invalidLabel={t('invalidNumber')}
267
- onEdit={(v) => edit('maxBodyChars', v)} />
268
- <CheckRow id="dne-dryrun" label={t('dryRun')} checked={draft.dryRun} disabled={disabled}
269
- onEdit={(v) => edit('dryRun', v)} />
360
+ onEdit={(v) => edit('maxBodyChars', v)}
361
+ />
362
+ <CheckRow
363
+ id="dne-dryrun"
364
+ label={t('dryRun')}
365
+ checked={draft.dryRun}
366
+ disabled={disabled}
367
+ onEdit={(v) => edit('dryRun', v)}
368
+ />
270
369
  <div className="dne-footer">
271
370
  {status.kind !== 'idle' ? (
272
- <p className={`dne-status${status.kind === 'error' ? ' dne-statusError' : ''}`} role="status">
371
+ <p
372
+ className={`dne-status${status.kind === 'error' ? ' dne-statusError' : ''}`}
373
+ role="status"
374
+ >
273
375
  {status.text}
274
376
  </p>
275
377
  ) : null}
276
- <button type="button" className="dne-btn dne-btnGhost" disabled={testing}
277
- onClick={onTest}>
378
+ <button
379
+ type="button"
380
+ className="dne-btn dne-btnGhost"
381
+ disabled={testing}
382
+ onClick={onTest}
383
+ >
278
384
  {t(testing ? 'testing' : 'test')}
279
385
  </button>
280
- <button type="button" className="dne-btn dne-btnGhost" disabled={!dirty || saving}
281
- onClick={() => { setDraft(draftFromValue(value)); setStatus(IDLE_STATUS) }}>
386
+ <button
387
+ type="button"
388
+ className="dne-btn dne-btnGhost"
389
+ disabled={!dirty || saving}
390
+ onClick={() => {
391
+ setDraft(draftFromValue(value))
392
+ setStatus(IDLE_STATUS)
393
+ }}
394
+ >
282
395
  {t('discard')}
283
396
  </button>
284
- <button type="button" className="dne-btn dne-btnPrimary" disabled={!dirty || invalid || saving || disabled}
285
- onClick={onSave}>
397
+ <button
398
+ type="button"
399
+ className="dne-btn dne-btnPrimary"
400
+ disabled={!dirty || invalid || saving || disabled}
401
+ onClick={onSave}
402
+ >
286
403
  {t(saving ? 'saving' : 'save')}
287
404
  </button>
288
405
  </div>
@@ -63,7 +63,12 @@ interface ClientContext {
63
63
  * (loopback 或 tailnet 信任域名)下行为一致。
64
64
  */
65
65
  function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
66
- let state: ScopeSnapshot = { status: 'loading', value: undefined, revision: undefined, writable: false }
66
+ let state: ScopeSnapshot = {
67
+ status: 'loading',
68
+ value: undefined,
69
+ revision: undefined,
70
+ writable: false,
71
+ }
67
72
  const listeners = new Set<() => void>()
68
73
  let generation = 0
69
74
  const publish = (next: ScopeSnapshot): void => {
@@ -72,7 +77,7 @@ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
72
77
  }
73
78
  const load = async (): Promise<void> => {
74
79
  const gen = ++generation
75
- let response
80
+ let response: Awaited<ReturnType<SettingsApi['describe']>>
76
81
  try {
77
82
  response = await api.describe({})
78
83
  } catch {
@@ -82,10 +87,20 @@ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
82
87
  const writable = response.result.value?.writable ?? false
83
88
  const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
84
89
  if (view === undefined) {
85
- publish({ status: 'unavailable', value: undefined, revision: undefined, writable })
90
+ publish({
91
+ status: 'unavailable',
92
+ value: undefined,
93
+ revision: undefined,
94
+ writable,
95
+ })
86
96
  return
87
97
  }
88
- publish({ status: 'ready', value: view.value, revision: view.revision, writable })
98
+ publish({
99
+ status: 'ready',
100
+ value: view.value,
101
+ revision: view.revision,
102
+ writable,
103
+ })
89
104
  }
90
105
  const refresh = (namespace?: unknown): void => {
91
106
  if (namespace !== undefined && namespace !== ns) return
@@ -125,10 +140,7 @@ export function apply(ctx: Context): void {
125
140
  },
126
141
  'notify-email: style',
127
142
  )
128
- c.effect(
129
- () => c.locale.register(NS, { zh, en }),
130
- 'notify-email: locale',
131
- )
143
+ c.effect(() => c.locale.register(NS, { zh, en }), 'notify-email: locale')
132
144
  const api = c.get('connection').api.settings
133
145
  const scope = createApiScope(api, SETTINGS_NS, c)
134
146
  c.slots.inject('settings.plugin.item', () =>
@@ -31,7 +31,29 @@ export interface Scope {
31
31
 
32
32
  /** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
33
33
  export interface SettingsApi {
34
- describe(payload?: Record<string, never>): Promise<{ result: { ok: boolean; value?: { writable: boolean; namespaces: Array<{ ns: string; value: unknown; revision: number; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
35
- update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
36
- replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
34
+ describe(payload?: Record<string, never>): Promise<{
35
+ result: {
36
+ ok: boolean
37
+ value?: {
38
+ writable: boolean
39
+ namespaces: Array<{
40
+ ns: string
41
+ value: unknown
42
+ revision: number
43
+ secrets: Array<{ path: string[]; set: boolean }>
44
+ }>
45
+ }
46
+ error?: { message?: string }
47
+ }
48
+ }>
49
+ update(request: {
50
+ ns: string
51
+ patch: Record<string, unknown>
52
+ expectedRevision?: number
53
+ }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
54
+ replace(request: {
55
+ ns: string
56
+ section: Record<string, unknown>
57
+ expectedRevision?: number
58
+ }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
37
59
  }