dsh-completion-reminder 1.0.0 → 1.1.0

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.js CHANGED
@@ -39,40 +39,46 @@ window.__ModuleLoader__.load({
39
39
  providers: {},
40
40
  clickUrl: '',
41
41
  iconUrl: '',
42
+ showSettingsPanel: true,
42
43
  };
43
44
 
44
45
  const DSH_CSS_VARS = {
45
46
  bgModule: 'var(--dsw-alias-bg-module-platform)',
47
+ borderL1: 'var(--dsw-alias-border-l1)',
48
+ borderL2: 'var(--dsw-alias-border-l2)',
46
49
  borderL3: 'var(--dsw-alias-border-l3)',
47
50
  labelPrimary: 'var(--dsw-alias-label-primary)',
48
51
  labelSecondary: 'var(--dsw-alias-label-secondary)',
49
52
  labelTertiary: 'var(--dsw-alias-label-tertiary)',
53
+ labelCaption: 'var(--dsw-alias-label-caption)',
50
54
  stateSuccessPrimary: 'var(--dsw-alias-state-success-primary)',
51
55
  stateWarnPrimary: 'var(--dsw-alias-state-warn-primary)',
52
56
  stateErrorPrimary: 'var(--dsw-alias-state-error-primary)',
57
+ stateBusinessPrimary: 'var(--dsw-alias-state-business-primary)',
58
+ buttonInfoFill: 'var(--dsw-alias-button-info-fill)',
53
59
  shadowLv3: 'var(--dsw-shadow-lv3)',
60
+ fontStrong14: 'var(--dsw-font-s-strong-14)',
61
+ fontXs13: 'var(--dsw-font-xs-13)',
54
62
  };
55
63
 
56
64
 
57
65
  // ── compiled client code ────────────────────────────────────────
58
66
  /**
59
- * DSH Completion Reminder — client half.
67
+ * DSH Completion Reminder — client half (v1.1).
60
68
  *
61
- * A DOM-based DSH client plugin that fires a notification when the agent
62
- * stops generating. The plugin watches the input toolbar (send vs stop
63
- * button) and the conversation stream to detect three terminal states:
64
- * success, user-stopped, and error.
69
+ * Detects the agent-completion lifecycle by watching the DSH composer
70
+ * primary button's `aria-label` (which flips between "Stop generating" /
71
+ * "Stop 生成" / "Stop" while running and "Send message" / "发送消息" /
72
+ * "Send" when idle) and the conversation root's `data-phase` (which
73
+ * transitions through `active` / `settling` / `hero`).
65
74
  *
66
- * Delivery channels:
67
- * - browser : window.Notification (default; user-gated permission)
68
- * - telegram : Telegram Bot API
69
- * - bark : Apple Push (Bark) HTTP API
70
- * - pushover : Pushover REST API
71
- * - serverchan: Server酱 (sct.ftqq.com) — popular in CN
72
- * - discord : Discord incoming webhook
73
- * - slack : Slack incoming webhook
74
- * - webhook : generic JSON POST webhook
75
- * - custom : user-supplied function
75
+ * The plugin:
76
+ * - exposes `window.DSHCompletionReminder.configure({...})` for API users
77
+ * - injects a floating 🔔 button in the bottom-right corner that opens
78
+ * a settings panel (provider picker + per-provider credentials + test
79
+ * button). Configuration is persisted to localStorage.
80
+ * - delivers notifications via 9 channels: browser, Telegram, Bark,
81
+ * Pushover, Server酱, Discord, Slack, generic Webhook, custom.
76
82
  *
77
83
  * The plugin is packaged as a DSH client plugin (`dsh.client` in
78
84
  * package.json) and loaded through `window.__ModuleLoader__`.
@@ -89,67 +95,62 @@ window.__ModuleLoader__.load({
89
95
  isActive: false,
90
96
  permission: detectPermission(),
91
97
  unbinder: [],
98
+ settingsPanelEl: null,
99
+ settingsButtonEl: null,
100
+ panelOpen: false,
92
101
  };
93
- // ──── DOM selectors used to detect the agent run lifecycle ─────────────────
102
+ // ──── DOM signals we look at ───────────────────────────────────────────────
94
103
  /**
95
- * Selectors for the input toolbar's send/stop button. DSH renders the
96
- * same component with a different icon/aria-label while the agent is
97
- * running; we watch the button's `aria-label` and `data-*` attributes.
104
+ * The composer card root in DSH: `<div data-composer-card="true">…</div>`.
105
+ * It contains the textarea, the `data-input-scroll` scrollport, and the
106
+ * primary send/stop button.
107
+ */
108
+ const COMPOSER_CARD_SELECTOR = '[data-composer-card]';
109
+ /**
110
+ * The composer root containing all sessions. Has `data-phase` attribute
111
+ * ('hero' | 'active' | 'settling'). We use this to disambiguate "first
112
+ * paint idle" from "completed and went idle".
113
+ */
114
+ const CONVERSATION_ROOT_SELECTOR = '[data-conversation-scroll], [data-composer-seat]';
115
+ /**
116
+ * The primary send/stop button. DSH flips its `aria-label` between
117
+ * "Stop generating" / "停止生成" (while running) and "Send message" /
118
+ * "发送消息" (when idle). In addition, DSH adds `disabled` to the
119
+ * button when the draft is empty.
120
+ *
121
+ * We do NOT match by class name (CSS-modules hashes change between
122
+ * builds) — only by stable attributes.
123
+ */
124
+ const PRIMARY_BUTTON_SELECTOR = 'button[type="button"]';
125
+ /**
126
+ * Tokens we look for in the primary button's `aria-label` to detect
127
+ * a *running* agent.
98
128
  */
99
- const TOOLBAR_BUTTON_SELECTORS = [
100
- // DSH common: button with a "send" / "stop" accessible name.
101
- 'button[aria-label*="send" i]',
102
- 'button[aria-label*="stop" i]',
103
- 'button[aria-label*="停止" i]',
104
- 'button[aria-label*="发送" i]',
105
- 'button[aria-label*="取消" i]',
106
- 'button[aria-label*="中止" i]',
107
- 'button[aria-label*="abort" i]',
108
- 'button[aria-label*="cancel" i]',
109
- // DSH common: button type="submit" in the chat form.
110
- 'form button[type="submit"]',
111
- 'textarea + * button',
112
- 'textarea ~ button',
113
- ];
114
- /** Texts that, when present on a visible button, mean "agent is running". */
115
129
  const RUNNING_TOKENS = [
116
- 'stop',
117
- 'stop generating',
118
- '停止',
119
- '中止',
120
- '取消生成',
121
- 'abort',
122
- 'cancel',
123
- 'pause',
124
- '暂停',
125
- 'interrupt',
130
+ 'stop generating', '停止生成', '停止', 'stop',
131
+ 'abort', 'cancel generating', 'cancel',
126
132
  ];
127
- /** Texts that mean "the run has finished (successfully)". */
128
- const SUCCESS_TOKENS = [
129
- 'send',
130
- '发送',
131
- 'submit',
132
- '提交',
133
+ /** Tokens for an *idle* / send button. */
134
+ const IDLE_TOKENS = [
135
+ 'send message', '发送消息', 'send', '发送',
133
136
  ];
134
- /** Selectors for the active conversation. */
135
- const CONVERSATION_SELECTORS = [
136
- '[data-conversation-id]',
137
- '[data-conversation]',
138
- 'main [class*="conversation"]',
139
- 'main [class*="chat"]',
140
- 'main [class*="message"]',
141
- ];
142
- // ──── Public configure / activate / deactivate ────────────────────────────
137
+ // ──── Public API ───────────────────────────────────────────────────────────
143
138
  function configure(opts) {
144
- if (!opts) {
145
- config = { ...DEFAULT_OPTIONS };
146
- return;
139
+ // First merge in any persisted config so the panel + programmatic
140
+ // configure() play nicely together.
141
+ const persisted = loadPersisted();
142
+ const merged = { ...persisted, ...(opts ?? {}) };
143
+ if (!opts || opts.providers === undefined) {
144
+ merged.providers = { ...(persisted.providers ?? {}), ...(opts?.providers ?? {}) };
147
145
  }
148
146
  config = {
149
147
  ...DEFAULT_OPTIONS,
150
- ...opts,
151
- providers: { ...DEFAULT_OPTIONS.providers, ...(opts.providers ?? {}) },
148
+ ...merged,
149
+ providers: { ...DEFAULT_OPTIONS.providers, ...(merged.providers ?? {}) },
152
150
  };
151
+ if (state.isActive) {
152
+ refreshSettingsButtonVisibility();
153
+ }
153
154
  }
154
155
  function activate() {
155
156
  if (state.isActive)
@@ -162,6 +163,9 @@ window.__ModuleLoader__.load({
162
163
  }
163
164
  startObserver();
164
165
  bindVisibilityEvents();
166
+ if (config.showSettingsPanel) {
167
+ ensureSettingsButton();
168
+ }
165
169
  }
166
170
  function deactivate() {
167
171
  state.isActive = false;
@@ -172,9 +176,46 @@ window.__ModuleLoader__.load({
172
176
  }
173
177
  catch { /* noop */ }
174
178
  }
179
+ removeSettingsUI();
175
180
  state.runStartedAt = null;
176
181
  state.inFlight = false;
177
182
  }
183
+ // ──── Persistence ──────────────────────────────────────────────────────────
184
+ function loadPersisted() {
185
+ if (typeof localStorage === 'undefined')
186
+ return {};
187
+ try {
188
+ const raw = localStorage.getItem(STORAGE_KEY);
189
+ if (!raw)
190
+ return {};
191
+ const parsed = JSON.parse(raw);
192
+ return parsed ?? {};
193
+ }
194
+ catch {
195
+ return {};
196
+ }
197
+ }
198
+ function savePersisted() {
199
+ if (typeof localStorage === 'undefined')
200
+ return;
201
+ try {
202
+ const out = {
203
+ provider: config.provider,
204
+ autoRequestPermission: config.autoRequestPermission,
205
+ notifyOnSuccess: config.notifyOnSuccess,
206
+ notifyOnStopped: config.notifyOnStopped,
207
+ notifyOnError: config.notifyOnError,
208
+ suppressWhenFocused: config.suppressWhenFocused,
209
+ cooldownMs: config.cooldownMs,
210
+ showSettingsPanel: config.showSettingsPanel,
211
+ providers: config.providers,
212
+ };
213
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(out));
214
+ }
215
+ catch {
216
+ // localStorage may be disabled — silently ignore.
217
+ }
218
+ }
178
219
  // ──── Permission handling ──────────────────────────────────────────────────
179
220
  function detectPermission() {
180
221
  if (typeof window === 'undefined' || !('Notification' in window)) {
@@ -204,15 +245,18 @@ window.__ModuleLoader__.load({
204
245
  if (state.observer)
205
246
  state.observer.disconnect();
206
247
  state.observer = new MutationObserver(handleMutations);
248
+ // Watch the whole document — DSH may swap the conversation root on
249
+ // session change. We only act on changes inside the composer card or
250
+ // the conversation root, which is cheap to filter in the callback.
207
251
  state.observer.observe(document.body, {
208
252
  childList: true,
209
253
  subtree: true,
210
254
  characterData: true,
211
255
  attributes: true,
212
- attributeFilter: ['aria-label', 'aria-pressed', 'data-state', 'data-status', 'class', 'disabled'],
256
+ attributeFilter: ['aria-label', 'aria-disabled', 'data-phase', 'data-state', 'class', 'disabled'],
213
257
  });
214
- // Take an initial reading of the current state.
215
- scanCurrentRun();
258
+ // Take an initial reading.
259
+ evaluateNow();
216
260
  }
217
261
  function stopObserver() {
218
262
  if (state.observer) {
@@ -224,157 +268,143 @@ window.__ModuleLoader__.load({
224
268
  if (!state.isActive)
225
269
  return;
226
270
  for (const m of mutations) {
227
- // Toolbar changes (send ↔ stop swap) are the strongest signal.
228
- if (m.type === 'attributes' || m.type === 'characterData') {
271
+ // Attribute changes on the primary button or its `data-composer-card`
272
+ // ancestor are the most precise signal.
273
+ if (m.type === 'attributes') {
229
274
  const target = m.target;
230
- if (target && isInsideToolbar(target)) {
231
- evaluateToolbar();
275
+ if (target && isInsideComposer(target)) {
276
+ evaluateNow();
277
+ continue;
232
278
  }
233
279
  }
234
- // New child nodes can also flip the toolbar; check the subtree root.
280
+ // Newly added composer cards / buttons.
235
281
  for (const node of m.addedNodes) {
236
282
  if (!(node instanceof HTMLElement))
237
283
  continue;
238
- if (node.matches?.(TOOLBAR_BUTTON_SELECTORS.join(','))) {
239
- evaluateToolbar();
240
- }
241
- else if (node.querySelector?.(TOOLBAR_BUTTON_SELECTORS.join(','))) {
242
- evaluateToolbar();
284
+ if (node.matches?.(COMPOSER_CARD_SELECTOR) ||
285
+ node.querySelector?.(COMPOSER_CARD_SELECTOR)) {
286
+ evaluateNow();
243
287
  }
244
288
  }
245
289
  }
246
290
  }
247
- function isInsideToolbar(el) {
248
- return !!el.closest('form, [class*="composer" i], [class*="toolbar" i], [class*="input" i], [class*="chat-input" i]');
291
+ function isInsideComposer(el) {
292
+ return !!el.closest(COMPOSER_CARD_SELECTOR);
249
293
  }
250
- function scanCurrentRun() {
251
- // Without any history, we don't know whether the user is mid-run.
252
- // Trust the current toolbar state on first paint.
253
- evaluateToolbar();
254
- }
255
- function evaluateToolbar() {
256
- const buttons = collectToolbarButtons();
257
- if (!buttons.length)
258
- return;
259
- // Look for a button whose text/aria-label signals "running".
260
- const running = buttons.find((b) => matchesAny(b, RUNNING_TOKENS));
261
- const idle = buttons.find((b) => matchesAny(b, SUCCESS_TOKENS));
262
- // Refresh cached model/agent name opportunistically.
294
+ function evaluateNow() {
295
+ const primary = findPrimaryButton();
263
296
  captureRunMetadata();
264
- if (running && !state.inFlight) {
265
- // Edge: idle → running
297
+ const isRunning = !!primary && isRunningButton(primary);
298
+ if (isRunning && !state.inFlight) {
299
+ // idle → running
266
300
  state.inFlight = true;
267
301
  state.runStartedAt = Date.now();
268
302
  return;
269
303
  }
270
- if (!running && state.inFlight) {
271
- // Edge: running → idle. We treat any exit-from-running as a
272
- // completion event and decide success vs stopped vs error by
273
- // inspecting the latest assistant message and any visible error.
304
+ if (!isRunning && state.inFlight) {
305
+ // running → idle: completion event
274
306
  state.inFlight = false;
275
307
  const startedAt = state.runStartedAt ?? Date.now();
276
308
  const durationMs = Date.now() - startedAt;
277
309
  state.runStartedAt = null;
278
310
  void completeRun(determineStatus(), durationMs);
279
311
  }
280
- // When the page first loads idle, do nothing.
281
- void idle;
282
- }
283
- /** Returns the buttons in the composer toolbar (send/stop/... ). */
284
- function collectToolbarButtons() {
285
- const out = [];
286
- const forms = document.querySelectorAll('form, [class*="composer" i], [class*="toolbar" i]');
287
- for (const f of forms) {
288
- const buttons = f.querySelectorAll('button');
289
- buttons.forEach((b) => {
290
- if (b.offsetParent !== null || b.getClientRects().length)
291
- out.push(b);
292
- });
293
- }
294
- // Also include bare buttons that match one of the toolbar selectors
295
- // outside a known form (best effort).
296
- for (const sel of TOOLBAR_BUTTON_SELECTORS) {
297
- document.querySelectorAll(sel).forEach((b) => {
298
- if (b.offsetParent !== null || b.getClientRects().length) {
299
- if (!out.includes(b))
300
- out.push(b);
301
- }
302
- });
312
+ }
313
+ /**
314
+ * Find the primary send/stop button by walking up from the composer card.
315
+ * The button is the only `<button type="button">` inside the composer that
316
+ * is NOT the leading add-command button, so we look for buttons with an
317
+ * aria-label that includes one of the well-known tokens.
318
+ */
319
+ function findPrimaryButton() {
320
+ const card = document.querySelector(COMPOSER_CARD_SELECTOR);
321
+ if (!card)
322
+ return null;
323
+ const buttons = card.querySelectorAll('button[type="button"]');
324
+ for (const b of buttons) {
325
+ const aria = (b.getAttribute('aria-label') || '').toLowerCase();
326
+ if (RUNNING_TOKENS.some((t) => aria.includes(t)) ||
327
+ IDLE_TOKENS.some((t) => aria.includes(t))) {
328
+ return b;
329
+ }
303
330
  }
304
- return out;
331
+ // Last-resort: if the composer has exactly one button, use it.
332
+ if (buttons.length === 1)
333
+ return buttons[0];
334
+ return null;
305
335
  }
306
- function matchesAny(btn, tokens) {
307
- const text = (btn.textContent ?? '').trim().toLowerCase();
308
- const aria = (btn.getAttribute('aria-label') ?? '').trim().toLowerCase();
309
- const title = (btn.getAttribute('title') ?? '').trim().toLowerCase();
310
- const cls = (btn.getAttribute('class') ?? '').toLowerCase();
311
- const hay = `${text} ${aria} ${title} ${cls}`;
312
- return tokens.some((tok) => hay.includes(tok));
336
+ function isRunningButton(btn) {
337
+ const aria = (btn.getAttribute('aria-label') || '').toLowerCase();
338
+ return RUNNING_TOKENS.some((t) => aria.includes(t));
313
339
  }
314
340
  /**
315
- * Inspect the most recent assistant message to decide success vs error.
316
- * If we can't decide, default to 'success' (the common case).
341
+ * Look at the conversation list to figure out success / error / stopped.
342
+ * If we cannot decide, default to 'success' (the common case).
317
343
  */
318
344
  function determineStatus() {
319
- // If the user pressed stop while we were running, we still flag the
320
- // event as 'stopped' when an explicit stopped/error marker is visible.
321
- const lastAssistant = findLastAssistantMessage();
322
- if (!lastAssistant)
345
+ // Strongest signal: the conversation root's `data-phase` is `settling`
346
+ // while a turn is being finalised. We treat the moment it leaves
347
+ // `settling` as completion.
348
+ const root = document.querySelector(CONVERSATION_ROOT_SELECTOR);
349
+ const phase = root?.getAttribute('data-phase');
350
+ if (phase === 'settling') {
323
351
  return 'success';
324
- const text = (lastAssistant.textContent ?? '').toLowerCase();
325
- if (/(error|exception|failed|traceback|错误|失败|异常)/.test(text) &&
326
- !/no error|没有错误|successfully|成功/.test(text)) {
327
- return 'error';
328
352
  }
329
- if (/(stopped by user|user stopped|手动停止|已停止|已取消)/.test(text)) {
330
- return 'stopped';
353
+ // Next, look at the latest assistant turn. If its last child has
354
+ // [data-state="interrupted"] / [data-state="error"] / class includes
355
+ // "error" / "stop", we have a more specific status.
356
+ const lastAssistant = findLastAssistantTurn();
357
+ if (lastAssistant) {
358
+ const cls = (lastAssistant.getAttribute('class') ?? '').toLowerCase();
359
+ const ds = (lastAssistant.getAttribute('data-state') ?? '').toLowerCase();
360
+ if (ds === 'interrupted' || cls.includes('interrupt') || cls.includes('stop')) {
361
+ return 'stopped';
362
+ }
363
+ if (ds === 'error' || cls.includes('error') || cls.includes('fail')) {
364
+ return 'error';
365
+ }
366
+ const text = (lastAssistant.textContent ?? '').toLowerCase();
367
+ if (/(error|exception|failed|traceback|错误|失败|异常)/.test(text) &&
368
+ !/no error|没有错误|successfully|成功/.test(text)) {
369
+ return 'error';
370
+ }
371
+ if (/(stopped by user|user stopped|手动停止|已停止|已取消)/.test(text)) {
372
+ return 'stopped';
373
+ }
331
374
  }
332
375
  return 'success';
333
376
  }
334
- function findLastAssistantMessage() {
335
- for (const sel of CONVERSATION_SELECTORS) {
336
- const all = document.querySelectorAll(sel);
337
- for (let i = all.length - 1; i >= 0; i--) {
338
- const el = all[i];
339
- const role = (el.getAttribute('data-role') ?? el.getAttribute('data-author') ?? '')
340
- .toLowerCase();
341
- if (role.includes('assistant') || role.includes('agent') || role.includes('model')) {
342
- return el;
343
- }
344
- }
377
+ function findLastAssistantTurn() {
378
+ const scroll = document.querySelector(CONVERSATION_ROOT_SELECTOR);
379
+ if (!scroll)
380
+ return null;
381
+ const candidates = scroll.querySelectorAll('[data-role="assistant"], [data-author="assistant"], [data-author="model"]');
382
+ if (candidates.length)
383
+ return candidates[candidates.length - 1];
384
+ let last = null;
385
+ for (const child of Array.from(scroll.children)) {
386
+ if (child instanceof HTMLElement)
387
+ last = child;
345
388
  }
346
- // Fallback: the last child in the conversation stream is the latest
347
- // message — if its text looks like an error we already know.
348
- const main = document.querySelector('main');
349
- const last = main?.lastElementChild;
350
- return last instanceof HTMLElement ? last : null;
389
+ return last;
351
390
  }
352
391
  function captureRunMetadata() {
353
- const model = readModelFromHeader();
354
- if (model)
355
- state.lastModel = model;
356
- const agent = readAgentFromHeader();
357
- if (agent)
358
- state.lastAgent = agent;
359
- }
360
- function readModelFromHeader() {
361
- // DSH renders a model label (e.g. "DeepSeek-V3") in a header chip.
362
- const candidates = document.querySelectorAll('[data-model], [data-testid*="model" i], [class*="model" i]');
363
- for (const el of candidates) {
392
+ const modelCandidates = document.querySelectorAll('[data-model], [data-testid*="model" i]');
393
+ for (const el of modelCandidates) {
364
394
  const text = (el.textContent ?? '').trim();
365
- if (text && text.length < 80)
366
- return text;
395
+ if (text && text.length < 80) {
396
+ state.lastModel = text;
397
+ break;
398
+ }
367
399
  }
368
- return null;
369
- }
370
- function readAgentFromHeader() {
371
- const candidates = document.querySelectorAll('[data-agent], [data-testid*="agent" i]');
372
- for (const el of candidates) {
400
+ const agentCandidates = document.querySelectorAll('[data-agent], [data-testid*="agent" i]');
401
+ for (const el of agentCandidates) {
373
402
  const text = (el.textContent ?? '').trim();
374
- if (text && text.length < 80)
375
- return text;
403
+ if (text && text.length < 80) {
404
+ state.lastAgent = text;
405
+ break;
406
+ }
376
407
  }
377
- return null;
378
408
  }
379
409
  // ──── Visibility / focus suppression ───────────────────────────────────────
380
410
  function bindVisibilityEvents() {
@@ -402,7 +432,6 @@ window.__ModuleLoader__.load({
402
432
  return;
403
433
  if (config.suppressWhenFocused && pageIsFocused())
404
434
  return;
405
- // Cooldown — avoid rapid-fire notifications on tool-call loops.
406
435
  const now = Date.now();
407
436
  if (now - state.lastNotifiedAt < config.cooldownMs)
408
437
  return;
@@ -465,7 +494,6 @@ window.__ModuleLoader__.load({
465
494
  // ──── browser ──────────────────────────────────────────────────────────────
466
495
  async function deliverBrowser(payload) {
467
496
  if (state.permission === 'unsupported') {
468
- // No Notification API — fall back to an in-page toast.
469
497
  showInPageToast(payload);
470
498
  return;
471
499
  }
@@ -501,7 +529,6 @@ window.__ModuleLoader__.load({
501
529
  };
502
530
  }
503
531
  catch (err) {
504
- // Some browsers throw when called from a non-active tab.
505
532
  showInPageToast(payload, '系统通知失败,已改为页面内提示。');
506
533
  config.onError(toError(err), 'browser');
507
534
  }
@@ -581,29 +608,14 @@ window.__ModuleLoader__.load({
581
608
  cursor: pointer;
582
609
  transition: opacity .25s ease, transform .25s ease;
583
610
  }
584
- .dsh-reminder-toast-title {
585
- font-weight: 600;
586
- margin-bottom: 4px;
587
- color: ${DSH_CSS_VARS.labelPrimary};
588
- }
589
- .dsh-reminder-toast-body {
590
- color: ${DSH_CSS_VARS.labelSecondary};
591
- white-space: pre-wrap;
592
- word-break: break-word;
593
- }
594
- .dsh-reminder-toast-hint {
595
- color: ${DSH_CSS_VARS.labelTertiary};
596
- font-size: 12px;
597
- margin-top: 6px;
598
- }
599
- .dsh-reminder-toast-leave {
600
- opacity: 0;
601
- transform: translateY(-4px);
602
- }
611
+ .dsh-reminder-toast-title { font-weight: 600; margin-bottom: 4px; color: ${DSH_CSS_VARS.labelPrimary}; }
612
+ .dsh-reminder-toast-body { color: ${DSH_CSS_VARS.labelSecondary}; white-space: pre-wrap; word-break: break-word; }
613
+ .dsh-reminder-toast-hint { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; margin-top: 6px; }
614
+ .dsh-reminder-toast-leave { opacity: 0; transform: translateY(-4px); }
603
615
  `;
604
616
  document.head.appendChild(style);
605
617
  }
606
- // ──── Generic fetch helper ─────────────────────────────────────────────────
618
+ // ──── Generic fetch helpers ────────────────────────────────────────────────
607
619
  async function postJson(url, body) {
608
620
  const res = await fetch(url, {
609
621
  method: 'POST',
@@ -663,7 +675,6 @@ window.__ModuleLoader__.load({
663
675
  if (!cfg.barkKey)
664
676
  throw new Error('Bark provider requires barkKey');
665
677
  const server = (cfg.barkServer || 'https://api.day.app').replace(/\/$/, '');
666
- // Bark accepts /:key/:title/:body?url=…&icon=…
667
678
  const url = `${server}/${encodeURIComponent(cfg.barkKey)}/${encodeURIComponent(payload.title)}/${encodeURIComponent(payload.body)}${payload.url ? `?url=${encodeURIComponent(payload.url)}` : ''}${payload.iconUrl ? `${payload.url ? '&' : '?'}icon=${encodeURIComponent(payload.iconUrl)}` : ''}`;
668
679
  const res = await fetch(url, { method: 'GET' });
669
680
  if (!res.ok) {
@@ -741,6 +752,436 @@ window.__ModuleLoader__.load({
741
752
  return value;
742
753
  return new Error(typeof value === 'string' ? value : JSON.stringify(value));
743
754
  }
755
+ // ──── Settings panel ───────────────────────────────────────────────────────
756
+ const SETTINGS_STYLE_ID = 'dsh-completion-reminder-settings-style';
757
+ function ensureSettingsButton() {
758
+ if (state.settingsButtonEl && document.body.contains(state.settingsButtonEl))
759
+ return;
760
+ injectSettingsStyles();
761
+ const btn = document.createElement('button');
762
+ btn.type = 'button';
763
+ btn.className = 'dsh-reminder-fab';
764
+ btn.setAttribute('aria-label', 'Completion Reminder Settings');
765
+ btn.title = 'Completion Reminder';
766
+ btn.innerHTML = `
767
+ <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
768
+ <path fill="currentColor" d="M12 2a7 7 0 0 0-7 7v3.586l-1.707 1.707A1 1 0 0 0 4 15.5h16a1 1 0 0 0 .707-1.707L19 12.586V9a7 7 0 0 0-7-7zm0 19a3 3 0 0 0 3-3H9a3 3 0 0 0 3 3z"/>
769
+ </svg>
770
+ `;
771
+ btn.addEventListener('click', () => toggleSettingsPanel());
772
+ document.body.appendChild(btn);
773
+ state.settingsButtonEl = btn;
774
+ }
775
+ function refreshSettingsButtonVisibility() {
776
+ if (config.showSettingsPanel) {
777
+ ensureSettingsButton();
778
+ }
779
+ else if (state.settingsButtonEl) {
780
+ state.settingsButtonEl.remove();
781
+ state.settingsButtonEl = null;
782
+ removeSettingsPanel();
783
+ }
784
+ }
785
+ function removeSettingsUI() {
786
+ removeSettingsPanel();
787
+ if (state.settingsButtonEl) {
788
+ state.settingsButtonEl.remove();
789
+ state.settingsButtonEl = null;
790
+ }
791
+ }
792
+ function toggleSettingsPanel() {
793
+ if (state.panelOpen)
794
+ removeSettingsPanel();
795
+ else
796
+ openSettingsPanel();
797
+ }
798
+ function openSettingsPanel() {
799
+ if (state.settingsPanelEl)
800
+ return;
801
+ injectSettingsStyles();
802
+ const panel = document.createElement('div');
803
+ panel.className = 'dsh-reminder-panel';
804
+ panel.setAttribute('role', 'dialog');
805
+ panel.setAttribute('aria-label', 'Completion Reminder Settings');
806
+ panel.innerHTML = renderSettingsPanel();
807
+ document.body.appendChild(panel);
808
+ state.settingsPanelEl = panel;
809
+ state.panelOpen = true;
810
+ wireSettingsPanel(panel);
811
+ }
812
+ function removeSettingsPanel() {
813
+ if (state.settingsPanelEl) {
814
+ state.settingsPanelEl.remove();
815
+ state.settingsPanelEl = null;
816
+ }
817
+ state.panelOpen = false;
818
+ }
819
+ function renderSettingsPanel() {
820
+ const providers = [
821
+ ['browser', '🌐 浏览器通知'],
822
+ ['telegram', '✈️ Telegram'],
823
+ ['bark', '🍎 Bark (iOS)'],
824
+ ['pushover', '📲 Pushover'],
825
+ ['serverchan', '🐦 Server酱'],
826
+ ['discord', '🎮 Discord'],
827
+ ['slack', '💼 Slack'],
828
+ ['webhook', '🔗 通用 Webhook'],
829
+ ['custom', '🛠 自定义'],
830
+ ];
831
+ const providerOptions = providers
832
+ .map(([id, name]) => `<option value="${id}" ${config.provider === id ? 'selected' : ''}>${name}</option>`)
833
+ .join('');
834
+ const p = config.providers;
835
+ const fields = [
836
+ ['telegramBotToken', 'Telegram Bot Token', 'bot123456:ABC…', p.telegramBotToken ?? ''],
837
+ ['telegramChatId', 'Telegram Chat ID', '123456789', p.telegramChatId ?? ''],
838
+ ['barkKey', 'Bark Key', '你的 iPhone Bark 设备 Key', p.barkKey ?? ''],
839
+ ['barkServer', 'Bark Server(可选)', '默认 https://api.day.app', p.barkServer ?? ''],
840
+ ['pushoverUserKey', 'Pushover User Key', 'u…', p.pushoverUserKey ?? ''],
841
+ ['pushoverToken', 'Pushover App Token', 'a…', p.pushoverToken ?? ''],
842
+ ['pushoverDevice', 'Pushover Device(可选)', '留空推送到所有设备', p.pushoverDevice ?? ''],
843
+ ['serverchanSendKey', 'Server酱 SendKey', 'SCT…', p.serverchanSendKey ?? ''],
844
+ ['discordWebhookUrl', 'Discord Webhook URL', 'https://discord.com/api/webhooks/…', p.discordWebhookUrl ?? ''],
845
+ ['slackWebhookUrl', 'Slack Webhook URL', 'https://hooks.slack.com/services/…', p.slackWebhookUrl ?? ''],
846
+ ['webhookUrl', '通用 Webhook URL', 'https://your-service.example/notify', p.webhookUrl ?? ''],
847
+ ];
848
+ const providerFieldsHtml = fields
849
+ .map(([name, label, placeholder, value]) => `
850
+ <label class="dsh-reminder-field" data-provider-field>
851
+ <span>${label}</span>
852
+ <input type="text" name="${name}" data-provider-input="${name}" placeholder="${escapeAttr(placeholder)}" value="${escapeAttr(value)}" autocomplete="off" spellcheck="false" />
853
+ </label>`)
854
+ .join('');
855
+ const perm = state.permission;
856
+ return `
857
+ <header class="dsh-reminder-panel-header">
858
+ <strong>提醒设置</strong>
859
+ <button type="button" class="dsh-reminder-close" data-reminder-action="close" aria-label="关闭">✕</button>
860
+ </header>
861
+ <div class="dsh-reminder-panel-body">
862
+ <label class="dsh-reminder-field">
863
+ <span>通知渠道</span>
864
+ <select data-reminder-input="provider">${providerOptions}</select>
865
+ </label>
866
+
867
+ <fieldset class="dsh-reminder-group" data-reminder-group="provider-fields">
868
+ <legend>渠道凭证(仅当前渠道需要)</legend>
869
+ ${providerFieldsHtml}
870
+ <p class="dsh-reminder-hint">
871
+ 配置只保存在本浏览器的 localStorage,
872
+ <strong>不会</strong> 上传到任何服务器。
873
+ </p>
874
+ </fieldset>
875
+
876
+ <fieldset class="dsh-reminder-group">
877
+ <legend>行为</legend>
878
+ <label class="dsh-reminder-row">
879
+ <input type="checkbox" data-reminder-input="notifyOnSuccess" ${config.notifyOnSuccess ? 'checked' : ''} />
880
+ <span>成功完成时通知</span>
881
+ </label>
882
+ <label class="dsh-reminder-row">
883
+ <input type="checkbox" data-reminder-input="notifyOnStopped" ${config.notifyOnStopped ? 'checked' : ''} />
884
+ <span>用户主动停止时通知</span>
885
+ </label>
886
+ <label class="dsh-reminder-row">
887
+ <input type="checkbox" data-reminder-input="notifyOnError" ${config.notifyOnError ? 'checked' : ''} />
888
+ <span>Agent 出错时通知</span>
889
+ </label>
890
+ <label class="dsh-reminder-row">
891
+ <input type="checkbox" data-reminder-input="suppressWhenFocused" ${config.suppressWhenFocused ? 'checked' : ''} />
892
+ <span>DSH 标签页可见时静默(推荐)</span>
893
+ </label>
894
+ <label class="dsh-reminder-row">
895
+ <input type="checkbox" data-reminder-input="autoRequestPermission" ${config.autoRequestPermission ? 'checked' : ''} />
896
+ <span>自动请求浏览器通知权限</span>
897
+ </label>
898
+ <label class="dsh-reminder-field">
899
+ <span>冷却(ms)— 防止连续完成时刷屏</span>
900
+ <input type="number" min="0" step="500" data-reminder-input="cooldownMs" value="${config.cooldownMs}" />
901
+ </label>
902
+ </fieldset>
903
+
904
+ <div class="dsh-reminder-row dsh-reminder-status">
905
+ <span>当前权限:<strong data-reminder-perm>${permissionLabel(perm)}</strong></span>
906
+ <button type="button" data-reminder-action="request-permission">请求权限</button>
907
+ </div>
908
+
909
+ <div class="dsh-reminder-actions">
910
+ <button type="button" class="primary" data-reminder-action="test">发送测试通知</button>
911
+ <button type="button" data-reminder-action="save">保存</button>
912
+ <button type="button" data-reminder-action="reset">重置</button>
913
+ </div>
914
+ <p class="dsh-reminder-hint" data-reminder-status></p>
915
+ </div>
916
+ `;
917
+ }
918
+ function wireSettingsPanel(panel) {
919
+ panel.querySelector('[data-reminder-action="close"]')
920
+ ?.addEventListener('click', () => removeSettingsPanel());
921
+ panel.addEventListener('change', (ev) => {
922
+ const target = ev.target;
923
+ if (!target)
924
+ return;
925
+ if (target instanceof HTMLSelectElement) {
926
+ const name = target.dataset.reminderInput;
927
+ if (name === 'provider') {
928
+ config.provider = target.value;
929
+ }
930
+ }
931
+ else if (target instanceof HTMLInputElement) {
932
+ const name = target.dataset.reminderInput;
933
+ const value = target.type === 'checkbox' ? target.checked
934
+ : target.type === 'number' ? Number(target.value) || 0
935
+ : target.value;
936
+ if (name === 'notifyOnSuccess')
937
+ config.notifyOnSuccess = !!value;
938
+ else if (name === 'notifyOnStopped')
939
+ config.notifyOnStopped = !!value;
940
+ else if (name === 'notifyOnError')
941
+ config.notifyOnError = !!value;
942
+ else if (name === 'suppressWhenFocused')
943
+ config.suppressWhenFocused = !!value;
944
+ else if (name === 'autoRequestPermission')
945
+ config.autoRequestPermission = !!value;
946
+ else if (name === 'cooldownMs')
947
+ config.cooldownMs = Number(value) || 0;
948
+ }
949
+ savePersisted();
950
+ });
951
+ panel.addEventListener('input', (ev) => {
952
+ const target = ev.target;
953
+ if (!target)
954
+ return;
955
+ const name = target.dataset.providerInput;
956
+ if (name) {
957
+ config.providers[name] = target.value;
958
+ savePersisted();
959
+ }
960
+ });
961
+ panel.querySelector('[data-reminder-action="save"]')
962
+ ?.addEventListener('click', () => {
963
+ savePersisted();
964
+ setStatus(panel, '已保存 ✓');
965
+ });
966
+ panel.querySelector('[data-reminder-action="reset"]')
967
+ ?.addEventListener('click', () => {
968
+ if (typeof localStorage !== 'undefined') {
969
+ try {
970
+ localStorage.removeItem(STORAGE_KEY);
971
+ }
972
+ catch { /* noop */ }
973
+ }
974
+ configure();
975
+ removeSettingsPanel();
976
+ openSettingsPanel();
977
+ });
978
+ panel.querySelector('[data-reminder-action="request-permission"]')
979
+ ?.addEventListener('click', async () => {
980
+ const result = await requestBrowserPermission();
981
+ const permEl = panel.querySelector('[data-reminder-perm]');
982
+ if (permEl)
983
+ permEl.textContent = permissionLabel(result);
984
+ setStatus(panel, result === 'granted' ? '权限已授予' : '权限状态:' + permissionLabel(result));
985
+ });
986
+ panel.querySelector('[data-reminder-action="test"]')
987
+ ?.addEventListener('click', async () => {
988
+ try {
989
+ const ctx = {
990
+ status: 'success',
991
+ durationMs: 4321,
992
+ completedAt: new Date().toISOString(),
993
+ url: typeof location !== 'undefined' ? location.href : '',
994
+ };
995
+ const payload = {
996
+ title: '🧪 DSH Completion Reminder — 测试',
997
+ body: `这是 ${config.provider} 渠道的测试通知。Agent 完成后将使用相同的方式推送。`,
998
+ url: ctx.url,
999
+ status: 'success',
1000
+ durationMs: ctx.durationMs,
1001
+ completedAt: ctx.completedAt,
1002
+ };
1003
+ await dispatch(payload);
1004
+ config.onNotify(payload, config.provider);
1005
+ setStatus(panel, '测试通知已发送,请查收。');
1006
+ }
1007
+ catch (err) {
1008
+ const e = toError(err);
1009
+ config.onError(e, config.provider);
1010
+ setStatus(panel, '测试失败:' + e.message, true);
1011
+ }
1012
+ });
1013
+ }
1014
+ function setStatus(panel, text, isError = false) {
1015
+ const el = panel.querySelector('[data-reminder-status]');
1016
+ if (!el)
1017
+ return;
1018
+ el.textContent = text;
1019
+ el.style.color = isError
1020
+ ? 'var(--dsw-alias-state-error-primary)'
1021
+ : 'var(--dsw-alias-state-success-primary)';
1022
+ setTimeout(() => {
1023
+ if (el.textContent === text)
1024
+ el.textContent = '';
1025
+ }, 5000);
1026
+ }
1027
+ function permissionLabel(p) {
1028
+ if (p === 'granted')
1029
+ return '已授予';
1030
+ if (p === 'denied')
1031
+ return '已拒绝';
1032
+ if (p === 'unsupported')
1033
+ return '不支持';
1034
+ return '未询问';
1035
+ }
1036
+ function escapeAttr(s) {
1037
+ return s
1038
+ .replace(/&/g, '&amp;')
1039
+ .replace(/"/g, '&quot;')
1040
+ .replace(/</g, '&lt;')
1041
+ .replace(/>/g, '&gt;');
1042
+ }
1043
+ function injectSettingsStyles() {
1044
+ if (document.getElementById(SETTINGS_STYLE_ID))
1045
+ return;
1046
+ const style = document.createElement('style');
1047
+ style.id = SETTINGS_STYLE_ID;
1048
+ style.textContent = `
1049
+ .dsh-reminder-fab {
1050
+ position: fixed;
1051
+ right: 18px;
1052
+ bottom: 18px;
1053
+ z-index: 2147483600;
1054
+ width: 40px;
1055
+ height: 40px;
1056
+ border-radius: 50%;
1057
+ background: ${DSH_CSS_VARS.buttonInfoFill};
1058
+ color: #fff;
1059
+ border: none;
1060
+ box-shadow: ${DSH_CSS_VARS.shadowLv3};
1061
+ cursor: pointer;
1062
+ display: grid;
1063
+ place-items: center;
1064
+ opacity: 0.85;
1065
+ transition: opacity .15s, transform .15s;
1066
+ }
1067
+ .dsh-reminder-fab:hover { opacity: 1; transform: scale(1.05); }
1068
+
1069
+ .dsh-reminder-panel {
1070
+ position: fixed;
1071
+ right: 18px;
1072
+ bottom: 70px;
1073
+ z-index: 2147483601;
1074
+ width: 360px;
1075
+ max-height: min(80vh, 640px);
1076
+ overflow: auto;
1077
+ background: ${DSH_CSS_VARS.bgModule};
1078
+ color: ${DSH_CSS_VARS.labelPrimary};
1079
+ border: 1px solid ${DSH_CSS_VARS.borderL3};
1080
+ border-radius: 12px;
1081
+ box-shadow: ${DSH_CSS_VARS.shadowLv3};
1082
+ font-size: 13px;
1083
+ line-height: 1.45;
1084
+ font-family: var(--dsw-font-family, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
1085
+ }
1086
+ .dsh-reminder-panel-header {
1087
+ display: flex;
1088
+ align-items: center;
1089
+ justify-content: space-between;
1090
+ padding: 10px 12px;
1091
+ border-bottom: 1px solid ${DSH_CSS_VARS.borderL1};
1092
+ font-size: 14px;
1093
+ font-weight: 600;
1094
+ position: sticky;
1095
+ top: 0;
1096
+ background: ${DSH_CSS_VARS.bgModule};
1097
+ }
1098
+ .dsh-reminder-close {
1099
+ background: transparent;
1100
+ border: none;
1101
+ color: ${DSH_CSS_VARS.labelSecondary};
1102
+ cursor: pointer;
1103
+ font-size: 16px;
1104
+ line-height: 1;
1105
+ padding: 2px 6px;
1106
+ border-radius: 4px;
1107
+ }
1108
+ .dsh-reminder-close:hover { background: ${DSH_CSS_VARS.borderL1}; color: ${DSH_CSS_VARS.labelPrimary}; }
1109
+
1110
+ .dsh-reminder-panel-body { padding: 12px 14px 16px; display: flex; flex-direction: column; gap: 14px; }
1111
+
1112
+ .dsh-reminder-group {
1113
+ border: 1px solid ${DSH_CSS_VARS.borderL1};
1114
+ border-radius: 8px;
1115
+ padding: 8px 10px 10px;
1116
+ margin: 0;
1117
+ display: flex;
1118
+ flex-direction: column;
1119
+ gap: 8px;
1120
+ }
1121
+ .dsh-reminder-group > legend { padding: 0 4px; color: ${DSH_CSS_VARS.labelSecondary}; font-size: 12px; }
1122
+
1123
+ .dsh-reminder-field { display: flex; flex-direction: column; gap: 4px; }
1124
+ .dsh-reminder-field > span { color: ${DSH_CSS_VARS.labelSecondary}; font-size: 12px; }
1125
+ .dsh-reminder-field input,
1126
+ .dsh-reminder-field select {
1127
+ background: transparent;
1128
+ color: ${DSH_CSS_VARS.labelPrimary};
1129
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1130
+ border-radius: 6px;
1131
+ padding: 6px 8px;
1132
+ font-size: 13px;
1133
+ outline: none;
1134
+ font-family: inherit;
1135
+ transition: border-color .15s, box-shadow .15s;
1136
+ }
1137
+ .dsh-reminder-field input:focus,
1138
+ .dsh-reminder-field select:focus {
1139
+ border-color: ${DSH_CSS_VARS.stateBusinessPrimary};
1140
+ box-shadow: 0 0 0 2px color-mix(in srgb, ${DSH_CSS_VARS.stateBusinessPrimary} 25%, transparent);
1141
+ }
1142
+
1143
+ .dsh-reminder-row { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
1144
+ .dsh-reminder-row > input[type="checkbox"] { accent-color: ${DSH_CSS_VARS.buttonInfoFill}; }
1145
+
1146
+ .dsh-reminder-actions { display: flex; gap: 8px; }
1147
+ .dsh-reminder-actions button {
1148
+ flex: 1;
1149
+ padding: 6px 10px;
1150
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1151
+ background: transparent;
1152
+ color: ${DSH_CSS_VARS.labelPrimary};
1153
+ border-radius: 6px;
1154
+ cursor: pointer;
1155
+ font-size: 13px;
1156
+ font-family: inherit;
1157
+ }
1158
+ .dsh-reminder-actions button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1159
+ .dsh-reminder-actions button.primary { background: ${DSH_CSS_VARS.buttonInfoFill}; color: #fff; border-color: transparent; }
1160
+ .dsh-reminder-actions button.primary:hover { filter: brightness(1.05); }
1161
+
1162
+ .dsh-reminder-status {
1163
+ padding: 6px 8px;
1164
+ border: 1px dashed ${DSH_CSS_VARS.borderL2};
1165
+ border-radius: 6px;
1166
+ justify-content: space-between;
1167
+ flex-wrap: wrap;
1168
+ }
1169
+ .dsh-reminder-status button {
1170
+ background: transparent;
1171
+ color: ${DSH_CSS_VARS.labelPrimary};
1172
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1173
+ border-radius: 6px;
1174
+ padding: 4px 8px;
1175
+ cursor: pointer;
1176
+ font-size: 12px;
1177
+ font-family: inherit;
1178
+ }
1179
+ .dsh-reminder-status button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1180
+
1181
+ .dsh-reminder-hint { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; line-height: 1.5; }
1182
+ `;
1183
+ document.head.appendChild(style);
1184
+ }
744
1185
  // ──── DSH client plugin entry ──────────────────────────────────────────────
745
1186
  /**
746
1187
  * DSH client plugin entry — called by the Cordis Loader.