@dsh-plus/notify-email 0.1.8 → 0.1.10

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.js CHANGED
@@ -292,12 +292,19 @@ function createTurnEndTrigger(resolveConfig) {
292
292
  //#endregion
293
293
  //#region src/watchers/decision.ts
294
294
  function installDecisionWatcher(ctx, service) {
295
- const seen = /* @__PURE__ */ new Set();
295
+ const seen = /* @__PURE__ */ new Map();
296
+ const UNKNOWN_BUCKET_CAP = 1e3;
296
297
  const logger = ctx.logger("notify-email");
297
298
  ctx.on("tools/pre-execute", (exec, next) => {
298
- if (seen.has(exec.callId)) return next();
299
- seen.add(exec.callId);
300
299
  const sessionId = exec.agent?.id ?? "unknown";
300
+ let bucket = seen.get(sessionId);
301
+ if (bucket === void 0) {
302
+ bucket = /* @__PURE__ */ new Set();
303
+ seen.set(sessionId, bucket);
304
+ }
305
+ if (bucket.has(exec.callId)) return next();
306
+ if (sessionId === "unknown" && bucket.size >= UNKNOWN_BUCKET_CAP) bucket.clear();
307
+ bucket.add(exec.callId);
301
308
  service.dispatchDecision({
302
309
  callId: exec.callId,
303
310
  sessionId,
@@ -308,6 +315,9 @@ function installDecisionWatcher(ctx, service) {
308
315
  });
309
316
  return next();
310
317
  });
318
+ ctx.on("session/disposed", (session) => {
319
+ seen.delete(session.id);
320
+ });
311
321
  }
312
322
  //#endregion
313
323
  //#region src/watchers/turn-end.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/notify-email",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,8 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "nodemailer": "9.0.5"
35
+ "nodemailer": "9.0.5",
36
+ "@dsh-plus/shared": "0.1.3"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@deepseek-ai/cordis": "^4.0.1",
@@ -1,18 +1,26 @@
1
1
  /**
2
2
  * 「邮件通知」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
3
- * 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃;
4
- * 另加「发送测试邮件」(唯一保留的自定义端点)。
5
- * 配置读写走官方 settingsScope 传输(scope.ts):value 为 schema 解析后的
6
- * 脱敏视图(smtp.pass 不出现,passConfigured 由 describe 的 secrets 探测),
3
+ * 外壳与基础控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/CheckRow),
4
+ * 本文件只保留业务字段与保存/测试逻辑。交互对齐官方卡片:折叠/展开、
5
+ * staged draft、未保存标记、保存/放弃;另加「发送测试邮件」。
6
+ * 配置读写走官方 settingsScope 传输:value 为 schema 解析后的脱敏视图
7
+ * (smtp.pass 不出现,passConfigured 由 describe 的 secrets 探测),
7
8
  * 保存经 settings.update 深合并(空 pass 剔除 = 保持不变)。
8
9
  * @module notify-email/client/card
9
10
  */
10
- import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
11
11
 
12
+ import {
13
+ CardChrome,
14
+ type CardStatusState,
15
+ CheckRow,
16
+ IDLE_STATUS,
17
+ type Scope,
18
+ type SettingsApi,
19
+ TextField,
20
+ } from '@dsh-plus/shared/client'
21
+ import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
12
22
  import { SETTINGS_NS } from '../ns.ts'
13
23
  import { sendTest } from './api.ts'
14
- import { CheckRow, TextField } from './fields.tsx'
15
- import type { Scope, SettingsApi } from './scope.ts'
16
24
 
17
25
  export interface CardProps {
18
26
  t(key: string): string
@@ -58,13 +66,6 @@ interface Draft {
58
66
  dryRun: boolean
59
67
  }
60
68
 
61
- interface Status {
62
- kind: 'idle' | 'ok' | 'error'
63
- text: string
64
- }
65
-
66
- const IDLE_STATUS: Status = { kind: 'idle', text: '' }
67
-
68
69
  function draftFromValue(value: ConfigValue): Draft {
69
70
  return {
70
71
  enabled: value.enabled,
@@ -122,7 +123,7 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
122
123
  const [passConfigured, setPassConfigured] = useState(false)
123
124
  const [saving, setSaving] = useState(false)
124
125
  const [testing, setTesting] = useState(false)
125
- const [status, setStatus] = useState<Status>(IDLE_STATUS)
126
+ const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
126
127
 
127
128
  // 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
128
129
  useEffect(() => {
@@ -234,177 +235,164 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
234
235
 
235
236
  const disabled = !snapshot.writable
236
237
  return (
237
- <li className={`dne-card${open ? ' dne-cardOpen' : ''}`}>
238
- <button
239
- type="button"
240
- className="dne-header"
241
- aria-expanded={open}
242
- aria-label={`${t(open ? 'collapse' : 'expand')}: ${t('title')}`}
243
- onClick={() => setOpen(!open)}
244
- >
245
- <span className="dne-headText">
246
- <span className="dne-name">{t('title')}</span>
247
- <span className="dne-description">{t('description')}</span>
248
- </span>
249
- {dirty ? <span className="dne-pending">{t('unsaved')}</span> : null}
250
- <span className={`dne-chevron${open ? ' dne-chevronOpen' : ''}`}>▾</span>
251
- </button>
252
- {open ? (
253
- <div className="dne-body">
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}
297
- disabled={disabled}
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
- />
329
- <p className="dne-groupLabel">{t('triggerGroup')}</p>
330
- {TRIGGER_KEYS.map((key) => (
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
- />
339
- ))}
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}
358
- invalid={!isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200}
359
- invalidLabel={t('invalidNumber')}
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
- />
369
- <div className="dne-footer">
370
- {status.kind !== 'idle' ? (
371
- <p
372
- className={`dne-status${status.kind === 'error' ? ' dne-statusError' : ''}`}
373
- role="status"
374
- >
375
- {status.text}
376
- </p>
377
- ) : null}
378
- <button
379
- type="button"
380
- className="dne-btn dne-btnGhost"
381
- disabled={testing}
382
- onClick={onTest}
383
- >
384
- {t(testing ? 'testing' : 'test')}
385
- </button>
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
- >
395
- {t('discard')}
396
- </button>
397
- <button
398
- type="button"
399
- className="dne-btn dne-btnPrimary"
400
- disabled={!dirty || invalid || saving || disabled}
401
- onClick={onSave}
402
- >
403
- {t(saving ? 'saving' : 'save')}
404
- </button>
405
- </div>
406
- </div>
407
- ) : null}
408
- </li>
238
+ <CardChrome
239
+ prefix="dne"
240
+ title={t('title')}
241
+ description={t('description')}
242
+ open={open}
243
+ onToggle={setOpen}
244
+ statusBadge={{ text: t(draft.enabled ? 'enabledOn' : 'enabledOff'), on: draft.enabled }}
245
+ dirty={dirty}
246
+ dirtyLabel={t('unsaved')}
247
+ readOnlyNotice={disabled ? t('readOnly') : undefined}
248
+ status={status}
249
+ actions={[
250
+ {
251
+ key: 'test',
252
+ label: t(testing ? 'testing' : 'test'),
253
+ disabled: testing,
254
+ onClick: onTest,
255
+ },
256
+ {
257
+ key: 'discard',
258
+ label: t('discard'),
259
+ disabled: !dirty || saving,
260
+ onClick: () => {
261
+ setDraft(draftFromValue(value))
262
+ setStatus(IDLE_STATUS)
263
+ },
264
+ },
265
+ {
266
+ key: 'save',
267
+ label: t(saving ? 'saving' : 'save'),
268
+ variant: 'primary',
269
+ disabled: !dirty || invalid || saving || disabled,
270
+ onClick: onSave,
271
+ },
272
+ ]}
273
+ >
274
+ <CheckRow
275
+ prefix="dne"
276
+ id="dne-enabled"
277
+ label={t('enabled')}
278
+ checked={draft.enabled}
279
+ disabled={disabled}
280
+ onEdit={(v) => edit('enabled', v)}
281
+ />
282
+ <TextField
283
+ prefix="dne"
284
+ id="dne-host"
285
+ label={t('host')}
286
+ hint={t('hostHint')}
287
+ value={draft.host}
288
+ disabled={disabled}
289
+ onEdit={(v) => edit('host', v)}
290
+ />
291
+ <TextField
292
+ prefix="dne"
293
+ id="dne-port"
294
+ label={t('port')}
295
+ hint={t('portHint')}
296
+ value={draft.port}
297
+ numeric
298
+ disabled={disabled}
299
+ invalid={!isPositiveInt(draft.port)}
300
+ invalidLabel={t('invalidNumber')}
301
+ onEdit={(v) => edit('port', v)}
302
+ />
303
+ <CheckRow
304
+ prefix="dne"
305
+ id="dne-secure"
306
+ label={t('secure')}
307
+ checked={draft.secure}
308
+ disabled={disabled}
309
+ onEdit={(v) => edit('secure', v)}
310
+ />
311
+ <TextField
312
+ prefix="dne"
313
+ id="dne-user"
314
+ label={t('user')}
315
+ hint={t('userHint')}
316
+ value={draft.user}
317
+ disabled={disabled}
318
+ onEdit={(v) => edit('user', v)}
319
+ />
320
+ <TextField
321
+ prefix="dne"
322
+ id="dne-pass"
323
+ label={t('pass')}
324
+ hint={t('passHint')}
325
+ value={draft.pass}
326
+ password
327
+ disabled={disabled}
328
+ badge={{
329
+ text: passConfigured ? t('passSet') : t('passUnset'),
330
+ set: passConfigured,
331
+ }}
332
+ onEdit={(v) => edit('pass', v)}
333
+ />
334
+ <TextField
335
+ prefix="dne"
336
+ id="dne-from"
337
+ label={t('from')}
338
+ hint={t('fromHint')}
339
+ value={draft.from}
340
+ disabled={disabled}
341
+ onEdit={(v) => edit('from', v)}
342
+ />
343
+ <TextField
344
+ prefix="dne"
345
+ id="dne-to"
346
+ label={t('to')}
347
+ hint={t('toHint')}
348
+ value={draft.toText}
349
+ disabled={disabled}
350
+ onEdit={(v) => edit('toText', v)}
351
+ />
352
+ <p className="dne-groupLabel">{t('triggerGroup')}</p>
353
+ {TRIGGER_KEYS.map((key) => (
354
+ <CheckRow
355
+ prefix="dne"
356
+ key={key}
357
+ id={`dne-${key}`}
358
+ label={t(key)}
359
+ checked={draft.triggers[key]}
360
+ disabled={disabled}
361
+ onEdit={(v) => editTrigger(key, v)}
362
+ />
363
+ ))}
364
+ <TextField
365
+ prefix="dne"
366
+ id="dne-debounce"
367
+ label={t('idleDebounceMs')}
368
+ hint={t('idleDebounceMsHint')}
369
+ value={draft.idleDebounceMs}
370
+ numeric
371
+ disabled={disabled}
372
+ invalid={!isPositiveInt(draft.idleDebounceMs)}
373
+ invalidLabel={t('invalidNumber')}
374
+ onEdit={(v) => edit('idleDebounceMs', v)}
375
+ />
376
+ <TextField
377
+ prefix="dne"
378
+ id="dne-maxchars"
379
+ label={t('maxBodyChars')}
380
+ hint={t('maxBodyCharsHint')}
381
+ value={draft.maxBodyChars}
382
+ numeric
383
+ disabled={disabled}
384
+ invalid={!isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200}
385
+ invalidLabel={t('invalidNumber')}
386
+ onEdit={(v) => edit('maxBodyChars', v)}
387
+ />
388
+ <CheckRow
389
+ prefix="dne"
390
+ id="dne-dryrun"
391
+ label={t('dryRun')}
392
+ checked={draft.dryRun}
393
+ disabled={disabled}
394
+ onEdit={(v) => edit('dryRun', v)}
395
+ />
396
+ </CardChrome>
409
397
  )
410
398
  }
@@ -4,24 +4,20 @@
4
4
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
5
5
  *
6
6
  * 类型说明:浏览器半只用到 slots/locale/connection/remote 的很窄一面,
7
- * 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
8
- * client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
9
- * settings.plugin.item 插槽与 dsh-client-connection 的 settings RPC 面为准。
7
+ * scope 与基础控件走 @dsh-plus/shared/client 套件(收编自本插件原实现)。
10
8
  * rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
11
9
  * (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
12
10
  * 官方配置页按此 key 与 Host 已注册命名空间配对分发。
13
11
  * 配置读写经官方 settings RPC 直连(rc7 起第三方命名空间全量开放;
14
- * 不复用 settingsScope 服务——非 loopback 页面下它无数据,见 scope.ts);
12
+ * 不复用 settingsScope 服务——非 loopback 页面下它无数据);
15
13
  * 自定义端点仅剩「发送测试邮件」(api.ts)。
16
14
  * @module @dsh-plus/notify-email/client
17
15
  */
18
16
  import type { Context } from '@deepseek-ai/cordis'
19
-
17
+ import { cardCss, createApiScope, injectCardStyle } from '@dsh-plus/shared/client'
20
18
  import { SETTINGS_NS } from '../ns.ts'
21
19
  import { NotifyEmailCard } from './card.tsx'
22
20
  import { en, NS, zh } from './i18n.ts'
23
- import type { Scope, ScopeSnapshot, SettingsApi } from './scope.ts'
24
- import { injectStyle } from './styles.ts'
25
21
 
26
22
  export const name = 'dsh-plus-notify-email'
27
23
 
@@ -43,7 +39,7 @@ interface RemoteLike {
43
39
  }
44
40
 
45
41
  interface ConnectionLike {
46
- api: { settings: SettingsApi }
42
+ api: { settings: Parameters<typeof createApiScope>[0] }
47
43
  }
48
44
 
49
45
  interface ClientContext {
@@ -55,85 +51,9 @@ interface ClientContext {
55
51
  effect(execute: () => () => void, label?: string): unknown
56
52
  }
57
53
 
58
- /**
59
- * api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
60
- * 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
61
- * 刷新:remote `settings/document-updated`(按命名空间过滤)与
62
- * `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
63
- * (loopback 或 tailnet 信任域名)下行为一致。
64
- */
65
- function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
66
- let state: ScopeSnapshot = {
67
- status: 'loading',
68
- value: undefined,
69
- revision: undefined,
70
- writable: false,
71
- }
72
- const listeners = new Set<() => void>()
73
- let generation = 0
74
- const publish = (next: ScopeSnapshot): void => {
75
- state = next
76
- for (const listener of [...listeners]) listener()
77
- }
78
- const load = async (): Promise<void> => {
79
- const gen = ++generation
80
- let response: Awaited<ReturnType<SettingsApi['describe']>>
81
- try {
82
- response = await api.describe({})
83
- } catch {
84
- return
85
- }
86
- if (gen !== generation || !response.result.ok) return
87
- const writable = response.result.value?.writable ?? false
88
- const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
89
- if (view === undefined) {
90
- publish({
91
- status: 'unavailable',
92
- value: undefined,
93
- revision: undefined,
94
- writable,
95
- })
96
- return
97
- }
98
- publish({
99
- status: 'ready',
100
- value: view.value,
101
- revision: view.revision,
102
- writable,
103
- })
104
- }
105
- const refresh = (namespace?: unknown): void => {
106
- if (namespace !== undefined && namespace !== ns) return
107
- void load()
108
- }
109
- const disposers = [
110
- c.get('remote').$on('settings/document-updated', refresh),
111
- c.on('connection/reset', () => {
112
- void load()
113
- }),
114
- ]
115
- c.effect(
116
- () => () => {
117
- for (const dispose of disposers) dispose()
118
- },
119
- 'notify-email: settings scope',
120
- )
121
- void load()
122
- return {
123
- getSnapshot: () => state,
124
- subscribe: (listener: () => void) => {
125
- listeners.add(listener)
126
- return () => {
127
- listeners.delete(listener)
128
- }
129
- },
130
- load,
131
- }
132
- }
133
-
134
54
  export function apply(ctx: Context): void {
135
55
  const c = ctx as unknown as ClientContext
136
- const tag = injectStyle()
56
+ const tag = injectCardStyle('@dsh-plus/notify-email', cardCss('dne'))
137
57
  c.effect(
138
58
  () => () => {
139
59
  tag?.remove()
@@ -142,7 +62,7 @@ export function apply(ctx: Context): void {
142
62
  )
143
63
  c.effect(() => c.locale.register(NS, { zh, en }), 'notify-email: locale')
144
64
  const api = c.get('connection').api.settings
145
- const scope = createApiScope(api, SETTINGS_NS, c)
65
+ const scope = createApiScope(api, SETTINGS_NS, c, 'notify-email: settings scope')
146
66
  c.slots.inject('settings.plugin.item', () =>
147
67
  c.slots.register(
148
68
  {
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用,与官方卡片同机制。
3
+ * 公共键(save/discard/unsaved 等)来自 shared 的 common 字典,本文件只维护业务键。
3
4
  * @module notify-email/client/i18n
4
5
  */
6
+ import { commonEn, commonZh, mergeDict } from '@dsh-plus/shared/client'
5
7
 
6
8
  export const NS = 'dsh-plus-notify-email'
7
9
 
8
- export const zh: Record<string, string> = {
10
+ const ownZh = {
9
11
  title: '邮件通知',
10
12
  description: '任务结束、等待决策或出错时向指定邮箱发送邮件。',
11
13
  enabled: '启用通知',
@@ -35,26 +37,15 @@ export const zh: Record<string, string> = {
35
37
  maxBodyChars: '正文截断长度',
36
38
  maxBodyCharsHint: '邮件正文内容超过该长度时截断。',
37
39
  dryRun: '仅记录日志(不真实发送)',
38
- save: '保存',
39
- saving: '保存中…',
40
- discard: '放弃',
41
- unsaved: '未保存',
42
40
  test: '发送测试邮件',
43
41
  testing: '发送中…',
44
42
  testOk: '测试邮件已发送。',
45
43
  testDryRun: '已按 dry-run 记录日志(未真实发送)。',
46
44
  testFailed: '测试发送失败:',
47
- saveFailed: '保存失败,请检查填写内容。',
48
- loading: '加载中…',
49
- unavailable: '配置服务不可用。',
50
- readOnly: '当前部署无 settings provider,配置为只读;请编辑 settings.yaml。',
51
- expand: '展开',
52
- collapse: '收起',
53
- invalidNumber: '请输入有效数字',
54
45
  incomplete: 'SMTP 配置不完整(服务器/发件地址/收件人必填)。',
55
46
  }
56
47
 
57
- export const en: Record<string, string> = {
48
+ const ownEn = {
58
49
  title: 'Email notifications',
59
50
  description: 'Email a mailbox when a task finishes, awaits a decision, or fails.',
60
51
  enabled: 'Enable notifications',
@@ -77,28 +68,20 @@ export const en: Record<string, string> = {
77
68
  onComplete: 'Task completed',
78
69
  onError: 'Task failed',
79
70
  onAborted: 'Cancelled by user',
80
- onQuestion: 'Waiting for an answer',
81
- onPlanReview: 'Plan pending approval',
82
- idleDebounceMs: 'Idle confirmation (ms)',
83
- idleDebounceMsHint: 'Wait this long after completion to confirm no follow-up work.',
84
- maxBodyChars: 'Body truncation',
85
- maxBodyCharsHint: 'Truncate mail body content beyond this length.',
86
- dryRun: 'Log only (do not send)',
87
- save: 'Save',
88
- saving: 'Saving…',
89
- discard: 'Discard',
90
- unsaved: 'Unsaved',
71
+ onQuestion: 'Question awaiting answer',
72
+ onPlanReview: 'Plan awaiting review',
73
+ idleDebounceMs: 'Idle debounce (ms)',
74
+ idleDebounceMsHint: 'After completion, wait this long to confirm no follow-up before sending.',
75
+ maxBodyChars: 'Body truncation length',
76
+ maxBodyCharsHint: 'Truncate the email body beyond this length.',
77
+ dryRun: 'Log only (do not actually send)',
91
78
  test: 'Send test email',
92
79
  testing: 'Sending…',
93
80
  testOk: 'Test email sent.',
94
- testDryRun: 'Recorded as dry-run (not actually sent).',
95
- testFailed: 'Test failed: ',
96
- saveFailed: 'Save failed; please check the fields.',
97
- loading: 'Loading…',
98
- unavailable: 'Configuration service unavailable.',
99
- readOnly: 'No settings provider in this deployment; edit settings.yaml instead.',
100
- expand: 'Expand',
101
- collapse: 'Collapse',
102
- invalidNumber: 'Enter a valid number',
81
+ testDryRun: 'Logged in dry-run mode (not sent).',
82
+ testFailed: 'Test send failed: ',
103
83
  incomplete: 'SMTP config incomplete (host/from/recipients required).',
104
84
  }
85
+
86
+ export const zh: Record<string, string> = mergeDict(commonZh, ownZh)
87
+ export const en: Record<string, string> = mergeDict(commonEn, ownEn)