dsh-notify-tone 0.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/LICENSE +21 -0
- package/README.md +57 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +407 -0
- package/lib/index.js +7 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-notify-tone contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# dsh-notify-tone(提示音插件)
|
|
2
|
+
|
|
3
|
+
dsh web GUI 的提示音插件:
|
|
4
|
+
|
|
5
|
+
- **需要操作提醒**:AI 执行任务中遇到需要用户授权或选择时(沙箱权限授权弹窗、`ask_user_question` 提问、计划审批等),播放急促提示音,提醒你回到窗口处理。
|
|
6
|
+
- **回答完成提醒**:AI 每轮回答结束时,播放提示音,提醒你回复已生成。
|
|
7
|
+
|
|
8
|
+
## 特性
|
|
9
|
+
|
|
10
|
+
- **零依赖**:只用浏览器 Web Audio API 合成提示音(基频 + 泛音,声音更明显),无需任何音频文件。
|
|
11
|
+
- **持久生效**:开关状态与所选提示音都保存在 `localStorage`,刷新页面、重启后依然保持。
|
|
12
|
+
- **悬浮设置菜单**:鼠标悬停页面右下角的 🔔 按钮,自动弹出二级菜单:
|
|
13
|
+
- **功能开关**:一键开/关(打开时播放当前音色试听反馈);
|
|
14
|
+
- **提示音选择**:内置 4 套提示音——经典双音、清脆叮咚(叮——)、明亮三连、柔和轻音,点击选项即选中生效(带 ✓ 标记);
|
|
15
|
+
- **试听预览**:每套音色右侧有「▶ 试听」按钮,点击可先听完整效果(需要操作 + 回答完成连播),再决定是否应用。
|
|
16
|
+
- **不侵入**:不改动 dsh 任何原有组件与第三方插件;插件只做「监听会话状态 + 发声 + 一个悬浮按钮/菜单」。
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
### 方式一:npm 发布安装(推荐,需先发布到 npm)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
dsh plugin --profile web add dsh-notify-tone
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 方式二:从 GitHub 直接安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
dsh plugin --profile web add git+https://github.com/<你的GitHub用户名>/dsh-notify-tone.git
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 方式三:本地链接(开发调试)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dsh plugin --profile web add link:/绝对路径/dsh-notify-tone
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
安装后**重启 dsh**(完全退出 `dsh web` 进程再启动)即生效。
|
|
39
|
+
|
|
40
|
+
本机当前以本地目录方式挂载:包位于 `~/.dsh/profiles/web/node_modules/dsh-notify-tone/`,
|
|
41
|
+
启用条目在 `~/.dsh/profiles/web/cordis.patch.yml`:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- insert:
|
|
45
|
+
- id: notify-tone
|
|
46
|
+
name: 'dsh-notify-tone'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 卸载
|
|
50
|
+
|
|
51
|
+
- npm/GitHub 方式:`dsh plugin --profile web remove dsh-notify-tone`,重启 dsh。
|
|
52
|
+
- 本地目录方式:删除 `cordis.patch.yml` 中的 `notify-tone` 插入行,删除 `node_modules/dsh-notify-tone/` 目录,重启 dsh。
|
|
53
|
+
|
|
54
|
+
## 故障排查
|
|
55
|
+
|
|
56
|
+
- 听不到声音:浏览器自动播放策略要求页面至少被点击过一次;点击页面任意位置后再试。
|
|
57
|
+
- 若之后运行过 `pnpm install` 导致手动放置的包被清理,请重新复制本目录到 `~/.dsh/profiles/web/node_modules/`(或在 profile 目录执行 `dsh plugin --profile web add link:<本目录绝对路径>`)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-notify-tone bundle patch: inserts the plugin row into the web profile
|
|
2
|
+
# roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
|
|
3
|
+
# field) over dsh-base.
|
|
4
|
+
#
|
|
5
|
+
# 提示音功能:AI 需要授权/选择时与每轮回答结束时播放提示音。
|
|
6
|
+
- insert:
|
|
7
|
+
- id: notify-tone
|
|
8
|
+
name: 'dsh-notify-tone'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// dsh-notify-tone — 浏览器端(web GUI)插件。
|
|
2
|
+
//
|
|
3
|
+
// 功能:
|
|
4
|
+
// 1. AI 执行任务中需要用户授权/选择时(sandbox 授权弹窗、ask_user_question、
|
|
5
|
+
// plan review 等,即会话出现 pendingInteraction)播放「需要操作」提示音;
|
|
6
|
+
// 2. AI 每轮回答结束时(会话 running -> false)播放「回答完成」提示音。
|
|
7
|
+
// 3. 多种提示音可选(经典双音 / 清脆叮咚 / 明亮三连 / 柔和轻音),
|
|
8
|
+
// 鼠标悬停 🔔 按钮弹出设置菜单:功能开关、提示音选择、点击试听预览。
|
|
9
|
+
//
|
|
10
|
+
// 实现要点:
|
|
11
|
+
// - 零依赖:不 require 任何 dsh 客户端模块,只用注入的 ctx.sessions 服务
|
|
12
|
+
// 与 Web Audio API(无需音频文件)。
|
|
13
|
+
// - 声音更明显:基频 + 泛音合成、指数衰减包络、音量提升。
|
|
14
|
+
// - 开关与所选提示音存 localStorage,持久生效。
|
|
15
|
+
// - 不改动 dsh 任何原有组件:只做「监听 + 发声 + 一个悬浮按钮/菜单」。
|
|
16
|
+
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: "dsh-notify-tone",
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
var module = { exports: {} };
|
|
21
|
+
var exports = module.exports;
|
|
22
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
23
|
+
|
|
24
|
+
// ===================== 常量 =====================
|
|
25
|
+
const KEY_ENABLED = "dsh.notify-sound.enabled";
|
|
26
|
+
const KEY_PRESET = "dsh.notify-sound.preset";
|
|
27
|
+
const ROOT_ID = "dsh-notify-tone-root";
|
|
28
|
+
const TOGGLE_ID = "dsh-notify-tone-toggle";
|
|
29
|
+
const MENU_ID = "dsh-notify-tone-menu";
|
|
30
|
+
|
|
31
|
+
// ===================== 提示音预设 =====================
|
|
32
|
+
// 每个预设含 interact(需要授权/选择)与 done(回答完成)两组音符。
|
|
33
|
+
// 音符格式:[频率Hz, 时长s];gap 为音符间隔(秒)。
|
|
34
|
+
const PRESETS = {
|
|
35
|
+
classic: {
|
|
36
|
+
label: "经典双音",
|
|
37
|
+
interact: { tones: [[880, 0.16], [660, 0.16]], gap: 0.08 },
|
|
38
|
+
done: { tones: [[660, 0.14], [880, 0.2]], gap: 0.08 },
|
|
39
|
+
},
|
|
40
|
+
ding: {
|
|
41
|
+
label: "清脆叮咚",
|
|
42
|
+
interact: { tones: [[1046.5, 0.35], [1318.5, 0.35]], gap: 0.1 },
|
|
43
|
+
done: { tones: [[1318.5, 0.5]], gap: 0 },
|
|
44
|
+
},
|
|
45
|
+
bright: {
|
|
46
|
+
label: "明亮三连",
|
|
47
|
+
interact: { tones: [[1568, 0.12], [1976, 0.12], [2637, 0.2]], gap: 0.07 },
|
|
48
|
+
done: { tones: [[1568, 0.16], [1976, 0.16]], gap: 0.07 },
|
|
49
|
+
},
|
|
50
|
+
soft: {
|
|
51
|
+
label: "柔和轻音",
|
|
52
|
+
interact: { tones: [[523, 0.2], [659, 0.25]], gap: 0.09 },
|
|
53
|
+
done: { tones: [[659, 0.28]], gap: 0 },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const PRESET_IDS = Object.keys(PRESETS);
|
|
57
|
+
/** 默认音色:清脆叮咚(比经典双音更明显,如「叮——」)。 */
|
|
58
|
+
const DEFAULT_PRESET = "ding";
|
|
59
|
+
|
|
60
|
+
// ===================== 声音引擎(Web Audio,无音频文件) =====================
|
|
61
|
+
let audioCtx = null;
|
|
62
|
+
|
|
63
|
+
function ensureAudio() {
|
|
64
|
+
if (typeof window === "undefined") return null;
|
|
65
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
66
|
+
if (!AC) return null;
|
|
67
|
+
if (audioCtx === null) {
|
|
68
|
+
try {
|
|
69
|
+
audioCtx = new AC();
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (audioCtx.state === "suspended") {
|
|
75
|
+
audioCtx.resume().catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
return audioCtx;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 播放一个音符:基频 + 2 倍频泛音(更明亮、更明显),指数衰减包络。
|
|
82
|
+
*/
|
|
83
|
+
function playTone(ctx, freq, dur, start, volume) {
|
|
84
|
+
const stop = start + dur + 0.05;
|
|
85
|
+
// 基频
|
|
86
|
+
const osc = ctx.createOscillator();
|
|
87
|
+
const gain = ctx.createGain();
|
|
88
|
+
osc.type = "sine";
|
|
89
|
+
osc.frequency.setValueAtTime(freq, start);
|
|
90
|
+
gain.gain.setValueAtTime(0.0001, start);
|
|
91
|
+
gain.gain.exponentialRampToValueAtTime(volume, start + 0.02);
|
|
92
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
|
93
|
+
osc.connect(gain);
|
|
94
|
+
gain.connect(ctx.destination);
|
|
95
|
+
osc.start(start);
|
|
96
|
+
osc.stop(stop);
|
|
97
|
+
// 泛音(2 倍频,音量更低,增强穿透力)
|
|
98
|
+
const osc2 = ctx.createOscillator();
|
|
99
|
+
const gain2 = ctx.createGain();
|
|
100
|
+
osc2.type = "sine";
|
|
101
|
+
osc2.frequency.setValueAtTime(freq * 2, start);
|
|
102
|
+
gain2.gain.setValueAtTime(0.0001, start);
|
|
103
|
+
gain2.gain.exponentialRampToValueAtTime(volume * 0.35, start + 0.02);
|
|
104
|
+
gain2.gain.exponentialRampToValueAtTime(0.0001, start + dur * 0.8);
|
|
105
|
+
osc2.connect(gain2);
|
|
106
|
+
gain2.connect(ctx.destination);
|
|
107
|
+
osc2.start(start);
|
|
108
|
+
osc2.stop(stop);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 按音符序列播放一组提示音。 */
|
|
112
|
+
function playPreset(tones, gap) {
|
|
113
|
+
const ctx = ensureAudio();
|
|
114
|
+
if (ctx === null) return;
|
|
115
|
+
const volume = 0.22;
|
|
116
|
+
let t = ctx.currentTime + 0.02;
|
|
117
|
+
for (const [freq, dur] of tones) {
|
|
118
|
+
playTone(ctx, freq, dur, t, volume);
|
|
119
|
+
t += dur + (gap ?? 0);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ===================== 状态(localStorage 持久化) =====================
|
|
124
|
+
function storageGet(key, fallback) {
|
|
125
|
+
try {
|
|
126
|
+
const value = localStorage.getItem(key);
|
|
127
|
+
return value === null ? fallback : value;
|
|
128
|
+
} catch {
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function storageSet(key, value) {
|
|
133
|
+
try {
|
|
134
|
+
localStorage.setItem(key, value);
|
|
135
|
+
} catch {
|
|
136
|
+
/* 存储不可用时仅本页生效 */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isEnabled() {
|
|
141
|
+
return storageGet(KEY_ENABLED, "true") !== "false";
|
|
142
|
+
}
|
|
143
|
+
function setEnabled(value) {
|
|
144
|
+
storageSet(KEY_ENABLED, value ? "true" : "false");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function currentPresetId() {
|
|
148
|
+
const id = storageGet(KEY_PRESET, DEFAULT_PRESET);
|
|
149
|
+
return PRESETS[id] ? id : DEFAULT_PRESET;
|
|
150
|
+
}
|
|
151
|
+
function setPresetId(id) {
|
|
152
|
+
if (PRESETS[id]) storageSet(KEY_PRESET, id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ===================== 事件节流(防连发) =====================
|
|
156
|
+
const lastPlayed = { interact: 0, done: 0 };
|
|
157
|
+
function throttledPlay(kind, minGapMs) {
|
|
158
|
+
if (!isEnabled()) return;
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (now - lastPlayed[kind] < minGapMs) return;
|
|
161
|
+
lastPlayed[kind] = now;
|
|
162
|
+
const spec = PRESETS[currentPresetId()][kind];
|
|
163
|
+
playPreset(spec.tones, spec.gap);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ===================== 会话状态订阅 =====================
|
|
167
|
+
// 基线:id -> { running, pending }。首次快照只建基线不发声,
|
|
168
|
+
// 避免页面刷新时对「正在运行」的会话误报。
|
|
169
|
+
let prevBySession = new Map();
|
|
170
|
+
|
|
171
|
+
function handleListChange(ctx) {
|
|
172
|
+
const list = ctx.sessions && ctx.sessions.list;
|
|
173
|
+
if (!list) return;
|
|
174
|
+
const snap = list.getSnapshot();
|
|
175
|
+
if (!snap || !snap.byId || !Array.isArray(snap.ids)) return;
|
|
176
|
+
|
|
177
|
+
const now = new Map();
|
|
178
|
+
for (const id of snap.ids) {
|
|
179
|
+
const row = snap.byId[id];
|
|
180
|
+
if (!row) continue;
|
|
181
|
+
now.set(id, { running: row.running === true, pending: row.pendingInteraction });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const [id, cur] of now) {
|
|
185
|
+
const prev = prevBySession.get(id);
|
|
186
|
+
if (prev === undefined) continue; // 新出现的会话:只入基线
|
|
187
|
+
// 需要用户授权/选择:pendingInteraction 从无到有
|
|
188
|
+
if (prev.pending === undefined && cur.pending !== undefined) {
|
|
189
|
+
throttledPlay("interact", 1200);
|
|
190
|
+
}
|
|
191
|
+
// 每轮回答结束:running true -> false
|
|
192
|
+
if (prev.running && !cur.running) {
|
|
193
|
+
throttledPlay("done", 2000);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 已被移除的会话从基线中清理
|
|
197
|
+
for (const id of prevBySession.keys()) {
|
|
198
|
+
if (!now.has(id)) prevBySession.delete(id);
|
|
199
|
+
}
|
|
200
|
+
prevBySession = now;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ===================== 悬浮按钮 + hover 设置菜单 =====================
|
|
204
|
+
function el(tag, text, css) {
|
|
205
|
+
const node = document.createElement(tag);
|
|
206
|
+
if (text !== undefined && text !== null) node.textContent = text;
|
|
207
|
+
if (css) node.style.cssText = css;
|
|
208
|
+
return node;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function updateToggle(btn) {
|
|
212
|
+
const on = isEnabled();
|
|
213
|
+
btn.textContent = on ? "🔔" : "🔕";
|
|
214
|
+
btn.title = on
|
|
215
|
+
? "提示音:已开启。鼠标悬停可设置音色/开关"
|
|
216
|
+
: "提示音:已关闭。鼠标悬停可设置音色/开关";
|
|
217
|
+
btn.setAttribute("aria-label", on ? "提示音已开启" : "提示音已关闭");
|
|
218
|
+
btn.style.opacity = on ? "0.9" : "0.55";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 渲染设置菜单内容(每次调用重建,简单可靠)。 */
|
|
222
|
+
function renderMenu(menu, btn) {
|
|
223
|
+
updateToggle(btn);
|
|
224
|
+
menu.textContent = "";
|
|
225
|
+
const base = "font-family:system-ui,-apple-system,sans-serif;";
|
|
226
|
+
|
|
227
|
+
menu.appendChild(el("div", "🔔 提示音设置", `${base}font-weight:600;font-size:13px;margin-bottom:8px;`));
|
|
228
|
+
|
|
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();
|
|
236
|
+
setEnabled(next);
|
|
237
|
+
renderMenu(menu, btn);
|
|
238
|
+
if (next) {
|
|
239
|
+
const spec = PRESETS[currentPresetId()].done;
|
|
240
|
+
playPreset(spec.tones, spec.gap); // 打开时试听当前音色的「完成」音
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
toggleRow.appendChild(sw);
|
|
244
|
+
menu.appendChild(toggleRow);
|
|
245
|
+
|
|
246
|
+
menu.appendChild(el("div", "", "border-top:1px solid rgba(128,128,128,0.22);margin:8px 0;"));
|
|
247
|
+
|
|
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) => {
|
|
264
|
+
event.stopPropagation();
|
|
265
|
+
previewSound(id); // 先试听再决定是否应用
|
|
266
|
+
});
|
|
267
|
+
row.addEventListener("click", () => {
|
|
268
|
+
setPresetId(id);
|
|
269
|
+
renderMenu(menu, btn);
|
|
270
|
+
});
|
|
271
|
+
row.appendChild(preview);
|
|
272
|
+
menu.appendChild(row);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
menu.appendChild(el("div", "点击选项选中即生效;▶ 可先试听", `${base}color:rgba(255,255,255,0.45);font-size:11px;margin-top:8px;`));
|
|
276
|
+
}
|
|
277
|
+
|
|
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);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function mountToggle() {
|
|
291
|
+
if (typeof document === "undefined") return;
|
|
292
|
+
if (document.getElementById(ROOT_ID)) return;
|
|
293
|
+
if (!document.body) {
|
|
294
|
+
document.addEventListener("DOMContentLoaded", mountToggle, { once: true });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const root = el("div", "", "position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;");
|
|
299
|
+
|
|
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;");
|
|
301
|
+
root.appendChild(menu);
|
|
302
|
+
|
|
303
|
+
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;");
|
|
304
|
+
btn.id = TOGGLE_ID;
|
|
305
|
+
btn.type = "button";
|
|
306
|
+
btn.addEventListener("click", (event) => {
|
|
307
|
+
event.stopPropagation();
|
|
308
|
+
const next = !isEnabled();
|
|
309
|
+
setEnabled(next);
|
|
310
|
+
updateToggle(btn);
|
|
311
|
+
renderMenu(menu, btn);
|
|
312
|
+
if (next) {
|
|
313
|
+
const spec = PRESETS[currentPresetId()].done;
|
|
314
|
+
playPreset(spec.tones, spec.gap);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
root.appendChild(btn);
|
|
318
|
+
|
|
319
|
+
document.body.appendChild(root);
|
|
320
|
+
|
|
321
|
+
// hover 显示菜单(延迟 120ms),移出后延迟 300ms 关闭(留出移到菜单的时间)
|
|
322
|
+
let hideTimer = null;
|
|
323
|
+
root.addEventListener("mouseenter", () => {
|
|
324
|
+
clearTimeout(hideTimer);
|
|
325
|
+
renderMenu(menu, btn);
|
|
326
|
+
menu.style.display = "block";
|
|
327
|
+
});
|
|
328
|
+
root.addEventListener("mouseleave", () => {
|
|
329
|
+
clearTimeout(hideTimer);
|
|
330
|
+
hideTimer = setTimeout(() => {
|
|
331
|
+
menu.style.display = "none";
|
|
332
|
+
}, 300);
|
|
333
|
+
});
|
|
334
|
+
// 点击菜单外部区域时关闭菜单
|
|
335
|
+
document.addEventListener("click", (event) => {
|
|
336
|
+
if (!root.contains(event.target)) menu.style.display = "none";
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 浏览器自动播放策略:需要用户先与页面交互过,AudioContext 才会出声。
|
|
341
|
+
// 捕获任意一次用户点击来预热音频上下文。
|
|
342
|
+
function warmupAudio() {
|
|
343
|
+
if (typeof document === "undefined") return;
|
|
344
|
+
const unlock = () => ensureAudio();
|
|
345
|
+
document.addEventListener("click", unlock, { capture: true, once: true });
|
|
346
|
+
document.addEventListener("keydown", unlock, { capture: true, once: true });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ===================== 插件主体 =====================
|
|
350
|
+
// 注意:inject 是 cordis「服务名」列表(fiber 会等待这些服务可用才激活),
|
|
351
|
+
// 不是包名/模块 id。sessions 服务由 @deepseek-ai/dsh-client-runtime 提供
|
|
352
|
+
// (rootCtx.reflect.provide("sessions", ...))。
|
|
353
|
+
const inject = ["sessions"];
|
|
354
|
+
|
|
355
|
+
function apply(ctx) {
|
|
356
|
+
// 订阅会话列表状态变化(running / pendingInteraction)
|
|
357
|
+
const list = ctx && ctx.sessions && ctx.sessions.list;
|
|
358
|
+
if (list && typeof list.subscribe === "function") {
|
|
359
|
+
ctx.effect(() => {
|
|
360
|
+
// 先建立基线快照,再订阅后续变化
|
|
361
|
+
const bootstrap = () => {
|
|
362
|
+
const snap = list.getSnapshot();
|
|
363
|
+
prevBySession = new Map();
|
|
364
|
+
if (snap && snap.byId && Array.isArray(snap.ids)) {
|
|
365
|
+
for (const id of snap.ids) {
|
|
366
|
+
const row = snap.byId[id];
|
|
367
|
+
if (row) prevBySession.set(id, { running: row.running === true, pending: row.pendingInteraction });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return list.subscribe(() => handleListChange(ctx));
|
|
371
|
+
};
|
|
372
|
+
const unsubscribe = bootstrap();
|
|
373
|
+
return () => {
|
|
374
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
375
|
+
};
|
|
376
|
+
}, "dsh-notify-tone: sessions subscription");
|
|
377
|
+
} else {
|
|
378
|
+
console.warn("[dsh-notify-tone] ctx.sessions.list 不可用,提示音功能未启用");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
mountToggle();
|
|
382
|
+
warmupAudio();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 测试钩子(仅供验证脚本使用;cordis 只消费 apply/inject,额外导出无副作用)
|
|
386
|
+
exports.__test = {
|
|
387
|
+
PRESETS,
|
|
388
|
+
PRESET_IDS,
|
|
389
|
+
DEFAULT_PRESET,
|
|
390
|
+
currentPresetId,
|
|
391
|
+
setPresetId,
|
|
392
|
+
isEnabled,
|
|
393
|
+
setEnabled,
|
|
394
|
+
playPreset,
|
|
395
|
+
playTone,
|
|
396
|
+
ensureAudio,
|
|
397
|
+
resetThrottle() {
|
|
398
|
+
lastPlayed.interact = 0;
|
|
399
|
+
lastPlayed.done = 0;
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
exports.apply = apply;
|
|
404
|
+
exports.inject = inject;
|
|
405
|
+
return module.exports;
|
|
406
|
+
}
|
|
407
|
+
});
|
package/lib/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-notify-tone",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh web GUI 提示音插件:AI 需要用户授权/选择时与每轮回答结束时播放提示音提醒用户。多套内置音色可选(经典双音/清脆叮咚/明亮三连/柔和轻音),悬停铃铛按钮可开关、选音色并试听,状态持久化。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"bundle": {
|
|
14
|
+
"patch": "./cordis.patch.yml"
|
|
15
|
+
},
|
|
16
|
+
"client": {
|
|
17
|
+
"inject": [
|
|
18
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
19
|
+
],
|
|
20
|
+
"platform": "web"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib",
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"dsh",
|
|
31
|
+
"deepseek-harness",
|
|
32
|
+
"plugin",
|
|
33
|
+
"notification",
|
|
34
|
+
"sound",
|
|
35
|
+
"tone",
|
|
36
|
+
"beep",
|
|
37
|
+
"alert"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/Talone22/dsh-notify-tone.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/Talone22/dsh-notify-tone#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/Talone22/dsh-notify-tone/issues"
|
|
47
|
+
}
|
|
48
|
+
}
|