dsh-completion-reminder 1.0.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 +173 -0
- package/cordis.patch.yml +7 -0
- package/dist/dsh-completion-reminder.js +590 -0
- package/install.md +97 -0
- package/lib/client.d.ts +34 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +779 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -0
- package/lib/types.d.ts +208 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +72 -0
- package/lib/types.js.map +1 -0
- package/package.json +68 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-completion-reminder",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
|
|
8
|
+
// ── inlined @dsh-completion-reminder/types ────────────────────
|
|
9
|
+
|
|
10
|
+
const DEFAULT_OPTIONS = {
|
|
11
|
+
provider: 'browser',
|
|
12
|
+
autoRequestPermission: true,
|
|
13
|
+
notifyOnSuccess: true,
|
|
14
|
+
notifyOnStopped: true,
|
|
15
|
+
notifyOnError: true,
|
|
16
|
+
suppressWhenFocused: true,
|
|
17
|
+
cooldownMs: 5000,
|
|
18
|
+
titleTemplate: function (ctx) {
|
|
19
|
+
if (ctx.status === 'success') return '✅ DSH Agent 已完成';
|
|
20
|
+
if (ctx.status === 'stopped') return '⏹ DSH Agent 已停止';
|
|
21
|
+
return '⚠️ DSH Agent 出错';
|
|
22
|
+
},
|
|
23
|
+
bodyTemplate: function (ctx) {
|
|
24
|
+
var parts = [];
|
|
25
|
+
if (ctx.agent) parts.push('Agent: ' + ctx.agent);
|
|
26
|
+
if (ctx.model) parts.push('Model: ' + ctx.model);
|
|
27
|
+
if (typeof ctx.durationMs === 'number') {
|
|
28
|
+
var total = Math.round(ctx.durationMs / 1000);
|
|
29
|
+
if (ctx.durationMs < 1000) parts.push('用时: ' + Math.round(ctx.durationMs) + 'ms');
|
|
30
|
+
else if (total >= 3600) parts.push('用时: ' + Math.floor(total / 3600) + 'h ' + Math.floor((total % 3600) / 60) + 'm');
|
|
31
|
+
else if (total >= 60) parts.push('用时: ' + Math.floor(total / 60) + 'm ' + (total % 60) + 's');
|
|
32
|
+
else parts.push('用时: ' + total + 's');
|
|
33
|
+
}
|
|
34
|
+
if (!parts.length) return '代理任务已结束,点击查看详情。';
|
|
35
|
+
return parts.join(' · ');
|
|
36
|
+
},
|
|
37
|
+
onNotify: function () { return undefined; },
|
|
38
|
+
onError: function (err) { try { console.warn('[dsh-completion-reminder]', err); } catch (_e) {} },
|
|
39
|
+
providers: {},
|
|
40
|
+
clickUrl: '',
|
|
41
|
+
iconUrl: '',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DSH_CSS_VARS = {
|
|
45
|
+
bgModule: 'var(--dsw-alias-bg-module-platform)',
|
|
46
|
+
borderL3: 'var(--dsw-alias-border-l3)',
|
|
47
|
+
labelPrimary: 'var(--dsw-alias-label-primary)',
|
|
48
|
+
labelSecondary: 'var(--dsw-alias-label-secondary)',
|
|
49
|
+
labelTertiary: 'var(--dsw-alias-label-tertiary)',
|
|
50
|
+
stateSuccessPrimary: 'var(--dsw-alias-state-success-primary)',
|
|
51
|
+
stateWarnPrimary: 'var(--dsw-alias-state-warn-primary)',
|
|
52
|
+
stateErrorPrimary: 'var(--dsw-alias-state-error-primary)',
|
|
53
|
+
shadowLv3: 'var(--dsw-shadow-lv3)',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
// ── compiled client code ────────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* DSH Completion Reminder — client half.
|
|
60
|
+
*
|
|
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.
|
|
65
|
+
*
|
|
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
|
+
*
|
|
77
|
+
* The plugin is packaged as a DSH client plugin (`dsh.client` in
|
|
78
|
+
* package.json) and loaded through `window.__ModuleLoader__`.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
let config = { ...DEFAULT_OPTIONS };
|
|
82
|
+
const state = {
|
|
83
|
+
observer: null,
|
|
84
|
+
runStartedAt: null,
|
|
85
|
+
lastModel: null,
|
|
86
|
+
lastAgent: null,
|
|
87
|
+
inFlight: false,
|
|
88
|
+
lastNotifiedAt: 0,
|
|
89
|
+
isActive: false,
|
|
90
|
+
permission: detectPermission(),
|
|
91
|
+
unbinder: [],
|
|
92
|
+
};
|
|
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". */
|
|
115
|
+
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
|
+
'提交',
|
|
133
|
+
];
|
|
134
|
+
/** Selectors for the active conversation. */
|
|
135
|
+
const CONVERSATION_SELECTORS = [
|
|
136
|
+
'[data-conversation-id]',
|
|
137
|
+
'[data-conversation]',
|
|
138
|
+
'main [class*="conversation"]',
|
|
139
|
+
'main [class*="chat"]',
|
|
140
|
+
'main [class*="message"]',
|
|
141
|
+
];
|
|
142
|
+
// ──── Public configure / activate / deactivate ────────────────────────────
|
|
143
|
+
function configure(opts) {
|
|
144
|
+
if (!opts) {
|
|
145
|
+
config = { ...DEFAULT_OPTIONS };
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
config = {
|
|
149
|
+
...DEFAULT_OPTIONS,
|
|
150
|
+
...opts,
|
|
151
|
+
providers: { ...DEFAULT_OPTIONS.providers, ...(opts.providers ?? {}) },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function activate() {
|
|
155
|
+
if (state.isActive)
|
|
156
|
+
return;
|
|
157
|
+
state.isActive = true;
|
|
158
|
+
if (config.provider === 'browser' &&
|
|
159
|
+
config.autoRequestPermission &&
|
|
160
|
+
state.permission === 'default') {
|
|
161
|
+
void requestBrowserPermission();
|
|
162
|
+
}
|
|
163
|
+
startObserver();
|
|
164
|
+
bindVisibilityEvents();
|
|
165
|
+
}
|
|
166
|
+
function deactivate() {
|
|
167
|
+
state.isActive = false;
|
|
168
|
+
stopObserver();
|
|
169
|
+
for (const off of state.unbinder.splice(0)) {
|
|
170
|
+
try {
|
|
171
|
+
off();
|
|
172
|
+
}
|
|
173
|
+
catch { /* noop */ }
|
|
174
|
+
}
|
|
175
|
+
state.runStartedAt = null;
|
|
176
|
+
state.inFlight = false;
|
|
177
|
+
}
|
|
178
|
+
// ──── Permission handling ──────────────────────────────────────────────────
|
|
179
|
+
function detectPermission() {
|
|
180
|
+
if (typeof window === 'undefined' || !('Notification' in window)) {
|
|
181
|
+
return 'unsupported';
|
|
182
|
+
}
|
|
183
|
+
return Notification.permission;
|
|
184
|
+
}
|
|
185
|
+
async function requestBrowserPermission() {
|
|
186
|
+
if (state.permission === 'unsupported')
|
|
187
|
+
return 'unsupported';
|
|
188
|
+
if (Notification.permission !== 'default') {
|
|
189
|
+
state.permission = Notification.permission;
|
|
190
|
+
return Notification.permission;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const result = await Notification.requestPermission();
|
|
194
|
+
state.permission = result;
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
config.onError(toError(err), 'browser');
|
|
199
|
+
return state.permission;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ──── DOM observation ──────────────────────────────────────────────────────
|
|
203
|
+
function startObserver() {
|
|
204
|
+
if (state.observer)
|
|
205
|
+
state.observer.disconnect();
|
|
206
|
+
state.observer = new MutationObserver(handleMutations);
|
|
207
|
+
state.observer.observe(document.body, {
|
|
208
|
+
childList: true,
|
|
209
|
+
subtree: true,
|
|
210
|
+
characterData: true,
|
|
211
|
+
attributes: true,
|
|
212
|
+
attributeFilter: ['aria-label', 'aria-pressed', 'data-state', 'data-status', 'class', 'disabled'],
|
|
213
|
+
});
|
|
214
|
+
// Take an initial reading of the current state.
|
|
215
|
+
scanCurrentRun();
|
|
216
|
+
}
|
|
217
|
+
function stopObserver() {
|
|
218
|
+
if (state.observer) {
|
|
219
|
+
state.observer.disconnect();
|
|
220
|
+
state.observer = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function handleMutations(mutations) {
|
|
224
|
+
if (!state.isActive)
|
|
225
|
+
return;
|
|
226
|
+
for (const m of mutations) {
|
|
227
|
+
// Toolbar changes (send ↔ stop swap) are the strongest signal.
|
|
228
|
+
if (m.type === 'attributes' || m.type === 'characterData') {
|
|
229
|
+
const target = m.target;
|
|
230
|
+
if (target && isInsideToolbar(target)) {
|
|
231
|
+
evaluateToolbar();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// New child nodes can also flip the toolbar; check the subtree root.
|
|
235
|
+
for (const node of m.addedNodes) {
|
|
236
|
+
if (!(node instanceof HTMLElement))
|
|
237
|
+
continue;
|
|
238
|
+
if (node.matches?.(TOOLBAR_BUTTON_SELECTORS.join(','))) {
|
|
239
|
+
evaluateToolbar();
|
|
240
|
+
}
|
|
241
|
+
else if (node.querySelector?.(TOOLBAR_BUTTON_SELECTORS.join(','))) {
|
|
242
|
+
evaluateToolbar();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
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();
|
|
254
|
+
}
|
|
255
|
+
function evaluateToolbar() {
|
|
256
|
+
const buttons = collectToolbarButtons();
|
|
257
|
+
if (!buttons.length)
|
|
258
|
+
return;
|
|
259
|
+
// Look for a button whose text/aria-label signals "running".
|
|
260
|
+
const running = buttons.find((b) => matchesAny(b, RUNNING_TOKENS));
|
|
261
|
+
const idle = buttons.find((b) => matchesAny(b, SUCCESS_TOKENS));
|
|
262
|
+
// Refresh cached model/agent name opportunistically.
|
|
263
|
+
captureRunMetadata();
|
|
264
|
+
if (running && !state.inFlight) {
|
|
265
|
+
// Edge: idle → running
|
|
266
|
+
state.inFlight = true;
|
|
267
|
+
state.runStartedAt = Date.now();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
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.
|
|
274
|
+
state.inFlight = false;
|
|
275
|
+
const startedAt = state.runStartedAt ?? Date.now();
|
|
276
|
+
const durationMs = Date.now() - startedAt;
|
|
277
|
+
state.runStartedAt = null;
|
|
278
|
+
void completeRun(determineStatus(), durationMs);
|
|
279
|
+
}
|
|
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
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
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));
|
|
313
|
+
}
|
|
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
|
+
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)
|
|
323
|
+
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';
|
|
331
|
+
}
|
|
332
|
+
return 'success';
|
|
333
|
+
}
|
|
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
|
+
}
|
|
345
|
+
}
|
|
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;
|
|
351
|
+
}
|
|
352
|
+
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) {
|
|
364
|
+
const text = (el.textContent ?? '').trim();
|
|
365
|
+
if (text && text.length < 80)
|
|
366
|
+
return text;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
function readAgentFromHeader() {
|
|
371
|
+
const candidates = document.querySelectorAll('[data-agent], [data-testid*="agent" i]');
|
|
372
|
+
for (const el of candidates) {
|
|
373
|
+
const text = (el.textContent ?? '').trim();
|
|
374
|
+
if (text && text.length < 80)
|
|
375
|
+
return text;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
// ──── Visibility / focus suppression ───────────────────────────────────────
|
|
380
|
+
function bindVisibilityEvents() {
|
|
381
|
+
if (typeof document === 'undefined')
|
|
382
|
+
return;
|
|
383
|
+
const onVis = () => { };
|
|
384
|
+
document.addEventListener('visibilitychange', onVis);
|
|
385
|
+
state.unbinder.push(() => document.removeEventListener('visibilitychange', onVis));
|
|
386
|
+
const onFocus = () => { };
|
|
387
|
+
window.addEventListener('focus', onFocus);
|
|
388
|
+
state.unbinder.push(() => window.removeEventListener('focus', onFocus));
|
|
389
|
+
}
|
|
390
|
+
function pageIsFocused() {
|
|
391
|
+
if (typeof document === 'undefined')
|
|
392
|
+
return true;
|
|
393
|
+
if (document.visibilityState === 'hidden')
|
|
394
|
+
return false;
|
|
395
|
+
if (document.hasFocus && !document.hasFocus())
|
|
396
|
+
return false;
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
// ──── Completion orchestration ─────────────────────────────────────────────
|
|
400
|
+
async function completeRun(status, durationMs) {
|
|
401
|
+
if (!shouldNotify(status))
|
|
402
|
+
return;
|
|
403
|
+
if (config.suppressWhenFocused && pageIsFocused())
|
|
404
|
+
return;
|
|
405
|
+
// Cooldown — avoid rapid-fire notifications on tool-call loops.
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
if (now - state.lastNotifiedAt < config.cooldownMs)
|
|
408
|
+
return;
|
|
409
|
+
state.lastNotifiedAt = now;
|
|
410
|
+
const ctx = {
|
|
411
|
+
status,
|
|
412
|
+
model: state.lastModel ?? undefined,
|
|
413
|
+
agent: state.lastAgent ?? undefined,
|
|
414
|
+
durationMs,
|
|
415
|
+
completedAt: new Date().toISOString(),
|
|
416
|
+
url: config.clickUrl || (typeof location !== 'undefined' ? location.href : ''),
|
|
417
|
+
};
|
|
418
|
+
const payload = {
|
|
419
|
+
title: config.titleTemplate(ctx),
|
|
420
|
+
body: config.bodyTemplate(ctx),
|
|
421
|
+
url: ctx.url,
|
|
422
|
+
iconUrl: config.iconUrl || undefined,
|
|
423
|
+
status,
|
|
424
|
+
model: ctx.model,
|
|
425
|
+
agent: ctx.agent,
|
|
426
|
+
durationMs,
|
|
427
|
+
completedAt: ctx.completedAt,
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
await dispatch(payload);
|
|
431
|
+
config.onNotify(payload, config.provider);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
config.onError(toError(err), config.provider);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function shouldNotify(status) {
|
|
438
|
+
if (status === 'success')
|
|
439
|
+
return config.notifyOnSuccess;
|
|
440
|
+
if (status === 'stopped')
|
|
441
|
+
return config.notifyOnStopped;
|
|
442
|
+
if (status === 'error')
|
|
443
|
+
return config.notifyOnError;
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
// ──── Provider dispatch ────────────────────────────────────────────────────
|
|
447
|
+
async function dispatch(payload) {
|
|
448
|
+
const provider = config.provider;
|
|
449
|
+
switch (provider) {
|
|
450
|
+
case 'browser': return deliverBrowser(payload);
|
|
451
|
+
case 'telegram': return deliverTelegram(payload);
|
|
452
|
+
case 'bark': return deliverBark(payload);
|
|
453
|
+
case 'pushover': return deliverPushover(payload);
|
|
454
|
+
case 'serverchan': return deliverServerChan(payload);
|
|
455
|
+
case 'discord': return deliverDiscord(payload);
|
|
456
|
+
case 'slack': return deliverSlack(payload);
|
|
457
|
+
case 'webhook': return deliverWebhook(payload);
|
|
458
|
+
case 'custom': return deliverCustom(payload);
|
|
459
|
+
default: {
|
|
460
|
+
const exhaustive = provider;
|
|
461
|
+
throw new Error(`Unknown provider: ${exhaustive}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// ──── browser ──────────────────────────────────────────────────────────────
|
|
466
|
+
async function deliverBrowser(payload) {
|
|
467
|
+
if (state.permission === 'unsupported') {
|
|
468
|
+
// No Notification API — fall back to an in-page toast.
|
|
469
|
+
showInPageToast(payload);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (state.permission !== 'granted') {
|
|
473
|
+
if (config.autoRequestPermission) {
|
|
474
|
+
const next = await requestBrowserPermission();
|
|
475
|
+
if (next !== 'granted') {
|
|
476
|
+
showInPageToast(payload, '未授予通知权限,已改为页面内提示。');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
showInPageToast(payload, '未授予通知权限,已改为页面内提示。');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const n = new Notification(payload.title, {
|
|
487
|
+
body: payload.body,
|
|
488
|
+
icon: payload.iconUrl,
|
|
489
|
+
tag: 'dsh-completion-reminder',
|
|
490
|
+
requireInteraction: false,
|
|
491
|
+
});
|
|
492
|
+
n.onclick = () => {
|
|
493
|
+
try {
|
|
494
|
+
if (typeof window !== 'undefined' && payload.url) {
|
|
495
|
+
window.focus();
|
|
496
|
+
window.open(payload.url, '_self');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch { /* noop */ }
|
|
500
|
+
n.close();
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
// Some browsers throw when called from a non-active tab.
|
|
505
|
+
showInPageToast(payload, '系统通知失败,已改为页面内提示。');
|
|
506
|
+
config.onError(toError(err), 'browser');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function showInPageToast(payload, hint) {
|
|
510
|
+
if (typeof document === 'undefined')
|
|
511
|
+
return;
|
|
512
|
+
const root = ensureToastRoot();
|
|
513
|
+
const card = document.createElement('div');
|
|
514
|
+
card.className = 'dsh-reminder-toast';
|
|
515
|
+
card.setAttribute('role', 'status');
|
|
516
|
+
card.innerHTML = `
|
|
517
|
+
<div class="dsh-reminder-toast-title"></div>
|
|
518
|
+
<div class="dsh-reminder-toast-body"></div>
|
|
519
|
+
${hint ? `<div class="dsh-reminder-toast-hint"></div>` : ''}
|
|
520
|
+
`;
|
|
521
|
+
const titleEl = card.querySelector('.dsh-reminder-toast-title');
|
|
522
|
+
const bodyEl = card.querySelector('.dsh-reminder-toast-body');
|
|
523
|
+
const hintEl = card.querySelector('.dsh-reminder-toast-hint');
|
|
524
|
+
titleEl.textContent = payload.title;
|
|
525
|
+
bodyEl.textContent = payload.body;
|
|
526
|
+
if (hintEl)
|
|
527
|
+
hintEl.textContent = hint ?? '';
|
|
528
|
+
card.addEventListener('click', () => {
|
|
529
|
+
if (payload.url) {
|
|
530
|
+
try {
|
|
531
|
+
window.open(payload.url, '_self');
|
|
532
|
+
}
|
|
533
|
+
catch { /* noop */ }
|
|
534
|
+
}
|
|
535
|
+
card.remove();
|
|
536
|
+
});
|
|
537
|
+
root.appendChild(card);
|
|
538
|
+
setTimeout(() => card.classList.add('dsh-reminder-toast-leave'), 4500);
|
|
539
|
+
setTimeout(() => card.remove(), 5200);
|
|
540
|
+
}
|
|
541
|
+
let toastRoot = null;
|
|
542
|
+
function ensureToastRoot() {
|
|
543
|
+
if (toastRoot && document.body.contains(toastRoot))
|
|
544
|
+
return toastRoot;
|
|
545
|
+
injectToastStyles();
|
|
546
|
+
const root = document.createElement('div');
|
|
547
|
+
root.id = 'dsh-completion-reminder-toasts';
|
|
548
|
+
root.className = 'dsh-reminder-toast-root';
|
|
549
|
+
document.body.appendChild(root);
|
|
550
|
+
toastRoot = root;
|
|
551
|
+
return root;
|
|
552
|
+
}
|
|
553
|
+
const TOAST_STYLE_ID = 'dsh-completion-reminder-toast-style';
|
|
554
|
+
function injectToastStyles() {
|
|
555
|
+
if (document.getElementById(TOAST_STYLE_ID))
|
|
556
|
+
return;
|
|
557
|
+
const style = document.createElement('style');
|
|
558
|
+
style.id = TOAST_STYLE_ID;
|
|
559
|
+
style.textContent = `
|
|
560
|
+
.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;
|
|
570
|
+
}
|
|
571
|
+
.dsh-reminder-toast {
|
|
572
|
+
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;
|
|
577
|
+
box-shadow: ${DSH_CSS_VARS.shadowLv3};
|
|
578
|
+
padding: 12px 14px;
|
|
579
|
+
font-size: 13px;
|
|
580
|
+
line-height: 1.4;
|
|
581
|
+
cursor: pointer;
|
|
582
|
+
transition: opacity .25s ease, transform .25s ease;
|
|
583
|
+
}
|
|
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
|
+
}
|
|
603
|
+
`;
|
|
604
|
+
document.head.appendChild(style);
|
|
605
|
+
}
|
|
606
|
+
// ──── Generic fetch helper ─────────────────────────────────────────────────
|
|
607
|
+
async function postJson(url, body) {
|
|
608
|
+
const res = await fetch(url, {
|
|
609
|
+
method: 'POST',
|
|
610
|
+
headers: { 'Content-Type': 'application/json' },
|
|
611
|
+
body: JSON.stringify(body),
|
|
612
|
+
});
|
|
613
|
+
if (!res.ok) {
|
|
614
|
+
const text = await safeText(res);
|
|
615
|
+
throw new Error(`POST ${url} → ${res.status}: ${text || res.statusText}`);
|
|
616
|
+
}
|
|
617
|
+
return res;
|
|
618
|
+
}
|
|
619
|
+
async function postForm(url, fields) {
|
|
620
|
+
const form = new URLSearchParams();
|
|
621
|
+
for (const [k, v] of Object.entries(fields))
|
|
622
|
+
form.set(k, v);
|
|
623
|
+
const res = await fetch(url, {
|
|
624
|
+
method: 'POST',
|
|
625
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
626
|
+
body: form.toString(),
|
|
627
|
+
});
|
|
628
|
+
if (!res.ok) {
|
|
629
|
+
const text = await safeText(res);
|
|
630
|
+
throw new Error(`POST ${url} → ${res.status}: ${text || res.statusText}`);
|
|
631
|
+
}
|
|
632
|
+
return res;
|
|
633
|
+
}
|
|
634
|
+
async function safeText(res) {
|
|
635
|
+
try {
|
|
636
|
+
return (await res.text()).slice(0, 500);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return '';
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// ──── telegram ─────────────────────────────────────────────────────────────
|
|
643
|
+
async function deliverTelegram(payload) {
|
|
644
|
+
const cfg = config.providers;
|
|
645
|
+
if (!cfg.telegramBotToken || !cfg.telegramChatId) {
|
|
646
|
+
throw new Error('Telegram provider requires telegramBotToken and telegramChatId');
|
|
647
|
+
}
|
|
648
|
+
const url = `https://api.telegram.org/bot${encodeURIComponent(cfg.telegramBotToken)}/sendMessage`;
|
|
649
|
+
const text = `*${escapeMd(payload.title)}*\n${escapeMd(payload.body)}`;
|
|
650
|
+
await postJson(url, {
|
|
651
|
+
chat_id: cfg.telegramChatId,
|
|
652
|
+
text,
|
|
653
|
+
parse_mode: 'Markdown',
|
|
654
|
+
disable_web_page_preview: true,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
function escapeMd(s) {
|
|
658
|
+
return s.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (m) => `\\${m}`);
|
|
659
|
+
}
|
|
660
|
+
// ──── bark ─────────────────────────────────────────────────────────────────
|
|
661
|
+
async function deliverBark(payload) {
|
|
662
|
+
const cfg = config.providers;
|
|
663
|
+
if (!cfg.barkKey)
|
|
664
|
+
throw new Error('Bark provider requires barkKey');
|
|
665
|
+
const server = (cfg.barkServer || 'https://api.day.app').replace(/\/$/, '');
|
|
666
|
+
// Bark accepts /:key/:title/:body?url=…&icon=…
|
|
667
|
+
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
|
+
const res = await fetch(url, { method: 'GET' });
|
|
669
|
+
if (!res.ok) {
|
|
670
|
+
const text = await safeText(res);
|
|
671
|
+
throw new Error(`Bark ${res.status}: ${text || res.statusText}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// ──── pushover ─────────────────────────────────────────────────────────────
|
|
675
|
+
async function deliverPushover(payload) {
|
|
676
|
+
const cfg = config.providers;
|
|
677
|
+
if (!cfg.pushoverToken || !cfg.pushoverUserKey) {
|
|
678
|
+
throw new Error('Pushover provider requires pushoverToken and pushoverUserKey');
|
|
679
|
+
}
|
|
680
|
+
const fields = {
|
|
681
|
+
token: cfg.pushoverToken,
|
|
682
|
+
user: cfg.pushoverUserKey,
|
|
683
|
+
title: payload.title,
|
|
684
|
+
message: payload.body,
|
|
685
|
+
url: payload.url,
|
|
686
|
+
url_title: 'Open DSH',
|
|
687
|
+
};
|
|
688
|
+
if (cfg.pushoverDevice)
|
|
689
|
+
fields.device = cfg.pushoverDevice;
|
|
690
|
+
await postForm('https://api.pushover.net/1/messages.json', fields);
|
|
691
|
+
}
|
|
692
|
+
// ──── serverchan ───────────────────────────────────────────────────────────
|
|
693
|
+
async function deliverServerChan(payload) {
|
|
694
|
+
const cfg = config.providers;
|
|
695
|
+
if (!cfg.serverchanSendKey)
|
|
696
|
+
throw new Error('Server酱 provider requires serverchanSendKey');
|
|
697
|
+
await postForm(`https://sctapi.ftqq.com/${encodeURIComponent(cfg.serverchanSendKey)}.send`, {
|
|
698
|
+
title: payload.title,
|
|
699
|
+
desp: payload.body + (payload.url ? `\n\n[打开 DSH](${payload.url})` : ''),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
// ──── discord ──────────────────────────────────────────────────────────────
|
|
703
|
+
async function deliverDiscord(payload) {
|
|
704
|
+
const cfg = config.providers;
|
|
705
|
+
if (!cfg.discordWebhookUrl)
|
|
706
|
+
throw new Error('Discord provider requires discordWebhookUrl');
|
|
707
|
+
await postJson(cfg.discordWebhookUrl, {
|
|
708
|
+
content: `**${payload.title}**\n${payload.body}${payload.url ? `\n${payload.url}` : ''}`,
|
|
709
|
+
username: 'DSH Reminder',
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
// ──── slack ────────────────────────────────────────────────────────────────
|
|
713
|
+
async function deliverSlack(payload) {
|
|
714
|
+
const cfg = config.providers;
|
|
715
|
+
if (!cfg.slackWebhookUrl)
|
|
716
|
+
throw new Error('Slack provider requires slackWebhookUrl');
|
|
717
|
+
await postJson(cfg.slackWebhookUrl, {
|
|
718
|
+
text: `*${payload.title}*\n${payload.body}${payload.url ? `\n<${payload.url}|Open DSH>` : ''}`,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
// ──── generic webhook ──────────────────────────────────────────────────────
|
|
722
|
+
async function deliverWebhook(payload) {
|
|
723
|
+
const cfg = config.providers;
|
|
724
|
+
if (!cfg.webhookUrl)
|
|
725
|
+
throw new Error('Webhook provider requires webhookUrl');
|
|
726
|
+
const body = cfg.webhookPayload
|
|
727
|
+
? cfg.webhookPayload(payload)
|
|
728
|
+
: payload;
|
|
729
|
+
await postJson(cfg.webhookUrl, body);
|
|
730
|
+
}
|
|
731
|
+
// ──── custom ───────────────────────────────────────────────────────────────
|
|
732
|
+
async function deliverCustom(payload) {
|
|
733
|
+
const fn = config.providers.customSend;
|
|
734
|
+
if (!fn)
|
|
735
|
+
throw new Error('Custom provider requires providers.customSend');
|
|
736
|
+
await fn(payload);
|
|
737
|
+
}
|
|
738
|
+
// ──── Helpers ──────────────────────────────────────────────────────────────
|
|
739
|
+
function toError(value) {
|
|
740
|
+
if (value instanceof Error)
|
|
741
|
+
return value;
|
|
742
|
+
return new Error(typeof value === 'string' ? value : JSON.stringify(value));
|
|
743
|
+
}
|
|
744
|
+
// ──── DSH client plugin entry ──────────────────────────────────────────────
|
|
745
|
+
/**
|
|
746
|
+
* DSH client plugin entry — called by the Cordis Loader.
|
|
747
|
+
*/
|
|
748
|
+
function apply(ctx, opts) {
|
|
749
|
+
configure(opts);
|
|
750
|
+
activate();
|
|
751
|
+
}
|
|
752
|
+
exports.configure = configure;
|
|
753
|
+
exports.activate = activate;
|
|
754
|
+
exports.deactivate = deactivate;
|
|
755
|
+
exports.apply = apply;
|
|
756
|
+
exports.requestBrowserPermission = requestBrowserPermission;
|
|
757
|
+
exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
|
|
758
|
+
//# sourceMappingURL=client.js.map
|
|
759
|
+
|
|
760
|
+
// ── export fallback (guard against stripped 'export { ... }' lists) ──
|
|
761
|
+
if (typeof exports.apply !== 'function' && typeof apply === 'function') {
|
|
762
|
+
exports.apply = apply;
|
|
763
|
+
}
|
|
764
|
+
if (typeof exports.configure !== 'function' && typeof configure === 'function') {
|
|
765
|
+
exports.configure = configure;
|
|
766
|
+
}
|
|
767
|
+
if (typeof exports.activate !== 'function' && typeof activate === 'function') {
|
|
768
|
+
exports.activate = activate;
|
|
769
|
+
}
|
|
770
|
+
if (typeof exports.deactivate !== 'function' && typeof deactivate === 'function') {
|
|
771
|
+
exports.deactivate = deactivate;
|
|
772
|
+
}
|
|
773
|
+
if (typeof exports.requestBrowserPermission !== 'function' && typeof requestBrowserPermission === 'function') {
|
|
774
|
+
exports.requestBrowserPermission = requestBrowserPermission;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return module.exports;
|
|
778
|
+
}
|
|
779
|
+
});
|