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/README.md +94 -113
- package/dist/dsh-completion-reminder.js +1 -0
- package/install.md +30 -44
- package/lib/client.d.ts +24 -18
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +780 -240
- package/lib/client.js.map +1 -1
- package/lib/types.d.ts +31 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +16 -1
- package/lib/types.js.map +1 -1
- package/package.json +3 -2
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* button
|
|
64
|
-
*
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
117
|
+
// ──── Public API ───────────────────────────────────────────────────────────
|
|
143
118
|
function configure(opts) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
...
|
|
151
|
-
providers: { ...DEFAULT_OPTIONS.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-
|
|
235
|
+
attributeFilter: ['aria-label', 'aria-disabled', 'data-phase', 'data-state', 'class', 'disabled'],
|
|
213
236
|
});
|
|
214
|
-
|
|
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
|
-
|
|
228
|
-
if (m.type === 'attributes' || m.type === 'characterData') {
|
|
249
|
+
if (m.type === 'attributes') {
|
|
229
250
|
const target = m.target;
|
|
230
|
-
if (target &&
|
|
231
|
-
|
|
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?.(
|
|
239
|
-
|
|
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
|
|
248
|
-
return !!el.closest(
|
|
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
|
|
256
|
-
const
|
|
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
|
-
|
|
265
|
-
|
|
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 (!
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
+
if (buttons.length === 1)
|
|
299
|
+
return buttons[0];
|
|
300
|
+
return null;
|
|
305
301
|
}
|
|
306
|
-
function
|
|
307
|
-
const
|
|
308
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
354
|
-
|
|
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
|
-
|
|
350
|
+
if (text && text.length < 80) {
|
|
351
|
+
state.lastModel = text;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
367
354
|
}
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
// ────
|
|
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
|
-
*
|
|
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, '&')
|
|
1120
|
+
.replace(/</g, '<')
|
|
1121
|
+
.replace(/>/g, '>');
|
|
1122
|
+
}
|
|
1123
|
+
function escapeAttr(s) {
|
|
1124
|
+
return escapeHtml(s).replace(/"/g, '"');
|
|
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) ──
|