dsh-completion-reminder 1.0.0 → 1.2.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,44 +39,55 @@ 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.2).
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
+ * Strategy:
70
+ * 1. Detect agent-completion lifecycle by watching the DSH composer
71
+ * primary button's `aria-label` (which flips between "Stop
72
+ * generating" / "停止生成" and "Send message" / "发送消息") inside
73
+ * `[data-composer-card]`. The `data-phase` attribute on the
74
+ * conversation root helps disambiguate active / settling / hero.
65
75
  *
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
76
+ * 2. Register a settings section in DSH's own settings dialog via
77
+ * `ctx.slots.inject('settings.section', ...)` so the user finds the
78
+ * configuration alongside General / Models / Plugins rather than as
79
+ * an extra floating widget.
80
+ *
81
+ * 3. Filter credential fields by the currently selected provider so the
82
+ * panel stays compact (Telegram shows 2 fields, Bark shows 1, etc.).
83
+ *
84
+ * 4. Persist configuration to `localStorage` and re-load on activation.
76
85
  *
77
86
  * The plugin is packaged as a DSH client plugin (`dsh.client` in
78
87
  * package.json) and loaded through `window.__ModuleLoader__`.
79
88
  */
89
+ var React = require("react");
90
+ var { forwardRef, useEffect, useImperativeHandle, useRef } = require("react");
80
91
 
81
92
  let config = { ...DEFAULT_OPTIONS };
82
93
  const state = {
@@ -89,67 +100,36 @@ window.__ModuleLoader__.load({
89
100
  isActive: false,
90
101
  permission: detectPermission(),
91
102
  unbinder: [],
103
+ panelHostEl: null,
104
+ panelRendered: false,
105
+ hintEl: null,
92
106
  };
93
- // ──── DOM selectors used to detect the agent run lifecycle ─────────────────
94
- /**
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.
98
- */
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". */
107
+ // ──── DOM signals we look at ───────────────────────────────────────────────
108
+ const COMPOSER_CARD_SELECTOR = '[data-composer-card]';
109
+ const CONVERSATION_ROOT_SELECTOR = '[data-conversation-scroll], [data-composer-seat]';
115
110
  const RUNNING_TOKENS = [
116
- 'stop',
117
- 'stop generating',
118
- '停止',
119
- '中止',
120
- '取消生成',
121
- 'abort',
122
- 'cancel',
123
- 'pause',
124
- '暂停',
125
- 'interrupt',
126
- ];
127
- /** Texts that mean "the run has finished (successfully)". */
128
- const SUCCESS_TOKENS = [
129
- 'send',
130
- '发送',
131
- 'submit',
132
- '提交',
111
+ 'stop generating', '停止生成', '停止', 'stop',
112
+ 'abort', 'cancel generating', 'cancel',
133
113
  ];
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"]',
114
+ const IDLE_TOKENS = [
115
+ 'send message', '发送消息', 'send', '发送',
141
116
  ];
142
- // ──── Public configure / activate / deactivate ────────────────────────────
117
+ // ──── Public API ───────────────────────────────────────────────────────────
143
118
  function configure(opts) {
144
- if (!opts) {
145
- config = { ...DEFAULT_OPTIONS };
146
- return;
119
+ const persisted = loadPersisted();
120
+ const merged = { ...persisted, ...(opts ?? {}) };
121
+ if (!opts || opts.providers === undefined) {
122
+ merged.providers = { ...(persisted.providers ?? {}), ...(opts?.providers ?? {}) };
147
123
  }
148
124
  config = {
149
125
  ...DEFAULT_OPTIONS,
150
- ...opts,
151
- providers: { ...DEFAULT_OPTIONS.providers, ...(opts.providers ?? {}) },
126
+ ...merged,
127
+ providers: { ...DEFAULT_OPTIONS.providers, ...(merged.providers ?? {}) },
152
128
  };
129
+ if (state.isActive) {
130
+ // Re-render the open panel with new values.
131
+ rerenderPanel();
132
+ }
153
133
  }
154
134
  function activate() {
155
135
  if (state.isActive)
@@ -162,6 +142,7 @@ window.__ModuleLoader__.load({
162
142
  }
163
143
  startObserver();
164
144
  bindVisibilityEvents();
145
+ // Don't auto-show a hint — the user now finds us via DSH Settings.
165
146
  }
166
147
  function deactivate() {
167
148
  state.isActive = false;
@@ -172,8 +153,50 @@ window.__ModuleLoader__.load({
172
153
  }
173
154
  catch { /* noop */ }
174
155
  }
156
+ removeHint();
175
157
  state.runStartedAt = null;
176
158
  state.inFlight = false;
159
+ // Clear the panel host so the next activation rebuilds it.
160
+ if (state.panelHostEl) {
161
+ state.panelHostEl.innerHTML = '';
162
+ state.panelHostEl = null;
163
+ }
164
+ state.panelRendered = false;
165
+ }
166
+ // ──── Persistence ──────────────────────────────────────────────────────────
167
+ function loadPersisted() {
168
+ if (typeof localStorage === 'undefined')
169
+ return {};
170
+ try {
171
+ const raw = localStorage.getItem(STORAGE_KEY);
172
+ if (!raw)
173
+ return {};
174
+ const parsed = JSON.parse(raw);
175
+ return parsed ?? {};
176
+ }
177
+ catch {
178
+ return {};
179
+ }
180
+ }
181
+ function savePersisted() {
182
+ if (typeof localStorage === 'undefined')
183
+ return;
184
+ try {
185
+ const out = {
186
+ provider: config.provider,
187
+ autoRequestPermission: config.autoRequestPermission,
188
+ notifyOnSuccess: config.notifyOnSuccess,
189
+ notifyOnStopped: config.notifyOnStopped,
190
+ notifyOnError: config.notifyOnError,
191
+ suppressWhenFocused: config.suppressWhenFocused,
192
+ cooldownMs: config.cooldownMs,
193
+ providers: config.providers,
194
+ };
195
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(out));
196
+ }
197
+ catch {
198
+ // localStorage may be disabled — silently ignore.
199
+ }
177
200
  }
178
201
  // ──── Permission handling ──────────────────────────────────────────────────
179
202
  function detectPermission() {
@@ -209,10 +232,9 @@ window.__ModuleLoader__.load({
209
232
  subtree: true,
210
233
  characterData: true,
211
234
  attributes: true,
212
- attributeFilter: ['aria-label', 'aria-pressed', 'data-state', 'data-status', 'class', 'disabled'],
235
+ attributeFilter: ['aria-label', 'aria-disabled', 'data-phase', 'data-state', 'class', 'disabled'],
213
236
  });
214
- // Take an initial reading of the current state.
215
- scanCurrentRun();
237
+ evaluateNow();
216
238
  }
217
239
  function stopObserver() {
218
240
  if (state.observer) {
@@ -224,157 +246,120 @@ window.__ModuleLoader__.load({
224
246
  if (!state.isActive)
225
247
  return;
226
248
  for (const m of mutations) {
227
- // Toolbar changes (send ↔ stop swap) are the strongest signal.
228
- if (m.type === 'attributes' || m.type === 'characterData') {
249
+ if (m.type === 'attributes') {
229
250
  const target = m.target;
230
- if (target && isInsideToolbar(target)) {
231
- evaluateToolbar();
251
+ if (target && isInsideComposer(target)) {
252
+ evaluateNow();
253
+ continue;
232
254
  }
233
255
  }
234
- // New child nodes can also flip the toolbar; check the subtree root.
235
256
  for (const node of m.addedNodes) {
236
257
  if (!(node instanceof HTMLElement))
237
258
  continue;
238
- if (node.matches?.(TOOLBAR_BUTTON_SELECTORS.join(','))) {
239
- evaluateToolbar();
240
- }
241
- else if (node.querySelector?.(TOOLBAR_BUTTON_SELECTORS.join(','))) {
242
- evaluateToolbar();
259
+ if (node.matches?.(COMPOSER_CARD_SELECTOR) ||
260
+ node.querySelector?.(COMPOSER_CARD_SELECTOR)) {
261
+ evaluateNow();
243
262
  }
244
263
  }
245
264
  }
246
265
  }
247
- function isInsideToolbar(el) {
248
- return !!el.closest('form, [class*="composer" i], [class*="toolbar" i], [class*="input" i], [class*="chat-input" i]');
249
- }
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();
266
+ function isInsideComposer(el) {
267
+ return !!el.closest(COMPOSER_CARD_SELECTOR);
254
268
  }
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.
269
+ function evaluateNow() {
270
+ const primary = findPrimaryButton();
263
271
  captureRunMetadata();
264
- if (running && !state.inFlight) {
265
- // Edge: idle → running
272
+ const isRunning = !!primary && isRunningButton(primary);
273
+ if (isRunning && !state.inFlight) {
266
274
  state.inFlight = true;
267
275
  state.runStartedAt = Date.now();
268
276
  return;
269
277
  }
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.
278
+ if (!isRunning && state.inFlight) {
274
279
  state.inFlight = false;
275
280
  const startedAt = state.runStartedAt ?? Date.now();
276
281
  const durationMs = Date.now() - startedAt;
277
282
  state.runStartedAt = null;
278
283
  void completeRun(determineStatus(), durationMs);
279
284
  }
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
- });
285
+ }
286
+ function findPrimaryButton() {
287
+ const card = document.querySelector(COMPOSER_CARD_SELECTOR);
288
+ if (!card)
289
+ return null;
290
+ const buttons = card.querySelectorAll('button[type="button"]');
291
+ for (const b of buttons) {
292
+ const aria = (b.getAttribute('aria-label') || '').toLowerCase();
293
+ if (RUNNING_TOKENS.some((t) => aria.includes(t)) ||
294
+ IDLE_TOKENS.some((t) => aria.includes(t))) {
295
+ return b;
296
+ }
303
297
  }
304
- return out;
298
+ if (buttons.length === 1)
299
+ return buttons[0];
300
+ return null;
305
301
  }
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));
302
+ function isRunningButton(btn) {
303
+ const aria = (btn.getAttribute('aria-label') || '').toLowerCase();
304
+ return RUNNING_TOKENS.some((t) => aria.includes(t));
313
305
  }
314
- /**
315
- * Inspect the most recent assistant message to decide success vs error.
316
- * If we can't decide, default to 'success' (the common case).
317
- */
318
306
  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)
307
+ const root = document.querySelector(CONVERSATION_ROOT_SELECTOR);
308
+ const phase = root?.getAttribute('data-phase');
309
+ if (phase === 'settling')
323
310
  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
- }
329
- if (/(stopped by user|user stopped|手动停止|已停止|已取消)/.test(text)) {
330
- return 'stopped';
311
+ const lastAssistant = findLastAssistantTurn();
312
+ if (lastAssistant) {
313
+ const cls = (lastAssistant.getAttribute('class') ?? '').toLowerCase();
314
+ const ds = (lastAssistant.getAttribute('data-state') ?? '').toLowerCase();
315
+ if (ds === 'interrupted' || cls.includes('interrupt') || cls.includes('stop')) {
316
+ return 'stopped';
317
+ }
318
+ if (ds === 'error' || cls.includes('error') || cls.includes('fail')) {
319
+ return 'error';
320
+ }
321
+ const text = (lastAssistant.textContent ?? '').toLowerCase();
322
+ if (/(error|exception|failed|traceback|错误|失败|异常)/.test(text) &&
323
+ !/no error|没有错误|successfully|成功/.test(text)) {
324
+ return 'error';
325
+ }
326
+ if (/(stopped by user|user stopped|手动停止|已停止|已取消)/.test(text)) {
327
+ return 'stopped';
328
+ }
331
329
  }
332
330
  return 'success';
333
331
  }
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
- }
332
+ function findLastAssistantTurn() {
333
+ const scroll = document.querySelector(CONVERSATION_ROOT_SELECTOR);
334
+ if (!scroll)
335
+ return null;
336
+ const candidates = scroll.querySelectorAll('[data-role="assistant"], [data-author="assistant"], [data-author="model"]');
337
+ if (candidates.length)
338
+ return candidates[candidates.length - 1];
339
+ let last = null;
340
+ for (const child of Array.from(scroll.children)) {
341
+ if (child instanceof HTMLElement)
342
+ last = child;
345
343
  }
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;
344
+ return last;
351
345
  }
352
346
  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) {
347
+ const modelCandidates = document.querySelectorAll('[data-model], [data-testid*="model" i]');
348
+ for (const el of modelCandidates) {
364
349
  const text = (el.textContent ?? '').trim();
365
- if (text && text.length < 80)
366
- return text;
350
+ if (text && text.length < 80) {
351
+ state.lastModel = text;
352
+ break;
353
+ }
367
354
  }
368
- return null;
369
- }
370
- function readAgentFromHeader() {
371
- const candidates = document.querySelectorAll('[data-agent], [data-testid*="agent" i]');
372
- for (const el of candidates) {
355
+ const agentCandidates = document.querySelectorAll('[data-agent], [data-testid*="agent" i]');
356
+ for (const el of agentCandidates) {
373
357
  const text = (el.textContent ?? '').trim();
374
- if (text && text.length < 80)
375
- return text;
358
+ if (text && text.length < 80) {
359
+ state.lastAgent = text;
360
+ break;
361
+ }
376
362
  }
377
- return null;
378
363
  }
379
364
  // ──── Visibility / focus suppression ───────────────────────────────────────
380
365
  function bindVisibilityEvents() {
@@ -402,7 +387,6 @@ window.__ModuleLoader__.load({
402
387
  return;
403
388
  if (config.suppressWhenFocused && pageIsFocused())
404
389
  return;
405
- // Cooldown — avoid rapid-fire notifications on tool-call loops.
406
390
  const now = Date.now();
407
391
  if (now - state.lastNotifiedAt < config.cooldownMs)
408
392
  return;
@@ -462,10 +446,8 @@ window.__ModuleLoader__.load({
462
446
  }
463
447
  }
464
448
  }
465
- // ──── browser ──────────────────────────────────────────────────────────────
466
449
  async function deliverBrowser(payload) {
467
450
  if (state.permission === 'unsupported') {
468
- // No Notification API — fall back to an in-page toast.
469
451
  showInPageToast(payload);
470
452
  return;
471
453
  }
@@ -501,7 +483,6 @@ window.__ModuleLoader__.load({
501
483
  };
502
484
  }
503
485
  catch (err) {
504
- // Some browsers throw when called from a non-active tab.
505
486
  showInPageToast(payload, '系统通知失败,已改为页面内提示。');
506
487
  config.onError(toError(err), 'browser');
507
488
  }
@@ -558,52 +539,24 @@ window.__ModuleLoader__.load({
558
539
  style.id = TOAST_STYLE_ID;
559
540
  style.textContent = `
560
541
  .dsh-reminder-toast-root {
561
- position: fixed;
562
- top: 20px;
563
- right: 20px;
564
- z-index: 2147483647;
565
- display: flex;
566
- flex-direction: column;
567
- gap: 8px;
568
- pointer-events: none;
569
- max-width: 360px;
542
+ position: fixed; top: 20px; right: 20px; z-index: 2147483647;
543
+ display: flex; flex-direction: column; gap: 8px; pointer-events: none; max-width: 360px;
570
544
  }
571
545
  .dsh-reminder-toast {
572
546
  pointer-events: auto;
573
- background: ${DSH_CSS_VARS.bgModule};
574
- color: ${DSH_CSS_VARS.labelPrimary};
575
- border: 1px solid ${DSH_CSS_VARS.borderL3};
576
- border-radius: 10px;
547
+ background: ${DSH_CSS_VARS.bgModule}; color: ${DSH_CSS_VARS.labelPrimary};
548
+ border: 1px solid ${DSH_CSS_VARS.borderL3}; border-radius: 10px;
577
549
  box-shadow: ${DSH_CSS_VARS.shadowLv3};
578
- padding: 12px 14px;
579
- font-size: 13px;
580
- line-height: 1.4;
581
- cursor: pointer;
550
+ padding: 12px 14px; font-size: 13px; line-height: 1.4; cursor: pointer;
582
551
  transition: opacity .25s ease, transform .25s ease;
583
552
  }
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
- }
553
+ .dsh-reminder-toast-title { font-weight: 600; margin-bottom: 4px; color: ${DSH_CSS_VARS.labelPrimary}; }
554
+ .dsh-reminder-toast-body { color: ${DSH_CSS_VARS.labelSecondary}; white-space: pre-wrap; word-break: break-word; }
555
+ .dsh-reminder-toast-hint { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; margin-top: 6px; }
556
+ .dsh-reminder-toast-leave { opacity: 0; transform: translateY(-4px); }
603
557
  `;
604
558
  document.head.appendChild(style);
605
559
  }
606
- // ──── Generic fetch helper ─────────────────────────────────────────────────
607
560
  async function postJson(url, body) {
608
561
  const res = await fetch(url, {
609
562
  method: 'POST',
@@ -639,7 +592,6 @@ window.__ModuleLoader__.load({
639
592
  return '';
640
593
  }
641
594
  }
642
- // ──── telegram ─────────────────────────────────────────────────────────────
643
595
  async function deliverTelegram(payload) {
644
596
  const cfg = config.providers;
645
597
  if (!cfg.telegramBotToken || !cfg.telegramChatId) {
@@ -657,13 +609,11 @@ window.__ModuleLoader__.load({
657
609
  function escapeMd(s) {
658
610
  return s.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (m) => `\\${m}`);
659
611
  }
660
- // ──── bark ─────────────────────────────────────────────────────────────────
661
612
  async function deliverBark(payload) {
662
613
  const cfg = config.providers;
663
614
  if (!cfg.barkKey)
664
615
  throw new Error('Bark provider requires barkKey');
665
616
  const server = (cfg.barkServer || 'https://api.day.app').replace(/\/$/, '');
666
- // Bark accepts /:key/:title/:body?url=…&icon=…
667
617
  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
618
  const res = await fetch(url, { method: 'GET' });
669
619
  if (!res.ok) {
@@ -671,7 +621,6 @@ window.__ModuleLoader__.load({
671
621
  throw new Error(`Bark ${res.status}: ${text || res.statusText}`);
672
622
  }
673
623
  }
674
- // ──── pushover ─────────────────────────────────────────────────────────────
675
624
  async function deliverPushover(payload) {
676
625
  const cfg = config.providers;
677
626
  if (!cfg.pushoverToken || !cfg.pushoverUserKey) {
@@ -689,7 +638,6 @@ window.__ModuleLoader__.load({
689
638
  fields.device = cfg.pushoverDevice;
690
639
  await postForm('https://api.pushover.net/1/messages.json', fields);
691
640
  }
692
- // ──── serverchan ───────────────────────────────────────────────────────────
693
641
  async function deliverServerChan(payload) {
694
642
  const cfg = config.providers;
695
643
  if (!cfg.serverchanSendKey)
@@ -699,7 +647,6 @@ window.__ModuleLoader__.load({
699
647
  desp: payload.body + (payload.url ? `\n\n[打开 DSH](${payload.url})` : ''),
700
648
  });
701
649
  }
702
- // ──── discord ──────────────────────────────────────────────────────────────
703
650
  async function deliverDiscord(payload) {
704
651
  const cfg = config.providers;
705
652
  if (!cfg.discordWebhookUrl)
@@ -709,7 +656,6 @@ window.__ModuleLoader__.load({
709
656
  username: 'DSH Reminder',
710
657
  });
711
658
  }
712
- // ──── slack ────────────────────────────────────────────────────────────────
713
659
  async function deliverSlack(payload) {
714
660
  const cfg = config.providers;
715
661
  if (!cfg.slackWebhookUrl)
@@ -718,36 +664,629 @@ window.__ModuleLoader__.load({
718
664
  text: `*${payload.title}*\n${payload.body}${payload.url ? `\n<${payload.url}|Open DSH>` : ''}`,
719
665
  });
720
666
  }
721
- // ──── generic webhook ──────────────────────────────────────────────────────
722
667
  async function deliverWebhook(payload) {
723
668
  const cfg = config.providers;
724
669
  if (!cfg.webhookUrl)
725
670
  throw new Error('Webhook provider requires webhookUrl');
726
- const body = cfg.webhookPayload
727
- ? cfg.webhookPayload(payload)
728
- : payload;
671
+ const body = cfg.webhookPayload ? cfg.webhookPayload(payload) : payload;
729
672
  await postJson(cfg.webhookUrl, body);
730
673
  }
731
- // ──── custom ───────────────────────────────────────────────────────────────
732
674
  async function deliverCustom(payload) {
733
675
  const fn = config.providers.customSend;
734
676
  if (!fn)
735
677
  throw new Error('Custom provider requires providers.customSend');
736
678
  await fn(payload);
737
679
  }
738
- // ──── Helpers ──────────────────────────────────────────────────────────────
739
680
  function toError(value) {
740
681
  if (value instanceof Error)
741
682
  return value;
742
683
  return new Error(typeof value === 'string' ? value : JSON.stringify(value));
743
684
  }
744
- // ──── DSH client plugin entry ──────────────────────────────────────────────
685
+ // ──── Settings dialog integration ──────────────────────────────────────────
686
+ /**
687
+ * Map a provider id to the credential fields it needs. Used to filter
688
+ * the panel so users only see the fields relevant to their choice.
689
+ */
690
+ const PROVIDER_FIELDS = {
691
+ browser: [],
692
+ telegram: [
693
+ { key: 'telegramBotToken', label: 'Bot Token', placeholder: '123456:ABC…' },
694
+ { key: 'telegramChatId', label: 'Chat ID', placeholder: '123456789' },
695
+ ],
696
+ bark: [
697
+ { key: 'barkKey', label: 'Bark Key', placeholder: 'iPhone Bark 设备 Key' },
698
+ { key: 'barkServer', label: 'Bark Server(可选)', placeholder: 'https://api.day.app' },
699
+ ],
700
+ pushover: [
701
+ { key: 'pushoverToken', label: 'App Token', placeholder: 'a…' },
702
+ { key: 'pushoverUserKey', label: 'User Key', placeholder: 'u…' },
703
+ { key: 'pushoverDevice', label: 'Device(可选)', placeholder: '留空推送到所有设备' },
704
+ ],
705
+ serverchan: [
706
+ { key: 'serverchanSendKey', label: 'Server酱 SendKey', placeholder: 'SCT…' },
707
+ ],
708
+ discord: [
709
+ { key: 'discordWebhookUrl', label: 'Discord Webhook URL', placeholder: 'https://discord.com/api/webhooks/…' },
710
+ ],
711
+ slack: [
712
+ { key: 'slackWebhookUrl', label: 'Slack Webhook URL', placeholder: 'https://hooks.slack.com/services/…' },
713
+ ],
714
+ webhook: [
715
+ { key: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://your-service.example/notify' },
716
+ ],
717
+ custom: [
718
+ { key: 'webhookUrl', label: 'Custom provider 占位字段', placeholder: '可填任意值' },
719
+ ],
720
+ };
721
+ const PROVIDER_LABELS = [
722
+ ['browser', '🌐 浏览器通知'],
723
+ ['telegram', '✈️ Telegram'],
724
+ ['bark', '🍎 Bark (iOS)'],
725
+ ['pushover', '📲 Pushover'],
726
+ ['serverchan', '🐦 Server酱'],
727
+ ['discord', '🎮 Discord'],
728
+ ['slack', '💼 Slack'],
729
+ ['webhook', '🔗 通用 Webhook'],
730
+ ['custom', '🛠 自定义'],
731
+ ];
732
+ const PANEL_STYLE_ID = 'dsh-completion-reminder-panel-style';
733
+ /**
734
+ * The Host React component. It renders a stable <div> that the
735
+ * vanilla JS portion of the plugin fills in. This way we get free
736
+ * DSH theming (the host div inherits the dialog's CSS variables) and
737
+ * we don't have to bundle React/JSX-runtime.
738
+ */
739
+ const Host = forwardRef((_props, ref) => {
740
+ const innerRef = useRef(null);
741
+ useImperativeHandle(ref, () => innerRef.current);
742
+ return React.createElement('div', {
743
+ ref: innerRef,
744
+ className: 'dsh-reminder-host',
745
+ 'data-reminder-host': '',
746
+ });
747
+ });
748
+ Host.displayName = 'DSHCompletionReminderHost';
745
749
  /**
746
- * DSH client plugin entry — called by the Cordis Loader.
750
+ * Component registered with DSH's `settings.section` slot. Renders a
751
+ * ref-bound div into the dialog; the ref is captured by the parent
752
+ * closure and populated with the actual settings UI.
753
+ */
754
+ function makeSectionComponent(registerHost) {
755
+ return function ReminderSection() {
756
+ const localRef = useRef(null);
757
+ useEffect(() => {
758
+ if (localRef.current) {
759
+ registerHost(localRef.current);
760
+ }
761
+ return () => {
762
+ if (localRef.current) {
763
+ localRef.current.innerHTML = '';
764
+ }
765
+ };
766
+ }, []);
767
+ return React.createElement(Host, { ref: localRef });
768
+ };
769
+ }
770
+ /**
771
+ * Plugin entry point invoked by the DSH Cordis Loader.
772
+ *
773
+ * In DSH v1.x the host invokes the plugin's exported `apply(ctx, opts)`
774
+ * after the module system has been bootstrapped. We use the context
775
+ * to register a settings section.
747
776
  */
748
777
  function apply(ctx, opts) {
749
778
  configure(opts);
750
779
  activate();
780
+ // Try to register a settings section. If the host doesn't expose
781
+ // `ctx.slots` (older / different host), we silently fall back to the
782
+ // detection-only behaviour and surface a one-time hint.
783
+ try {
784
+ if (ctx && ctx.inject && ctx.slots) {
785
+ const slots = ctx.slots;
786
+ const inject = ctx.inject;
787
+ // The `slots.inject(name, factory)` API registers a slot
788
+ // contributor. The factory returns a teardown function (often
789
+ // `ctx.slots.register(...)`) that adds the entry. We keep the
790
+ // teardown around for symmetry but it is not currently used.
791
+ slots.inject('settings.section', () => {
792
+ // Register the section. The third arg is the React component
793
+ // factory; we provide a component that yields a ref-bound host.
794
+ const sectionFactory = makeSectionComponent((el) => {
795
+ state.panelHostEl = el;
796
+ injectPanelInto(el);
797
+ });
798
+ const off = slots.register({
799
+ name: 'settings.section',
800
+ id: 'reminder',
801
+ order: 50,
802
+ label: () => '🔔 提醒',
803
+ locale: '@dsh-completion-reminder',
804
+ inject: () => ({}),
805
+ }, sectionFactory);
806
+ return off;
807
+ });
808
+ }
809
+ else {
810
+ showFirstRunHint();
811
+ }
812
+ }
813
+ catch (err) {
814
+ try {
815
+ console.warn('[dsh-completion-reminder] failed to register settings section:', err);
816
+ }
817
+ catch { /* noop */ }
818
+ showFirstRunHint();
819
+ }
820
+ }
821
+ /**
822
+ * Show a single, dismissable hint that points the user to the DSH
823
+ * settings dialog. We only show it once and only when the section
824
+ * registration failed (older host, no slots service, etc.).
825
+ */
826
+ function showFirstRunHint() {
827
+ if (state.hintEl || hasPersistedConfig())
828
+ return;
829
+ injectHintStyles();
830
+ const el = document.createElement('div');
831
+ el.className = 'dsh-reminder-hint';
832
+ el.innerHTML = `
833
+ <span>🔔 DSH Completion Reminder 已激活。在「设置 → 🔔 提醒」中配置通知渠道。</span>
834
+ <button type="button" data-reminder-hint-dismiss>知道了</button>
835
+ `;
836
+ el.querySelector('[data-reminder-hint-dismiss]')?.addEventListener('click', () => {
837
+ el.remove();
838
+ state.hintEl = null;
839
+ });
840
+ document.body.appendChild(el);
841
+ state.hintEl = el;
842
+ setTimeout(() => {
843
+ el.classList.add('dsh-reminder-hint-leave');
844
+ setTimeout(() => { el.remove(); state.hintEl = null; }, 600);
845
+ }, 12000);
846
+ }
847
+ function removeHint() {
848
+ if (state.hintEl) {
849
+ state.hintEl.remove();
850
+ state.hintEl = null;
851
+ }
852
+ }
853
+ function hasPersistedConfig() {
854
+ if (typeof localStorage === 'undefined')
855
+ return false;
856
+ try {
857
+ return !!localStorage.getItem(STORAGE_KEY);
858
+ }
859
+ catch {
860
+ return false;
861
+ }
862
+ }
863
+ const HINT_STYLE_ID = 'dsh-completion-reminder-hint-style';
864
+ function injectHintStyles() {
865
+ if (document.getElementById(HINT_STYLE_ID))
866
+ return;
867
+ const style = document.createElement('style');
868
+ style.id = HINT_STYLE_ID;
869
+ style.textContent = `
870
+ .dsh-reminder-hint {
871
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
872
+ z-index: 2147483600;
873
+ display: flex; align-items: center; gap: 10px;
874
+ background: ${DSH_CSS_VARS.bgModule}; color: ${DSH_CSS_VARS.labelPrimary};
875
+ border: 1px solid ${DSH_CSS_VARS.borderL3}; border-radius: 999px;
876
+ box-shadow: ${DSH_CSS_VARS.shadowLv3};
877
+ padding: 8px 8px 8px 14px;
878
+ font-size: 13px; line-height: 1.2;
879
+ transition: opacity .4s, transform .4s;
880
+ }
881
+ .dsh-reminder-hint button {
882
+ background: ${DSH_CSS_VARS.buttonInfoFill}; color: #fff;
883
+ border: none; border-radius: 999px; padding: 4px 10px;
884
+ font-size: 12px; cursor: pointer; font-family: inherit;
885
+ }
886
+ .dsh-reminder-hint-leave { opacity: 0; transform: translate(-50%, 12px); }
887
+ `;
888
+ document.head.appendChild(style);
889
+ }
890
+ // ──── Panel rendering (vanilla DOM inside the DSH settings dialog) ─────────
891
+ /**
892
+ * Provider descriptions shown next to the picker.
893
+ */
894
+ const PROVIDER_DESCRIPTIONS = {
895
+ browser: '使用浏览器原生通知 API,首次使用需用户授权。',
896
+ telegram: '通过 Telegram Bot 推送到你的聊天 / 频道 / 群组。',
897
+ bark: '通过 Bark HTTP API 推送到 iPhone。',
898
+ pushover: '通过 Pushover 推送到 Android / iOS / 桌面。',
899
+ serverchan: '推送到微信(sct.ftqq.com,SendKey)。',
900
+ discord: '通过 Discord Webhook 推送到频道。',
901
+ slack: '通过 Slack Incoming Webhook 推送到频道。',
902
+ webhook: 'POST JSON 到你提供的 URL。',
903
+ custom: '通过 customSend(payload) 函数自己实现。',
904
+ };
905
+ function injectPanelInto(host) {
906
+ state.panelHostEl = host;
907
+ renderPanelInto(host);
908
+ }
909
+ function rerenderPanel() {
910
+ if (state.panelHostEl) {
911
+ renderPanelInto(state.panelHostEl);
912
+ }
913
+ }
914
+ function renderPanelInto(host) {
915
+ injectPanelStyles();
916
+ host.innerHTML = buildPanelHtml();
917
+ wirePanelEvents(host);
918
+ }
919
+ function buildPanelHtml() {
920
+ const providerOptions = PROVIDER_LABELS
921
+ .map(([id, label]) => `<option value="${id}" ${config.provider === id ? 'selected' : ''}>${escapeHtml(label)}</option>`)
922
+ .join('');
923
+ const p = config.providers;
924
+ const fields = PROVIDER_FIELDS[config.provider] ?? [];
925
+ const fieldsHtml = fields.length === 0
926
+ ? `<p class="dsh-reminder-panel-hint">此渠道无需凭证,保存即可使用。</p>`
927
+ : fields.map((f) => {
928
+ const value = p[f.key] ?? '';
929
+ return `
930
+ <label class="dsh-reminder-panel-field">
931
+ <span class="dsh-reminder-panel-label">${escapeHtml(f.label)}</span>
932
+ <input type="text" data-reminder-field="${escapeAttr(f.key)}" value="${escapeAttr(value)}" placeholder="${escapeAttr(f.placeholder)}" autocomplete="off" spellcheck="false" />
933
+ </label>
934
+ `;
935
+ }).join('');
936
+ const desc = PROVIDER_DESCRIPTIONS[config.provider] ?? '';
937
+ return `
938
+ <div class="dsh-reminder-panel">
939
+ <header class="dsh-reminder-panel-header">
940
+ <strong>Agent 完成提醒</strong>
941
+ <span class="dsh-reminder-panel-sub">配置通知渠道,agent 完成后通知你</span>
942
+ </header>
943
+
944
+ <section class="dsh-reminder-panel-section">
945
+ <label class="dsh-reminder-panel-field">
946
+ <span class="dsh-reminder-panel-label">通知渠道</span>
947
+ <select data-reminder-input="provider">${providerOptions}</select>
948
+ </label>
949
+ <p class="dsh-reminder-panel-hint">${escapeHtml(desc)}</p>
950
+ </section>
951
+
952
+ <section class="dsh-reminder-panel-section">
953
+ <header class="dsh-reminder-panel-section-title">凭证(仅当前渠道需要)</header>
954
+ ${fieldsHtml}
955
+ </section>
956
+
957
+ <section class="dsh-reminder-panel-section">
958
+ <header class="dsh-reminder-panel-section-title">行为</header>
959
+ <label class="dsh-reminder-panel-row">
960
+ <input type="checkbox" data-reminder-input="notifyOnSuccess" ${config.notifyOnSuccess ? 'checked' : ''} />
961
+ <span>成功完成时通知</span>
962
+ </label>
963
+ <label class="dsh-reminder-panel-row">
964
+ <input type="checkbox" data-reminder-input="notifyOnStopped" ${config.notifyOnStopped ? 'checked' : ''} />
965
+ <span>用户主动停止时通知</span>
966
+ </label>
967
+ <label class="dsh-reminder-panel-row">
968
+ <input type="checkbox" data-reminder-input="notifyOnError" ${config.notifyOnError ? 'checked' : ''} />
969
+ <span>Agent 出错时通知</span>
970
+ </label>
971
+ <label class="dsh-reminder-panel-row">
972
+ <input type="checkbox" data-reminder-input="suppressWhenFocused" ${config.suppressWhenFocused ? 'checked' : ''} />
973
+ <span>DSH 标签页可见时静默(推荐)</span>
974
+ </label>
975
+ <label class="dsh-reminder-panel-row">
976
+ <input type="checkbox" data-reminder-input="autoRequestPermission" ${config.autoRequestPermission ? 'checked' : ''} />
977
+ <span>自动请求浏览器通知权限</span>
978
+ </label>
979
+ <label class="dsh-reminder-panel-field">
980
+ <span class="dsh-reminder-panel-label">冷却(ms)— 防止连续完成时刷屏</span>
981
+ <input type="number" min="0" step="500" data-reminder-input="cooldownMs" value="${config.cooldownMs}" />
982
+ </label>
983
+ </section>
984
+
985
+ <section class="dsh-reminder-panel-section dsh-reminder-panel-perm">
986
+ <span>当前权限:<strong data-reminder-perm>${permissionLabel(state.permission)}</strong></span>
987
+ <button type="button" data-reminder-action="request-permission">请求权限</button>
988
+ </section>
989
+
990
+ <footer class="dsh-reminder-panel-actions">
991
+ <button type="button" class="primary" data-reminder-action="test">发送测试通知</button>
992
+ <button type="button" data-reminder-action="reset">重置</button>
993
+ <span class="dsh-reminder-panel-status" data-reminder-status></span>
994
+ </footer>
995
+ <p class="dsh-reminder-panel-hint">配置只保存在本浏览器的 localStorage,不会上传任何服务器。</p>
996
+ </div>
997
+ `;
998
+ }
999
+ function wirePanelEvents(host) {
1000
+ const root = host.querySelector('.dsh-reminder-panel');
1001
+ if (!root)
1002
+ return;
1003
+ root.addEventListener('change', (ev) => {
1004
+ const target = ev.target;
1005
+ if (!target)
1006
+ return;
1007
+ if (target instanceof HTMLSelectElement) {
1008
+ const name = target.dataset.reminderInput;
1009
+ if (name === 'provider') {
1010
+ config.provider = target.value;
1011
+ savePersisted();
1012
+ // Re-render so the credentials section updates to the new channel.
1013
+ renderPanelInto(host);
1014
+ }
1015
+ }
1016
+ else if (target instanceof HTMLInputElement) {
1017
+ const name = target.dataset.reminderInput;
1018
+ if (!name)
1019
+ return;
1020
+ const value = target.type === 'checkbox' ? target.checked
1021
+ : target.type === 'number' ? Number(target.value) || 0
1022
+ : target.value;
1023
+ if (name === 'notifyOnSuccess')
1024
+ config.notifyOnSuccess = !!value;
1025
+ else if (name === 'notifyOnStopped')
1026
+ config.notifyOnStopped = !!value;
1027
+ else if (name === 'notifyOnError')
1028
+ config.notifyOnError = !!value;
1029
+ else if (name === 'suppressWhenFocused')
1030
+ config.suppressWhenFocused = !!value;
1031
+ else if (name === 'autoRequestPermission')
1032
+ config.autoRequestPermission = !!value;
1033
+ else if (name === 'cooldownMs')
1034
+ config.cooldownMs = Number(value) || 0;
1035
+ savePersisted();
1036
+ }
1037
+ });
1038
+ root.addEventListener('input', (ev) => {
1039
+ const target = ev.target;
1040
+ if (!target)
1041
+ return;
1042
+ const key = target.dataset.reminderField;
1043
+ if (key) {
1044
+ config.providers[key] = target.value;
1045
+ savePersisted();
1046
+ }
1047
+ });
1048
+ root.querySelector('[data-reminder-action="reset"]')?.addEventListener('click', () => {
1049
+ if (typeof localStorage !== 'undefined') {
1050
+ try {
1051
+ localStorage.removeItem(STORAGE_KEY);
1052
+ }
1053
+ catch { /* noop */ }
1054
+ }
1055
+ configure();
1056
+ renderPanelInto(host);
1057
+ setPanelStatus(root, '已重置 ✓');
1058
+ });
1059
+ root.querySelector('[data-reminder-action="request-permission"]')
1060
+ ?.addEventListener('click', async () => {
1061
+ const result = await requestBrowserPermission();
1062
+ const permEl = root.querySelector('[data-reminder-perm]');
1063
+ if (permEl)
1064
+ permEl.textContent = permissionLabel(result);
1065
+ setPanelStatus(root, result === 'granted' ? '权限已授予' : '权限状态:' + permissionLabel(result));
1066
+ });
1067
+ root.querySelector('[data-reminder-action="test"]')
1068
+ ?.addEventListener('click', async () => {
1069
+ try {
1070
+ const ctx = {
1071
+ status: 'success',
1072
+ durationMs: 4321,
1073
+ completedAt: new Date().toISOString(),
1074
+ url: typeof location !== 'undefined' ? location.href : '',
1075
+ };
1076
+ const payload = {
1077
+ title: '🧪 DSH Completion Reminder — 测试',
1078
+ body: `这是 ${config.provider} 渠道的测试通知。Agent 完成后将使用相同的方式推送。`,
1079
+ url: ctx.url,
1080
+ status: 'success',
1081
+ durationMs: ctx.durationMs,
1082
+ completedAt: ctx.completedAt,
1083
+ };
1084
+ await dispatch(payload);
1085
+ config.onNotify(payload, config.provider);
1086
+ setPanelStatus(root, '测试通知已发送,请查收。');
1087
+ }
1088
+ catch (err) {
1089
+ const e = toError(err);
1090
+ config.onError(e, config.provider);
1091
+ setPanelStatus(root, '测试失败:' + e.message, true);
1092
+ }
1093
+ });
1094
+ }
1095
+ function setPanelStatus(panel, text, isError = false) {
1096
+ const el = panel.querySelector('[data-reminder-status]');
1097
+ if (!el)
1098
+ return;
1099
+ el.textContent = text;
1100
+ el.style.color = isError
1101
+ ? 'var(--dsw-alias-state-error-primary)'
1102
+ : 'var(--dsw-alias-state-success-primary)';
1103
+ setTimeout(() => {
1104
+ if (el.textContent === text)
1105
+ el.textContent = '';
1106
+ }, 5000);
1107
+ }
1108
+ function permissionLabel(p) {
1109
+ if (p === 'granted')
1110
+ return '已授予';
1111
+ if (p === 'denied')
1112
+ return '已拒绝';
1113
+ if (p === 'unsupported')
1114
+ return '不支持';
1115
+ return '未询问';
1116
+ }
1117
+ function escapeHtml(s) {
1118
+ return s
1119
+ .replace(/&/g, '&amp;')
1120
+ .replace(/</g, '&lt;')
1121
+ .replace(/>/g, '&gt;');
1122
+ }
1123
+ function escapeAttr(s) {
1124
+ return escapeHtml(s).replace(/"/g, '&quot;');
1125
+ }
1126
+ function injectPanelStyles() {
1127
+ if (document.getElementById(PANEL_STYLE_ID))
1128
+ return;
1129
+ const style = document.createElement('style');
1130
+ style.id = PANEL_STYLE_ID;
1131
+ // The host's parent (DSH settings dialog) sets `color-scheme: dark`
1132
+ // for the dark theme, so we explicitly hint the dark UA scheme on
1133
+ // selects + add explicit colors that work in both themes. Native
1134
+ // form controls otherwise default to UA colors (white-on-white in
1135
+ // dark mode).
1136
+ style.textContent = `
1137
+ .dsh-reminder-host { color: inherit; }
1138
+ .dsh-reminder-host,
1139
+ .dsh-reminder-host * { box-sizing: border-box; }
1140
+ .dsh-reminder-panel {
1141
+ display: flex;
1142
+ flex-direction: column;
1143
+ gap: 18px;
1144
+ color: ${DSH_CSS_VARS.labelPrimary};
1145
+ font-size: 13px;
1146
+ line-height: 1.5;
1147
+ padding: 4px 4px 24px;
1148
+ }
1149
+ .dsh-reminder-panel-header {
1150
+ display: flex;
1151
+ flex-direction: column;
1152
+ gap: 2px;
1153
+ border-bottom: 1px solid ${DSH_CSS_VARS.borderL1};
1154
+ padding-bottom: 12px;
1155
+ }
1156
+ .dsh-reminder-panel-header strong { font-size: 15px; font-weight: 600; }
1157
+ .dsh-reminder-panel-sub { color: ${DSH_CSS_VARS.labelTertiary}; font-size: 12px; }
1158
+
1159
+ .dsh-reminder-panel-section {
1160
+ display: flex;
1161
+ flex-direction: column;
1162
+ gap: 10px;
1163
+ }
1164
+ .dsh-reminder-panel-section-title {
1165
+ font-size: 12px;
1166
+ color: ${DSH_CSS_VARS.labelSecondary};
1167
+ text-transform: uppercase;
1168
+ letter-spacing: 0.04em;
1169
+ }
1170
+
1171
+ .dsh-reminder-panel-field {
1172
+ display: flex;
1173
+ flex-direction: column;
1174
+ gap: 4px;
1175
+ }
1176
+ .dsh-reminder-panel-label {
1177
+ color: ${DSH_CSS_VARS.labelSecondary};
1178
+ font-size: 12px;
1179
+ }
1180
+ .dsh-reminder-panel-field input[type="text"],
1181
+ .dsh-reminder-panel-field input[type="number"],
1182
+ .dsh-reminder-panel-field select {
1183
+ color-scheme: light dark;
1184
+ width: 100%;
1185
+ background: transparent;
1186
+ color: ${DSH_CSS_VARS.labelPrimary};
1187
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1188
+ border-radius: 6px;
1189
+ padding: 6px 10px;
1190
+ font-size: 13px;
1191
+ font-family: inherit;
1192
+ outline: none;
1193
+ transition: border-color .15s, box-shadow .15s;
1194
+ -webkit-appearance: none;
1195
+ appearance: none;
1196
+ }
1197
+ .dsh-reminder-panel-field select {
1198
+ /* Provide a small caret since we stripped the native chrome. */
1199
+ background-image: linear-gradient(45deg, transparent 50%, ${DSH_CSS_VARS.labelSecondary} 50%),
1200
+ linear-gradient(135deg, ${DSH_CSS_VARS.labelSecondary} 50%, transparent 50%);
1201
+ background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
1202
+ background-size: 5px 5px, 5px 5px;
1203
+ background-repeat: no-repeat;
1204
+ padding-right: 26px;
1205
+ }
1206
+ .dsh-reminder-panel-field input[type="text"]:focus,
1207
+ .dsh-reminder-panel-field input[type="number"]:focus,
1208
+ .dsh-reminder-panel-field select:focus {
1209
+ border-color: ${DSH_CSS_VARS.stateBusinessPrimary};
1210
+ box-shadow: 0 0 0 2px color-mix(in srgb, ${DSH_CSS_VARS.stateBusinessPrimary} 25%, transparent);
1211
+ }
1212
+
1213
+ .dsh-reminder-panel-row {
1214
+ display: flex;
1215
+ align-items: center;
1216
+ gap: 8px;
1217
+ cursor: pointer;
1218
+ user-select: none;
1219
+ }
1220
+ .dsh-reminder-panel-row > input[type="checkbox"] {
1221
+ accent-color: ${DSH_CSS_VARS.buttonInfoFill};
1222
+ }
1223
+
1224
+ .dsh-reminder-panel-hint {
1225
+ color: ${DSH_CSS_VARS.labelTertiary};
1226
+ font-size: 12px;
1227
+ line-height: 1.5;
1228
+ margin: 0;
1229
+ }
1230
+
1231
+ .dsh-reminder-panel-perm {
1232
+ flex-direction: row;
1233
+ align-items: center;
1234
+ justify-content: space-between;
1235
+ flex-wrap: wrap;
1236
+ gap: 8px;
1237
+ padding: 8px 10px;
1238
+ border: 1px dashed ${DSH_CSS_VARS.borderL2};
1239
+ border-radius: 6px;
1240
+ color: ${DSH_CSS_VARS.labelSecondary};
1241
+ font-size: 12px;
1242
+ }
1243
+ .dsh-reminder-panel-perm strong { color: ${DSH_CSS_VARS.labelPrimary}; }
1244
+ .dsh-reminder-panel-perm button {
1245
+ background: transparent;
1246
+ color: ${DSH_CSS_VARS.labelPrimary};
1247
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1248
+ border-radius: 6px;
1249
+ padding: 4px 10px;
1250
+ cursor: pointer;
1251
+ font-size: 12px;
1252
+ font-family: inherit;
1253
+ }
1254
+ .dsh-reminder-panel-perm button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1255
+
1256
+ .dsh-reminder-panel-actions {
1257
+ display: flex;
1258
+ align-items: center;
1259
+ gap: 8px;
1260
+ flex-wrap: wrap;
1261
+ }
1262
+ .dsh-reminder-panel-actions button {
1263
+ padding: 6px 14px;
1264
+ border: 1px solid ${DSH_CSS_VARS.borderL2};
1265
+ background: transparent;
1266
+ color: ${DSH_CSS_VARS.labelPrimary};
1267
+ border-radius: 6px;
1268
+ cursor: pointer;
1269
+ font-size: 13px;
1270
+ font-family: inherit;
1271
+ }
1272
+ .dsh-reminder-panel-actions button:hover { background: ${DSH_CSS_VARS.borderL1}; }
1273
+ .dsh-reminder-panel-actions button.primary {
1274
+ background: ${DSH_CSS_VARS.buttonInfoFill};
1275
+ color: #fff;
1276
+ border-color: transparent;
1277
+ }
1278
+ .dsh-reminder-panel-actions button.primary:hover { filter: brightness(1.05); }
1279
+
1280
+ .dsh-reminder-panel-status {
1281
+ margin-left: auto;
1282
+ color: ${DSH_CSS_VARS.stateSuccessPrimary};
1283
+ font-size: 12px;
1284
+ min-width: 0;
1285
+ flex: 1 1 0;
1286
+ text-align: right;
1287
+ }
1288
+ `;
1289
+ document.head.appendChild(style);
751
1290
  }
752
1291
  exports.configure = configure;
753
1292
  exports.activate = activate;
@@ -755,6 +1294,7 @@ window.__ModuleLoader__.load({
755
1294
  exports.apply = apply;
756
1295
  exports.requestBrowserPermission = requestBrowserPermission;
757
1296
  exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
1297
+ exports.renderPanelInto = renderPanelInto;
758
1298
  //# sourceMappingURL=client.js.map
759
1299
 
760
1300
  // ── export fallback (guard against stripped 'export { ... }' lists) ──