dsh-notify-tone 0.1.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +16 -12
  2. package/lib/client.js +484 -95
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,23 +1,26 @@
1
- # dsh-notify-tone(提示音插件)
1
+ # dsh-notify-tone(声音 + 视觉 + 系统通知提醒插件)
2
2
 
3
- dsh web GUI 的提示音插件:
3
+ dsh web GUI 的提醒插件:
4
4
 
5
- - **需要操作提醒**:AI 执行任务中遇到需要用户授权或选择时(沙箱权限授权弹窗、`ask_user_question` 提问、计划审批等),播放急促提示音,提醒你回到窗口处理。
6
- - **回答完成提醒**:AI 每轮回答结束时,播放提示音,提醒你回复已生成。
5
+ - **需要操作提醒**:AI 执行任务中遇到需要用户授权或选择时(沙箱权限授权弹窗、`ask_user_question` 提问、计划审批等),同时触发提示音、屏幕光效与系统通知,提醒你回到窗口处理。
6
+ - **回答完成提醒**:AI 每轮回答结束时,同样触发三重提醒,提醒你回复已生成。
7
7
 
8
8
  ## 特性
9
9
 
10
- - **零依赖**:只用浏览器 Web Audio API 合成提示音(基频 + 泛音,声音更明显),无需任何音频文件。
11
- - **持久生效**:开关状态与所选提示音都保存在 `localStorage`,刷新页面、重启后依然保持。
12
- - **悬浮设置菜单**:鼠标悬停页面右下角的 🔔 按钮,自动弹出二级菜单:
13
- - **功能开关**:一键开/关(打开时播放当前音色试听反馈);
14
- - **提示音选择**:内置 4 套提示音——经典双音、清脆叮咚(叮——)、明亮三连、柔和轻音,点击选项即选中生效(带 ✓ 标记);
15
- - **试听预览**:每套音色右侧有「▶ 试听」按钮,点击可先听完整效果(需要操作 + 回答完成连播),再决定是否应用。
16
- - **不侵入**:不改动 dsh 任何原有组件与第三方插件;插件只做「监听会话状态 + 发声 + 一个悬浮按钮/菜单」。
10
+ - **声音提醒**:Web Audio 合成(基频 + 泛音,无音频文件),内置 **5 套音色**——经典双音、清脆叮咚(叮——)、明亮三连、柔和轻音、**尖锐警示**(方波高频,最醒目),点击即可试听并选中。
11
+ - **视觉提醒**:屏幕四周大圆弧光带 + 泡泡式弹性向内挤压脉冲(柔和、不遮挡、不拦截鼠标);「需要操作」「回答完成」**各自独立配色**(8 种预设 + 🌈 自定义色相),选色即时预览。
12
+ - **系统通知**:触发时弹出**操作系统级桌面通知**(含系统声音),**在浏览器外、其他窗口、浏览器最小化时也能看到**;点击通知可跳回 dsh 窗口。首次使用需在浏览器中授权(打开开关时自动请求)。
13
+ - **分别开关**:总开关、声音开关、视觉开关、系统通知开关**四者独立**,互不影响。
14
+ - **持久生效**:所有开关、音色、颜色都存 `localStorage`,重启后保持;旧版设置(`dsh.notify-sound.*`)自动迁移。
15
+ - **悬浮设置菜单**:鼠标悬停页面右下角 🔔 按钮自动弹出:总开关、声音/视觉/系统通知分别开关、音色选择(点击试听)、颜色选择(展开面板,点击预览)。
16
+ - **不侵入**:不改动 dsh 任何原有组件与第三方插件;插件只做「监听会话状态 + 发声 + 视觉层 + 系统通知 + 一个悬浮按钮/菜单」。
17
+ ![alt text](/image_1.jpg)
18
+ ![alt text](/image_3.jpg)
19
+ ![alt text](/image_2.jpg)
17
20
 
18
21
  ## 安装
19
22
 
20
- ### 方式一:npm 发布安装(推荐,需先发布到 npm)
23
+ ### 方式一:npm 安装(已发布到npm)
21
24
 
22
25
  ```bash
23
26
  dsh plugin --profile web add dsh-notify-tone
@@ -54,4 +57,5 @@ dsh plugin --profile web add link:/绝对路径/dsh-notify-tone
54
57
  ## 故障排查
55
58
 
56
59
  - 听不到声音:浏览器自动播放策略要求页面至少被点击过一次;点击页面任意位置后再试。
60
+ - 收不到系统通知:① 打开 🔔 菜单「系统通知」开关并允许浏览器的授权询问;② 若显示"权限已被禁用",需到浏览器站点设置(地址栏左侧锁图标 → 网站设置)中把「通知」改为允许;③ Windows 还需确认系统"通知和操作"未对浏览器静音。
57
61
  - 若之后运行过 `pnpm install` 导致手动放置的包被清理,请重新复制本目录到 `~/.dsh/profiles/web/node_modules/`(或在 profile 目录执行 `dsh plugin --profile web add link:<本目录绝对路径>`)。
package/lib/client.js CHANGED
@@ -2,17 +2,20 @@
2
2
  //
3
3
  // 功能:
4
4
  // 1. AI 执行任务中需要用户授权/选择时(sandbox 授权弹窗、ask_user_question、
5
- // plan review 等,即会话出现 pendingInteraction)播放「需要操作」提示音;
6
- // 2. AI 每轮回答结束时(会话 running -> false)播放「回答完成」提示音。
7
- // 3. 多种提示音可选(经典双音 / 清脆叮咚 / 明亮三连 / 柔和轻音),
8
- // 鼠标悬停 🔔 按钮弹出设置菜单:功能开关、提示音选择、点击试听预览。
5
+ // plan review 等,即会话出现 pendingInteraction)触发「需要操作」提醒;
6
+ // 2. AI 每轮回答结束时(会话 running -> false)触发「回答完成」提醒。
7
+ // 3. 提醒 = 声音 + 视觉,可分别开关:
8
+ // - 声音:多套内置音色(经典双音 / 清脆叮咚 / 明亮三连 / 柔和轻音),
9
+ // 用 Web Audio 合成(基频 + 泛音,无需音频文件);
10
+ // - 视觉:屏幕四周大圆弧光带 + 泡泡式弹性向内挤压脉冲,
11
+ // 「需要操作」「回答完成」各自独立配色(预设 8 色 + 自定义色相)。
12
+ // 4. 鼠标悬停 🔔 按钮弹出设置菜单:总开关、声音/视觉分别开关、
13
+ // 音色选择(点击试听)、颜色选择(点击预览)。
9
14
  //
10
15
  // 实现要点:
11
- // - 零依赖:不 require 任何 dsh 客户端模块,只用注入的 ctx.sessions 服务
12
- // Web Audio API(无需音频文件)。
13
- // - 声音更明显:基频 + 泛音合成、指数衰减包络、音量提升。
14
- // - 开关与所选提示音存 localStorage,持久生效。
15
- // - 不改动 dsh 任何原有组件:只做「监听 + 发声 + 一个悬浮按钮/菜单」。
16
+ // - 零依赖:不 require 任何 dsh 客户端模块,只用注入的 ctx.sessions 服务。
17
+ // - 所有设置存 localStorage,持久生效;旧版 key(dsh.notify-sound.*)自动迁移。
18
+ // - 不改动 dsh 任何原有组件:只做「监听 + 发声 + 视觉层 + 一个悬浮按钮/菜单」。
16
19
 
17
20
  window.__ModuleLoader__.load({
18
21
  id: "dsh-notify-tone",
@@ -22,15 +25,21 @@ window.__ModuleLoader__.load({
22
25
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
23
26
 
24
27
  // ===================== 常量 =====================
25
- const KEY_ENABLED = "dsh.notify-sound.enabled";
26
- const KEY_PRESET = "dsh.notify-sound.preset";
28
+ const KEY_ENABLED = "dsh.notify-tone.enabled";
29
+ const KEY_SOUND_ENABLED = "dsh.notify-tone.sound.enabled";
30
+ const KEY_PRESET = "dsh.notify-tone.sound.preset";
31
+ const KEY_VISUAL_ENABLED = "dsh.notify-tone.visual.enabled";
32
+ const KEY_HUE_INTERACT = "dsh.notify-tone.visual.interactHue";
33
+ const KEY_HUE_DONE = "dsh.notify-tone.visual.doneHue";
34
+ const KEY_NOTIFICATION_ENABLED = "dsh.notify-tone.notification.enabled";
35
+
27
36
  const ROOT_ID = "dsh-notify-tone-root";
28
37
  const TOGGLE_ID = "dsh-notify-tone-toggle";
29
38
  const MENU_ID = "dsh-notify-tone-menu";
39
+ const FX_ID = "dsh-notify-tone-fx";
40
+ const FX_STYLE_ID = "dsh-notify-tone-fx-style";
30
41
 
31
42
  // ===================== 提示音预设 =====================
32
- // 每个预设含 interact(需要授权/选择)与 done(回答完成)两组音符。
33
- // 音符格式:[频率Hz, 时长s];gap 为音符间隔(秒)。
34
43
  const PRESETS = {
35
44
  classic: {
36
45
  label: "经典双音",
@@ -52,11 +61,43 @@ window.__ModuleLoader__.load({
52
61
  interact: { tones: [[523, 0.2], [659, 0.25]], gap: 0.09 },
53
62
  done: { tones: [[659, 0.28]], gap: 0 },
54
63
  },
64
+ sharp: {
65
+ label: "尖锐警示",
66
+ wave: "square",
67
+ volume: 0.26,
68
+ interact: { tones: [[1760, 0.09], [2093, 0.09], [2637, 0.16]], gap: 0.06 },
69
+ done: { tones: [[2093, 0.28]], gap: 0 },
70
+ },
55
71
  };
56
72
  const PRESET_IDS = Object.keys(PRESETS);
57
- /** 默认音色:清脆叮咚(比经典双音更明显,如「叮——」)。 */
58
73
  const DEFAULT_PRESET = "ding";
59
74
 
75
+ // 预设颜色(色相值),与 demo 一致
76
+ const COLOR_PRESETS = [35, 15, 0, 320, 270, 215, 180, 145];
77
+ const DEFAULT_HUE_INTERACT = 35;
78
+ const DEFAULT_HUE_DONE = 145;
79
+
80
+ // 视觉固定参数(demo 确认过的默认值;暂不提供菜单滑块,保持简洁)
81
+ const VISUAL = { amp: "26px", glow: "0.55", dur: "1.3s", radius: "34px" };
82
+
83
+ // ===================== 旧版设置迁移(dsh.notify-sound.* → dsh.notify-tone.*) =====================
84
+ (function migrateOldKeys() {
85
+ try {
86
+ const map = {
87
+ "dsh.notify-sound.enabled": KEY_ENABLED,
88
+ "dsh.notify-sound.preset": KEY_PRESET,
89
+ };
90
+ for (const [oldKey, newKey] of Object.entries(map)) {
91
+ if (localStorage.getItem(oldKey) !== null && localStorage.getItem(newKey) === null) {
92
+ localStorage.setItem(newKey, localStorage.getItem(oldKey));
93
+ localStorage.removeItem(oldKey);
94
+ }
95
+ }
96
+ } catch {
97
+ /* 迁移失败不影响使用 */
98
+ }
99
+ })();
100
+
60
101
  // ===================== 声音引擎(Web Audio,无音频文件) =====================
61
102
  let audioCtx = null;
62
103
 
@@ -79,13 +120,13 @@ window.__ModuleLoader__.load({
79
120
 
80
121
  /**
81
122
  * 播放一个音符:基频 + 2 倍频泛音(更明亮、更明显),指数衰减包络。
123
+ * @param wave - 波形(sine/square/triangle/sawtooth),尖锐音用 square。
82
124
  */
83
- function playTone(ctx, freq, dur, start, volume) {
125
+ function playTone(ctx, freq, dur, start, volume, wave) {
84
126
  const stop = start + dur + 0.05;
85
- // 基频
86
127
  const osc = ctx.createOscillator();
87
128
  const gain = ctx.createGain();
88
- osc.type = "sine";
129
+ osc.type = wave ?? "sine";
89
130
  osc.frequency.setValueAtTime(freq, start);
90
131
  gain.gain.setValueAtTime(0.0001, start);
91
132
  gain.gain.exponentialRampToValueAtTime(volume, start + 0.02);
@@ -97,7 +138,7 @@ window.__ModuleLoader__.load({
97
138
  // 泛音(2 倍频,音量更低,增强穿透力)
98
139
  const osc2 = ctx.createOscillator();
99
140
  const gain2 = ctx.createGain();
100
- osc2.type = "sine";
141
+ osc2.type = wave ?? "sine";
101
142
  osc2.frequency.setValueAtTime(freq * 2, start);
102
143
  gain2.gain.setValueAtTime(0.0001, start);
103
144
  gain2.gain.exponentialRampToValueAtTime(volume * 0.35, start + 0.02);
@@ -108,18 +149,105 @@ window.__ModuleLoader__.load({
108
149
  osc2.stop(stop);
109
150
  }
110
151
 
111
- /** 按音符序列播放一组提示音。 */
112
- function playPreset(tones, gap) {
152
+ /** 按音符序列播放一组提示音(支持预设波形与音量)。 */
153
+ function playPreset(tones, gap, wave, volume) {
113
154
  const ctx = ensureAudio();
114
155
  if (ctx === null) return;
115
- const volume = 0.22;
156
+ const vol = volume ?? 0.22;
116
157
  let t = ctx.currentTime + 0.02;
117
158
  for (const [freq, dur] of tones) {
118
- playTone(ctx, freq, dur, t, volume);
159
+ playTone(ctx, freq, dur, t, vol, wave);
119
160
  t += dur + (gap ?? 0);
120
161
  }
121
162
  }
122
163
 
164
+ // ===================== 视觉层 CSS(注入 <style>) =====================
165
+ const FX_CSS = `
166
+ #${FX_ID} {
167
+ position: fixed; inset: 0;
168
+ pointer-events: none;
169
+ z-index: 2147483000;
170
+ --hue: 35;
171
+ --amp: ${VISUAL.amp};
172
+ --glow: ${VISUAL.glow};
173
+ --dur: ${VISUAL.dur};
174
+ --radius: ${VISUAL.radius};
175
+ }
176
+ #${FX_ID} .halo {
177
+ position: absolute; inset: 0;
178
+ border-radius: var(--radius);
179
+ opacity: 0;
180
+ box-shadow:
181
+ inset 0 0 calc(150px * var(--glow)) calc(22px * var(--glow)) hsla(var(--hue), 90%, 62%, calc(var(--glow) * 0.5)),
182
+ inset 0 0 calc(50px * var(--glow)) calc(10px * var(--glow)) hsla(var(--hue), 95%, 70%, calc(var(--glow) * 0.4));
183
+ }
184
+ #${FX_ID} .arc {
185
+ position: absolute;
186
+ opacity: 0;
187
+ filter: blur(3px);
188
+ }
189
+ #${FX_ID} .arc.top {
190
+ top: -40px; left: -8%; right: -8%; height: 280px;
191
+ background: radial-gradient(150% 100% at 50% 0%, hsla(var(--hue), 95%, 74%, calc(var(--glow) * 0.95)), transparent 60%);
192
+ }
193
+ #${FX_ID} .arc.bottom {
194
+ bottom: -40px; left: -8%; right: -8%; height: 280px;
195
+ background: radial-gradient(150% 100% at 50% 100%, hsla(var(--hue), 95%, 74%, calc(var(--glow) * 0.95)), transparent 60%);
196
+ }
197
+ #${FX_ID} .arc.left {
198
+ left: -40px; top: -8%; bottom: -8%; width: 280px;
199
+ background: radial-gradient(100% 150% at 0% 50%, hsla(var(--hue), 95%, 74%, calc(var(--glow) * 0.95)), transparent 60%);
200
+ }
201
+ #${FX_ID} .arc.right {
202
+ right: -40px; top: -8%; bottom: -8%; width: 280px;
203
+ background: radial-gradient(100% 150% at 100% 50%, hsla(var(--hue), 95%, 74%, calc(var(--glow) * 0.95)), transparent 60%);
204
+ }
205
+ #${FX_ID}.play .halo { animation: dshnt-halo var(--dur) ease-out forwards; }
206
+ #${FX_ID}.play .arc.top { animation: dshnt-pop-top var(--dur) cubic-bezier(.22, 1.5, .36, 1) forwards; }
207
+ #${FX_ID}.play .arc.bottom { animation: dshnt-pop-bottom var(--dur) cubic-bezier(.22, 1.5, .36, 1) forwards; }
208
+ #${FX_ID}.play .arc.left { animation: dshnt-pop-left var(--dur) cubic-bezier(.22, 1.5, .36, 1) forwards; }
209
+ #${FX_ID}.play .arc.right { animation: dshnt-pop-right var(--dur) cubic-bezier(.22, 1.5, .36, 1) forwards; }
210
+ @keyframes dshnt-halo {
211
+ 0% { opacity: 0; }
212
+ 12% { opacity: calc(var(--glow)); }
213
+ 40% { opacity: calc(var(--glow) * 1.0); }
214
+ 72% { opacity: calc(var(--glow) * 0.5); }
215
+ 100% { opacity: 0; }
216
+ }
217
+ @keyframes dshnt-pop-top {
218
+ 0% { opacity: 0; transform: translateY(0); }
219
+ 10% { opacity: 1; }
220
+ 38% { opacity: 1; transform: translateY(var(--amp)); }
221
+ 62% { transform: translateY(calc(var(--amp) * -0.12)); }
222
+ 82% { transform: translateY(0); }
223
+ 100% { opacity: 0; }
224
+ }
225
+ @keyframes dshnt-pop-bottom {
226
+ 0% { opacity: 0; transform: translateY(0); }
227
+ 10% { opacity: 1; }
228
+ 38% { opacity: 1; transform: translateY(calc(var(--amp) * -1)); }
229
+ 62% { transform: translateY(calc(var(--amp) * 0.12)); }
230
+ 82% { transform: translateY(0); }
231
+ 100% { opacity: 0; }
232
+ }
233
+ @keyframes dshnt-pop-left {
234
+ 0% { opacity: 0; transform: translateX(0); }
235
+ 10% { opacity: 1; }
236
+ 38% { opacity: 1; transform: translateX(var(--amp)); }
237
+ 62% { transform: translateX(calc(var(--amp) * -0.12)); }
238
+ 82% { transform: translateX(0); }
239
+ 100% { opacity: 0; }
240
+ }
241
+ @keyframes dshnt-pop-right {
242
+ 0% { opacity: 0; transform: translateX(0); }
243
+ 10% { opacity: 1; }
244
+ 38% { opacity: 1; transform: translateX(calc(var(--amp) * -1)); }
245
+ 62% { transform: translateX(calc(var(--amp) * 0.12)); }
246
+ 82% { transform: translateX(0); }
247
+ 100% { opacity: 0; }
248
+ }
249
+ `;
250
+
123
251
  // ===================== 状态(localStorage 持久化) =====================
124
252
  function storageGet(key, fallback) {
125
253
  try {
@@ -137,6 +265,7 @@ window.__ModuleLoader__.load({
137
265
  }
138
266
  }
139
267
 
268
+ // 总开关
140
269
  function isEnabled() {
141
270
  return storageGet(KEY_ENABLED, "true") !== "false";
142
271
  }
@@ -144,6 +273,23 @@ window.__ModuleLoader__.load({
144
273
  storageSet(KEY_ENABLED, value ? "true" : "false");
145
274
  }
146
275
 
276
+ // 声音开关
277
+ function isSoundEnabled() {
278
+ return storageGet(KEY_SOUND_ENABLED, "true") !== "false";
279
+ }
280
+ function setSoundEnabled(value) {
281
+ storageSet(KEY_SOUND_ENABLED, value ? "true" : "false");
282
+ }
283
+
284
+ // 视觉开关
285
+ function isVisualEnabled() {
286
+ return storageGet(KEY_VISUAL_ENABLED, "true") !== "false";
287
+ }
288
+ function setVisualEnabled(value) {
289
+ storageSet(KEY_VISUAL_ENABLED, value ? "true" : "false");
290
+ }
291
+
292
+ // 音色预设
147
293
  function currentPresetId() {
148
294
  const id = storageGet(KEY_PRESET, DEFAULT_PRESET);
149
295
  return PRESETS[id] ? id : DEFAULT_PRESET;
@@ -152,6 +298,65 @@ window.__ModuleLoader__.load({
152
298
  if (PRESETS[id]) storageSet(KEY_PRESET, id);
153
299
  }
154
300
 
301
+ // 各功能颜色(色相)
302
+ function interactHue() {
303
+ const v = Number(storageGet(KEY_HUE_INTERACT, String(DEFAULT_HUE_INTERACT)));
304
+ return Number.isFinite(v) ? v : DEFAULT_HUE_INTERACT;
305
+ }
306
+ function doneHue() {
307
+ const v = Number(storageGet(KEY_HUE_DONE, String(DEFAULT_HUE_DONE)));
308
+ return Number.isFinite(v) ? v : DEFAULT_HUE_DONE;
309
+ }
310
+ function setInteractHue(h) {
311
+ if (Number.isFinite(h)) storageSet(KEY_HUE_INTERACT, String(Math.round(h)));
312
+ }
313
+ function setDoneHue(h) {
314
+ if (Number.isFinite(h)) storageSet(KEY_HUE_DONE, String(Math.round(h)));
315
+ }
316
+
317
+ // 系统通知开关
318
+ function isNotificationEnabled() {
319
+ return storageGet(KEY_NOTIFICATION_ENABLED, "true") !== "false";
320
+ }
321
+ function setNotificationEnabled(value) {
322
+ storageSet(KEY_NOTIFICATION_ENABLED, value ? "true" : "false");
323
+ }
324
+
325
+ // ===================== 系统通知(桌面通知,浏览器外可见) =====================
326
+ /** 若权限未定,请求一次通知权限(需在用户交互后调用才有效)。 */
327
+ function ensureNotificationPermission() {
328
+ if (typeof Notification === "undefined") return;
329
+ if (Notification.permission === "default" && typeof Notification.requestPermission === "function") {
330
+ Notification.requestPermission().catch(() => {});
331
+ }
332
+ }
333
+
334
+ /** 弹桌面系统通知(任何窗口下都能看到;随系统声音提醒)。 */
335
+ function notifyUser(kind) {
336
+ if (typeof Notification === "undefined") return;
337
+ if (Notification.permission !== "granted") return;
338
+ const isInteract = kind === "interact";
339
+ try {
340
+ const n = new Notification(
341
+ isInteract ? "dsh:AI 需要你的操作" : "dsh:AI 回答完成",
342
+ {
343
+ body: isInteract
344
+ ? "有授权或选择等待处理,请回到 dsh 窗口"
345
+ : "本轮回答已生成,可以回来查看了",
346
+ tag: "dsh-notify-tone",
347
+ }
348
+ );
349
+ n.onclick = () => {
350
+ try {
351
+ if (typeof window !== "undefined" && typeof window.focus === "function") window.focus();
352
+ } catch { /* 忽略 */ }
353
+ n.close();
354
+ };
355
+ } catch {
356
+ /* 通知被浏览器拦截时静默 */
357
+ }
358
+ }
359
+
155
360
  // ===================== 事件节流(防连发) =====================
156
361
  const lastPlayed = { interact: 0, done: 0 };
157
362
  function throttledPlay(kind, minGapMs) {
@@ -159,13 +364,21 @@ window.__ModuleLoader__.load({
159
364
  const now = Date.now();
160
365
  if (now - lastPlayed[kind] < minGapMs) return;
161
366
  lastPlayed[kind] = now;
162
- const spec = PRESETS[currentPresetId()][kind];
163
- playPreset(spec.tones, spec.gap);
367
+ // 声音 + 视觉 + 系统通知同时触发(各自可独立开关)
368
+ if (isSoundEnabled()) {
369
+ const preset = PRESETS[currentPresetId()];
370
+ const spec = preset[kind];
371
+ playPreset(spec.tones, spec.gap, preset.wave, preset.volume);
372
+ }
373
+ if (isVisualEnabled()) {
374
+ playFx(kind);
375
+ }
376
+ if (isNotificationEnabled()) {
377
+ notifyUser(kind);
378
+ }
164
379
  }
165
380
 
166
381
  // ===================== 会话状态订阅 =====================
167
- // 基线:id -> { running, pending }。首次快照只建基线不发声,
168
- // 避免页面刷新时对「正在运行」的会话误报。
169
382
  let prevBySession = new Map();
170
383
 
171
384
  function handleListChange(ctx) {
@@ -184,22 +397,54 @@ window.__ModuleLoader__.load({
184
397
  for (const [id, cur] of now) {
185
398
  const prev = prevBySession.get(id);
186
399
  if (prev === undefined) continue; // 新出现的会话:只入基线
187
- // 需要用户授权/选择:pendingInteraction 从无到有
188
400
  if (prev.pending === undefined && cur.pending !== undefined) {
189
401
  throttledPlay("interact", 1200);
190
402
  }
191
- // 每轮回答结束:running true -> false
192
403
  if (prev.running && !cur.running) {
193
404
  throttledPlay("done", 2000);
194
405
  }
195
406
  }
196
- // 已被移除的会话从基线中清理
197
407
  for (const id of prevBySession.keys()) {
198
408
  if (!now.has(id)) prevBySession.delete(id);
199
409
  }
200
410
  prevBySession = now;
201
411
  }
202
412
 
413
+ // ===================== 视觉层(挂载 + 播放) =====================
414
+ function mountFx() {
415
+ if (typeof document === "undefined") return;
416
+ if (document.getElementById(FX_ID)) return;
417
+ if (!document.body || !document.head) {
418
+ document.addEventListener("DOMContentLoaded", mountFx, { once: true });
419
+ return;
420
+ }
421
+ if (!document.getElementById(FX_STYLE_ID)) {
422
+ const style = document.createElement("style");
423
+ style.id = FX_STYLE_ID;
424
+ style.textContent = FX_CSS;
425
+ document.head.appendChild(style);
426
+ }
427
+ const root = document.createElement("div");
428
+ root.id = FX_ID;
429
+ root.innerHTML =
430
+ '<div class="halo"></div>' +
431
+ '<div class="arc top"></div>' +
432
+ '<div class="arc bottom"></div>' +
433
+ '<div class="arc left"></div>' +
434
+ '<div class="arc right"></div>';
435
+ document.body.appendChild(root);
436
+ }
437
+
438
+ function playFx(kind) {
439
+ if (typeof document === "undefined") return;
440
+ const root = document.getElementById(FX_ID);
441
+ if (!root) return;
442
+ root.style.setProperty("--hue", kind === "interact" ? interactHue() : doneHue());
443
+ root.classList.remove("play");
444
+ void root.offsetWidth; // 强制 reflow 重启动画
445
+ root.classList.add("play");
446
+ }
447
+
203
448
  // ===================== 悬浮按钮 + hover 设置菜单 =====================
204
449
  function el(tag, text, css) {
205
450
  const node = document.createElement(tag);
@@ -212,79 +457,209 @@ window.__ModuleLoader__.load({
212
457
  const on = isEnabled();
213
458
  btn.textContent = on ? "🔔" : "🔕";
214
459
  btn.title = on
215
- ? "提示音:已开启。鼠标悬停可设置音色/开关"
216
- : "提示音:已关闭。鼠标悬停可设置音色/开关";
217
- btn.setAttribute("aria-label", on ? "提示音已开启" : "提示音已关闭");
460
+ ? "提醒已开启。鼠标悬停可设置声音/视觉/颜色"
461
+ : "提醒已关闭。鼠标悬停可设置";
462
+ btn.setAttribute("aria-label", on ? "提醒已开启" : "提醒已关闭");
218
463
  btn.style.opacity = on ? "0.9" : "0.55";
219
464
  }
220
465
 
221
- /** 渲染设置菜单内容(每次调用重建,简单可靠)。 */
466
+ // 菜单内通用小组件
467
+ function switchButton(current, onToggle) {
468
+ const btn = el("button", current ? "已开启" : "已关闭",
469
+ "font-family:system-ui,sans-serif;border:1px solid rgba(128,128,128,0.4);background:rgba(255,255,255,0.08);color:#e8e8ea;border-radius:6px;padding:1px 9px;cursor:pointer;font-size:12px;");
470
+ btn.addEventListener("click", (event) => {
471
+ event.stopPropagation();
472
+ onToggle(!current);
473
+ });
474
+ return btn;
475
+ }
476
+
477
+ function presetButtons(onPick) {
478
+ const wrap = el("div", "", "display:flex;flex-wrap:wrap;gap:5px;margin-top:4px;");
479
+ for (const id of PRESET_IDS) {
480
+ const p = PRESETS[id];
481
+ const sel = id === currentPresetId();
482
+ const b = el("button", p.label,
483
+ "font-family:system-ui,sans-serif;font-size:11px;border-radius:6px;padding:3px 8px;cursor:pointer;border:1px solid " +
484
+ (sel ? "rgba(122,184,255,0.9)" : "rgba(128,128,128,0.35)") + ";" +
485
+ (sel ? "background:rgba(64,128,255,0.28);color:#dbe9ff;" : "background:rgba(255,255,255,0.06);color:#e8e8ea;"));
486
+ b.addEventListener("click", (event) => {
487
+ event.stopPropagation();
488
+ onPick(id);
489
+ });
490
+ wrap.appendChild(b);
491
+ }
492
+ return wrap;
493
+ }
494
+
495
+ /**
496
+ * 颜色选择器:预设色块 + 自定义色相滑块。
497
+ * @param currentHue - 当前色相。
498
+ * @param onPick - 颜色变化回调(只改值 + 预览,不重建菜单,保证滑块可连续拖动)。
499
+ * @param onCommitted - 提交回调(重建菜单刷新选中态;色块点击与滑块松手时调用)。
500
+ */
501
+ function colorPicker(currentHue, onPick, onCommitted) {
502
+ const wrap = el("div", "", "display:flex;flex-direction:column;gap:4px;margin-top:4px;");
503
+ const dots = el("div", "", "display:flex;gap:4px;flex-wrap:wrap;");
504
+ for (const h of COLOR_PRESETS) {
505
+ const sel = Math.abs(h - currentHue) < 2;
506
+ const d = el("div", "", "width:16px;height:16px;border-radius:50%;cursor:pointer;flex:none;background:hsl(" + h + " 85% 60%);border:1px solid rgba(255,255,255,0.35);box-sizing:border-box;" + (sel ? "outline:2px solid #fff;outline-offset:1px;" : ""));
507
+ d.addEventListener("click", (event) => {
508
+ event.stopPropagation();
509
+ onPick(h);
510
+ if (typeof onCommitted === "function") onCommitted();
511
+ });
512
+ dots.appendChild(d);
513
+ }
514
+ wrap.appendChild(dots);
515
+ const hueRow = el("div", "", "display:flex;align-items:center;gap:6px;");
516
+ hueRow.appendChild(el("span", "🌈", "font-size:12px;"));
517
+ const slider = document.createElement("input");
518
+ slider.type = "range";
519
+ slider.min = "0";
520
+ slider.max = "360";
521
+ slider.step = "1";
522
+ slider.value = String(Math.round(currentHue));
523
+ slider.style.cssText = "flex:1;accent-color:#58a6ff;height:18px;";
524
+ slider.addEventListener("input", () => {
525
+ onPick(Number(slider.value)); // 拖动中只改色+预览,不重建菜单
526
+ });
527
+ slider.addEventListener("change", () => {
528
+ if (typeof onCommitted === "function") onCommitted(); // 松手后重建刷新 UI
529
+ });
530
+ hueRow.appendChild(slider);
531
+ wrap.appendChild(hueRow);
532
+ return wrap;
533
+ }
534
+
535
+ let expandedColor = null; // "interact" | "done" | null(菜单中当前展开的颜色选择器)
536
+
222
537
  function renderMenu(menu, btn) {
223
538
  updateToggle(btn);
224
539
  menu.textContent = "";
225
540
  const base = "font-family:system-ui,-apple-system,sans-serif;";
226
541
 
227
- menu.appendChild(el("div", "🔔 提示音设置", `${base}font-weight:600;font-size:13px;margin-bottom:8px;`));
542
+ menu.appendChild(el("div", "🔔 提醒设置", base + "font-weight:600;font-size:13px;margin-bottom:8px;"));
228
543
 
229
- // —— 功能开关 ——
230
- const toggleRow = el("div", "", `${base}display:flex;justify-content:space-between;align-items:center;padding:5px 2px;`);
231
- toggleRow.appendChild(el("span", "功能开关"));
232
- const sw = el("button", isEnabled() ? "已开启" : "已关闭", `${base}border:1px solid rgba(128,128,128,0.4);background:rgba(255,255,255,0.08);color:#e8e8ea;border-radius:6px;padding:2px 10px;cursor:pointer;font-size:12px;`);
233
- sw.addEventListener("click", (event) => {
234
- event.stopPropagation();
235
- const next = !isEnabled();
544
+ // —— 总开关 ——
545
+ const masterRow = el("div", "", base + "display:flex;justify-content:space-between;align-items:center;padding:4px 2px;");
546
+ masterRow.appendChild(el("span", "功能开关"));
547
+ masterRow.appendChild(switchButton(isEnabled(), (next) => {
236
548
  setEnabled(next);
237
549
  renderMenu(menu, btn);
550
+ if (next && isSoundEnabled()) {
551
+ const preset = PRESETS[currentPresetId()];
552
+ const spec = preset.done;
553
+ playPreset(spec.tones, spec.gap, preset.wave, preset.volume);
554
+ }
555
+ }));
556
+ menu.appendChild(masterRow);
557
+
558
+ menu.appendChild(el("div", "", "border-top:1px solid rgba(128,128,128,0.22);margin:7px 0;"));
559
+
560
+ // —— 声音提醒 ——
561
+ const soundRow = el("div", "", base + "display:flex;justify-content:space-between;align-items:center;padding:4px 2px;");
562
+ soundRow.appendChild(el("span", "声音提醒"));
563
+ soundRow.appendChild(switchButton(isSoundEnabled(), (next) => {
564
+ setSoundEnabled(next);
565
+ renderMenu(menu, btn);
238
566
  if (next) {
239
- const spec = PRESETS[currentPresetId()].done;
240
- playPreset(spec.tones, spec.gap); // 打开时试听当前音色的「完成」音
567
+ const preset = PRESETS[currentPresetId()];
568
+ const spec = preset.done;
569
+ playPreset(spec.tones, spec.gap, preset.wave, preset.volume);
241
570
  }
242
- });
243
- toggleRow.appendChild(sw);
244
- menu.appendChild(toggleRow);
571
+ }));
572
+ menu.appendChild(soundRow);
573
+ if (isSoundEnabled()) {
574
+ menu.appendChild(presetButtons((id) => {
575
+ setPresetId(id);
576
+ const preset = PRESETS[id];
577
+ const spec = preset.interact;
578
+ playPreset(spec.tones, spec.gap, preset.wave, preset.volume); // 点击音色试听
579
+ renderMenu(menu, btn);
580
+ }));
581
+ }
245
582
 
246
- menu.appendChild(el("div", "", "border-top:1px solid rgba(128,128,128,0.22);margin:8px 0;"));
583
+ menu.appendChild(el("div", "", "border-top:1px solid rgba(128,128,128,0.22);margin:7px 0;"));
247
584
 
248
- // —— 提示音选择 ——
249
- menu.appendChild(el("div", "提示音选择", `${base}font-weight:600;font-size:13px;margin-bottom:6px;`));
250
- const active = currentPresetId();
251
- for (const id of PRESET_IDS) {
252
- const preset = PRESETS[id];
253
- const selected = id === active;
254
- const row = el("div", "", `${base}display:flex;justify-content:space-between;align-items:center;padding:5px 6px;border-radius:6px;cursor:pointer;${selected ? "background:rgba(64,128,255,0.22);" : ""}`);
255
- row.addEventListener("mouseenter", () => {
256
- row.style.background = "rgba(255,255,255,0.1)";
257
- });
258
- row.addEventListener("mouseleave", () => {
259
- row.style.background = id === currentPresetId() ? "rgba(64,128,255,0.22)" : "";
260
- });
261
- row.appendChild(el("span", preset.label + (selected ? " ✓" : "")));
262
- const preview = el("button", "▶ 试听", `${base}border:none;background:transparent;color:#7ab8ff;cursor:pointer;font-size:12px;padding:0 2px;`);
263
- preview.addEventListener("click", (event) => {
585
+ // —— 视觉提醒 ——
586
+ const visualRow = el("div", "", base + "display:flex;justify-content:space-between;align-items:center;padding:4px 2px;");
587
+ visualRow.appendChild(el("span", "视觉提醒"));
588
+ visualRow.appendChild(switchButton(isVisualEnabled(), (next) => {
589
+ setVisualEnabled(next);
590
+ renderMenu(menu, btn);
591
+ if (next) playFx("done");
592
+ }));
593
+ menu.appendChild(visualRow);
594
+
595
+ if (isVisualEnabled()) {
596
+ // 「需要操作」颜色
597
+ const iRow = el("div", "", base + "display:flex;align-items:center;gap:6px;padding:3px 2px;cursor:pointer;");
598
+ const iDot = el("span", "", "width:13px;height:13px;border-radius:50%;flex:none;background:hsl(" + interactHue() + " 85% 60%);");
599
+ iRow.appendChild(iDot);
600
+ iRow.appendChild(el("span", "需要操作", base + "flex:1;font-size:12px;"));
601
+ const iBtn = el("button", expandedColor === "interact" ? "▴" : "▾", base + "border:none;background:transparent;color:#7ab8ff;cursor:pointer;font-size:12px;");
602
+ iRow.appendChild(iBtn);
603
+ iRow.addEventListener("click", (event) => {
604
+ // 必须阻止冒泡:renderMenu 会移除本元素,事件若冒泡到 document
605
+ // 的「点击外部关闭」监听,contains(已移除元素) 为 false 会误关菜单
264
606
  event.stopPropagation();
265
- previewSound(id); // 先试听再决定是否应用
607
+ expandedColor = expandedColor === "interact" ? null : "interact";
608
+ renderMenu(menu, btn);
266
609
  });
267
- row.addEventListener("click", () => {
268
- setPresetId(id);
610
+ menu.appendChild(iRow);
611
+ if (expandedColor === "interact") {
612
+ menu.appendChild(colorPicker(interactHue(),
613
+ (h) => { // onPick:改色 + 预览,不重建(滑块可连续拖动)
614
+ setInteractHue(h);
615
+ playFx("interact");
616
+ },
617
+ () => renderMenu(menu, btn) // onCommitted:提交后重建刷新选中态
618
+ ));
619
+ }
620
+
621
+ // 「回答完成」颜色
622
+ const dRow = el("div", "", base + "display:flex;align-items:center;gap:6px;padding:3px 2px;cursor:pointer;");
623
+ const dDot = el("span", "", "width:13px;height:13px;border-radius:50%;flex:none;background:hsl(" + doneHue() + " 85% 60%);");
624
+ dRow.appendChild(dDot);
625
+ dRow.appendChild(el("span", "回答完成", base + "flex:1;font-size:12px;"));
626
+ const dBtn = el("button", expandedColor === "done" ? "▴" : "▾", base + "border:none;background:transparent;color:#7ab8ff;cursor:pointer;font-size:12px;");
627
+ dRow.appendChild(dBtn);
628
+ dRow.addEventListener("click", (event) => {
629
+ event.stopPropagation(); // 同上:防止重建后误关菜单
630
+ expandedColor = expandedColor === "done" ? null : "done";
269
631
  renderMenu(menu, btn);
270
632
  });
271
- row.appendChild(preview);
272
- menu.appendChild(row);
633
+ menu.appendChild(dRow);
634
+ if (expandedColor === "done") {
635
+ menu.appendChild(colorPicker(doneHue(),
636
+ (h) => {
637
+ setDoneHue(h);
638
+ playFx("done");
639
+ },
640
+ () => renderMenu(menu, btn)
641
+ ));
642
+ }
273
643
  }
274
644
 
275
- menu.appendChild(el("div", "点击选项选中即生效;▶ 可先试听", `${base}color:rgba(255,255,255,0.45);font-size:11px;margin-top:8px;`));
276
- }
645
+ menu.appendChild(el("div", "", "border-top:1px solid rgba(128,128,128,0.22);margin:7px 0;"));
277
646
 
278
- /** 试听某预设:连播「需要操作」+「回答完成」两段,让用户听完整效果。 */
279
- function previewSound(presetId) {
280
- const preset = PRESETS[presetId];
281
- if (!preset) return;
282
- // 两段连播:interact 播完 0.35s 后再播 done
283
- playPreset(preset.interact.tones, preset.interact.gap);
284
- const total = preset.interact.tones.reduce((acc, [, dur]) => acc + dur, 0) + preset.interact.gap * (preset.interact.tones.length - 1);
285
- setTimeout(() => {
286
- playPreset(preset.done.tones, preset.done.gap);
287
- }, (total + 0.35) * 1000);
647
+ // —— 系统通知 ——
648
+ const notifRow = el("div", "", base + "display:flex;justify-content:space-between;align-items:center;padding:4px 2px;");
649
+ notifRow.appendChild(el("span", "系统通知"));
650
+ notifRow.appendChild(switchButton(isNotificationEnabled(), (next) => {
651
+ setNotificationEnabled(next);
652
+ if (next) ensureNotificationPermission(); // 打开时若未授权则请求权限
653
+ renderMenu(menu, btn);
654
+ }));
655
+ menu.appendChild(notifRow);
656
+ if (typeof Notification !== "undefined" && Notification.permission === "denied") {
657
+ menu.appendChild(el("div", "⚠ 通知权限已在浏览器设置中禁用,请到浏览器站点设置中开启", base + "color:rgba(255,160,120,0.9);font-size:11px;padding:1px 2px 4px;"));
658
+ } else if (typeof Notification !== "undefined" && Notification.permission === "default" && isNotificationEnabled()) {
659
+ menu.appendChild(el("div", "ℹ 尚未授权:打开开关或点击上方任意提醒会请求通知权限", base + "color:rgba(255,255,255,0.45);font-size:11px;padding:1px 2px 4px;"));
660
+ }
661
+
662
+ menu.appendChild(el("div", "声音 / 视觉 / 系统通知 各自独立开关;颜色各功能独立", base + "color:rgba(255,255,255,0.45);font-size:11px;margin-top:8px;"));
288
663
  }
289
664
 
290
665
  function mountToggle() {
@@ -295,9 +670,9 @@ window.__ModuleLoader__.load({
295
670
  return;
296
671
  }
297
672
 
298
- const root = el("div", "", "position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;");
673
+ const root = el("div", "", "position:fixed;right:18px;bottom:18px;z-index:2147483001;display:flex;flex-direction:column;align-items:flex-end;");
299
674
 
300
- const menu = el("div", "", "display:none;margin-bottom:8px;min-width:230px;background:rgba(24,24,30,0.96);border:1px solid rgba(128,128,128,0.3);border-radius:10px;padding:10px 12px;box-shadow:0 6px 24px rgba(0,0,0,0.45);backdrop-filter:blur(6px);color:#e8e8ea;");
675
+ const menu = el("div", "", "display:none;margin-bottom:8px;min-width:240px;max-height:64vh;overflow-y:auto;background:rgba(24,24,30,0.96);border:1px solid rgba(128,128,128,0.3);border-radius:10px;padding:10px 12px;box-shadow:0 6px 24px rgba(0,0,0,0.45);backdrop-filter:blur(6px);color:#e8e8ea;");
301
676
  root.appendChild(menu);
302
677
 
303
678
  const btn = el("button", "🔔", "width:38px;height:38px;border-radius:50%;border:1px solid rgba(128,128,128,0.35);background:rgba(28,28,34,0.75);color:#fff;font-size:18px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,0.35);backdrop-filter:blur(4px);transition:opacity .15s ease;");
@@ -309,16 +684,16 @@ window.__ModuleLoader__.load({
309
684
  setEnabled(next);
310
685
  updateToggle(btn);
311
686
  renderMenu(menu, btn);
312
- if (next) {
313
- const spec = PRESETS[currentPresetId()].done;
314
- playPreset(spec.tones, spec.gap);
687
+ if (next && isSoundEnabled()) {
688
+ const preset = PRESETS[currentPresetId()];
689
+ const spec = preset.done;
690
+ playPreset(spec.tones, spec.gap, preset.wave, preset.volume);
315
691
  }
316
692
  });
317
693
  root.appendChild(btn);
318
694
 
319
695
  document.body.appendChild(root);
320
696
 
321
- // hover 显示菜单(延迟 120ms),移出后延迟 300ms 关闭(留出移到菜单的时间)
322
697
  let hideTimer = null;
323
698
  root.addEventListener("mouseenter", () => {
324
699
  clearTimeout(hideTimer);
@@ -329,16 +704,18 @@ window.__ModuleLoader__.load({
329
704
  clearTimeout(hideTimer);
330
705
  hideTimer = setTimeout(() => {
331
706
  menu.style.display = "none";
707
+ expandedColor = null;
332
708
  }, 300);
333
709
  });
334
- // 点击菜单外部区域时关闭菜单
335
710
  document.addEventListener("click", (event) => {
336
- if (!root.contains(event.target)) menu.style.display = "none";
711
+ if (!root.contains(event.target)) {
712
+ menu.style.display = "none";
713
+ expandedColor = null;
714
+ }
337
715
  });
338
716
  }
339
717
 
340
718
  // 浏览器自动播放策略:需要用户先与页面交互过,AudioContext 才会出声。
341
- // 捕获任意一次用户点击来预热音频上下文。
342
719
  function warmupAudio() {
343
720
  if (typeof document === "undefined") return;
344
721
  const unlock = () => ensureAudio();
@@ -347,17 +724,14 @@ window.__ModuleLoader__.load({
347
724
  }
348
725
 
349
726
  // ===================== 插件主体 =====================
350
- // 注意:inject 是 cordis「服务名」列表(fiber 会等待这些服务可用才激活),
351
- // 不是包名/模块 id。sessions 服务由 @deepseek-ai/dsh-client-runtime 提供
352
- // (rootCtx.reflect.provide("sessions", ...))。
727
+ // inject 是 cordis「服务名」列表(fiber 等待服务可用才激活),
728
+ // 不是包名/模块 id。sessions 服务由 @deepseek-ai/dsh-client-runtime 提供。
353
729
  const inject = ["sessions"];
354
730
 
355
731
  function apply(ctx) {
356
- // 订阅会话列表状态变化(running / pendingInteraction)
357
732
  const list = ctx && ctx.sessions && ctx.sessions.list;
358
733
  if (list && typeof list.subscribe === "function") {
359
734
  ctx.effect(() => {
360
- // 先建立基线快照,再订阅后续变化
361
735
  const bootstrap = () => {
362
736
  const snap = list.getSnapshot();
363
737
  prevBySession = new Map();
@@ -375,9 +749,10 @@ window.__ModuleLoader__.load({
375
749
  };
376
750
  }, "dsh-notify-tone: sessions subscription");
377
751
  } else {
378
- console.warn("[dsh-notify-tone] ctx.sessions.list 不可用,提示音功能未启用");
752
+ console.warn("[dsh-notify-tone] ctx.sessions.list 不可用,提醒功能未启用");
379
753
  }
380
754
 
755
+ mountFx();
381
756
  mountToggle();
382
757
  warmupAudio();
383
758
  }
@@ -391,9 +766,23 @@ window.__ModuleLoader__.load({
391
766
  setPresetId,
392
767
  isEnabled,
393
768
  setEnabled,
769
+ isSoundEnabled,
770
+ setSoundEnabled,
771
+ isVisualEnabled,
772
+ setVisualEnabled,
773
+ isNotificationEnabled,
774
+ setNotificationEnabled,
775
+ notifyUser,
776
+ ensureNotificationPermission,
777
+ interactHue,
778
+ doneHue,
779
+ setInteractHue,
780
+ setDoneHue,
394
781
  playPreset,
395
782
  playTone,
396
783
  ensureAudio,
784
+ playFx,
785
+ FX_ID,
397
786
  resetThrottle() {
398
787
  lastPlayed.interact = 0;
399
788
  lastPlayed.done = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-tone",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "dsh web GUI 提示音插件:AI 需要用户授权/选择时与每轮回答结束时播放提示音提醒用户。多套内置音色可选(经典双音/清脆叮咚/明亮三连/柔和轻音),悬停铃铛按钮可开关、选音色并试听,状态持久化。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",