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