dsh-notify-ding 0.0.1 → 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/README.md +61 -5
- package/cordis.patch.yml +10 -0
- package/docs/design.md +44 -0
- package/lib/client.js +285 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +114 -0
- package/package.json +55 -6
package/README.md
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
# dsh
|
|
1
|
+
# dsh·通知叮咚插件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DSH 需要你人工回答或一轮对话跑完时,弹出系统通知,并让宿主播放 Windows 自带的提示音。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
纯浏览器端触发、宿主端发声的双半身插件,不替换任何原生界面。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## 引言
|
|
8
|
+
|
|
9
|
+
<!-- 本节由作者本人撰写,AI 不代笔。 -->
|
|
10
|
+
最小的 Windows 11 系统通知 + 内置提示音的提醒插件。
|
|
11
|
+
|
|
12
|
+
## 功能
|
|
13
|
+
|
|
14
|
+
- 某个会话出现新的待人工回答交互时叮咚一次,question、plan-review、approval 都算。
|
|
15
|
+
- 某个会话从执行中落到空闲、也就是一轮对话跑完时叮咚一次。
|
|
16
|
+
- 两种触发都会弹浏览器系统通知,同时请宿主播放 Windows 内置提示音。
|
|
17
|
+
- 不判断页面是否聚焦,也不区分是否为当前会话,任何会话有事都会响。
|
|
18
|
+
- 同一待回答请求只响一次,两次响铃之间至少有 800 毫秒间隔,且该间隔跨标签页共享,多开标签页不会重复响。
|
|
19
|
+
|
|
20
|
+
## 使用
|
|
21
|
+
|
|
22
|
+
### 首次使用
|
|
23
|
+
|
|
24
|
+
浏览器系统通知需要页面授权。插件在加载时会尝试请求一次,若被浏览器拒绝,会在你下一次点击或按键时再请求一次。所以在第一次看到页面后点一下任意位置,授权弹窗就会出来。
|
|
25
|
+
|
|
26
|
+
若浏览器通知一直弹不出来,声音仍然会正常响——两条路是独立的。
|
|
27
|
+
|
|
28
|
+
### 声音从哪来
|
|
29
|
+
|
|
30
|
+
浏览器沙箱读不到 `C:\Windows\Media` 下的文件,所以声音必须由宿主进程来播。宿主半身在 web 载体上注册一条精确路由 `POST /dsh-notify-ding/ding`,收到请求就用 PowerShell 的 `System.Media.SoundPlayer` 播放 `Windows Notify System Generic.wav`,响一声。
|
|
8
31
|
|
|
9
32
|
## 安装
|
|
10
33
|
|
|
11
|
-
|
|
34
|
+
**从 GitHub 安装**:源码在 `src/`,`lib/` 不入仓库,安装时 npm 会触发 `prepare` 脚本现场构建。
|
|
12
35
|
|
|
13
36
|
```powershell
|
|
14
37
|
dsh plugin --profile web add github:better-er/dsh-notify-ding
|
|
15
38
|
```
|
|
16
39
|
|
|
40
|
+
**从 npm 安装**:包内已含构建产物 `lib/index.js` 与 `lib/client.js`,安装时不再构建。
|
|
41
|
+
|
|
42
|
+
```powershell
|
|
43
|
+
dsh plugin --profile web add dsh-notify-ding
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
两种方式装完都会自动挂载,重启 DSH web 后启用,无需手工编辑任何文件。
|
|
47
|
+
|
|
17
48
|
## 卸载
|
|
18
49
|
|
|
19
50
|
```powershell
|
|
@@ -22,6 +53,31 @@ dsh plugin --profile web remove dsh-notify-ding
|
|
|
22
53
|
|
|
23
54
|
彻底移除,重启 DSH web 后不再加载。
|
|
24
55
|
|
|
56
|
+
## 工作原理
|
|
57
|
+
|
|
58
|
+
浏览器半身订阅 `ctx.sessions.list` 与 `ctx.uiSession.pendingInteractions` 两个只读快照,用边沿检测判断该不该响:运行态从真落到假算一轮跑完,出现新 key 的待回答交互算需要人工介入。首帧只建基线不发声,同一待回答请求只响一次,两次响铃之间留至少 800 毫秒并把该时刻写入 localStorage 跨标签页共享。
|
|
59
|
+
|
|
60
|
+
判定通过时它做两件事:弹浏览器系统通知,以及向宿主发一个 POST。宿主半身只负责后者,它注册的 `/dsh-notify-ding/ding` 路由收到请求就用 PowerShell 播放提示音。完整设计见 [设计说明](docs/design.md)。
|
|
61
|
+
|
|
62
|
+
## 要求与开发
|
|
63
|
+
|
|
64
|
+
- 是标准形态的 dsh client 插件,声明 `dsh.client`,导出 `./client`。
|
|
65
|
+
- 同时声明了 `dsh.bundle`,因此也是一个自挂载的 bundle 层插件:用 `dsh plugin --profile <name> add` 从 GitHub 安装后会被自动识别为 profile layer 并挂载,无需手工写组合 entry。
|
|
66
|
+
- 宿主半身依赖 `webServer` 服务,浏览器半身依赖 `uiSession` 与 `sessions`。
|
|
67
|
+
- 构建型插件:`src/` 是 TypeScript 源码,`lib/` 是构建产物且不入库,安装或发布前由 `prepare` 构建。
|
|
68
|
+
|
|
69
|
+
## 开发
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
pnpm install
|
|
73
|
+
pnpm run typecheck # tsc --noEmit 严格类型检查
|
|
74
|
+
pnpm run build # tsdown,产出 lib/index.js 与 lib/client.js
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `src/index.ts`:宿主半身,注册播音路由。
|
|
78
|
+
- `src/client/index.ts`:浏览器半身,边沿检测与通知。
|
|
79
|
+
- 构建用 tsdown,client 产物是 `window.__ModuleLoader__.load({ id, factory })` 的注册式模块,`@deepseek-ai/cordis` 保持外部依赖。
|
|
80
|
+
|
|
25
81
|
## License
|
|
26
82
|
|
|
27
83
|
[MIT](./LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# dsh-notify-ding bundle patch: mount the notification plugin.
|
|
2
|
+
#
|
|
3
|
+
# Applied when this bundle appears in a profile's dsh.profile.bundles list.
|
|
4
|
+
# The node half (package root) owns the host-side sound route and the optional
|
|
5
|
+
# window sound; the browser half (dsh.client) is discovered automatically
|
|
6
|
+
# through the package's dsh.client declaration + exports["./client"].
|
|
7
|
+
|
|
8
|
+
- insert:
|
|
9
|
+
- id: dsh-notify-ding
|
|
10
|
+
name: dsh-notify-ding
|
package/docs/design.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 设计说明
|
|
2
|
+
|
|
3
|
+
## 为什么是双半身
|
|
4
|
+
|
|
5
|
+
浏览器沙箱读不到 `C:\Windows\Media` 下的音频文件,所以「Windows 本家音色」这件事只能由宿主进程完成。插件因此拆成两半:
|
|
6
|
+
|
|
7
|
+
- **宿主半身** 只负责发声:在 web 载体上注册一条精确路由,收到请求就用 PowerShell 的 `System.Media.SoundPlayer` 播放 wav。
|
|
8
|
+
- **浏览器半身** 只负责判断:盯住会话状态,判断什么时刻该响,然后同时弹系统通知并请求宿主播音。
|
|
9
|
+
|
|
10
|
+
宿主半身不参与任何触发判断,浏览器半身不碰任何文件路径,职责边界清楚。
|
|
11
|
+
|
|
12
|
+
## 触发信号
|
|
13
|
+
|
|
14
|
+
浏览器半身盯两个快照源:
|
|
15
|
+
|
|
16
|
+
| 信号 | 来源 | 触发条件 |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| 待人工回答 | `ctx.uiSession.pendingInteractions` | 某会话出现新 key 的交互,question、plan-review、approval 都算 |
|
|
19
|
+
| 一轮跑完 | `ctx.sessions.list` 的 `byId[].running` | 某会话运行态从真落到假 |
|
|
20
|
+
|
|
21
|
+
两者都是可订阅的只读快照,插件不修改任何状态,只观察。
|
|
22
|
+
|
|
23
|
+
## 去重与节流
|
|
24
|
+
|
|
25
|
+
按用户选择,两种触发都要响,且不判断页面是否聚焦、不区分是否为当前会话,所以「响太多次」是主要风险,靠三道闸门控制。
|
|
26
|
+
|
|
27
|
+
1. **首帧只建基线**:初次读到的快照只记录不发声,避免每次刷新页面补报一轮旧状态。
|
|
28
|
+
2. **同一待回答请求只响一次**:按交互的稳定 key 记忆,消失后清理记录但不发声。
|
|
29
|
+
3. **滚动最小间隔 800 毫秒**:两次响铃之间至少间隔这么久。窗口内到达的请求不丢弃,而是挂到窗口边界的定时器上补发,保证不漏报。该时刻写入 localStorage 跨标签页共享,多开标签页时同一件事只响一轮。
|
|
30
|
+
|
|
31
|
+
## 声音与通知的关系
|
|
32
|
+
|
|
33
|
+
两者互相独立:浏览器通知弹不出来时声音照响,宿主播音失败时通知照弹。任一侧失败只记一条 `console.warn`,不抛错、不互相阻塞。
|
|
34
|
+
|
|
35
|
+
## 认证
|
|
36
|
+
|
|
37
|
+
dsh web 由 connection 插件提供认证边界:根路径带 token 打开后铸出浏览器 cookie,后续同源请求自动带上。插件走同源 fetch 且带 `credentials: 'same-origin'`,因此沿用同一套 cookie,不需要自己处理 token。
|
|
38
|
+
|
|
39
|
+
## 已知边界
|
|
40
|
+
|
|
41
|
+
- 宿主半身依赖 `webServer` 服务,浏览器半身依赖 `uiSession` 与 `sessions`,缺任一则不激活。
|
|
42
|
+
- 播音走 PowerShell 进程,单次开销约是一百毫秒级,慢但不阻塞响应。
|
|
43
|
+
- 「一轮跑完」只覆盖正常结束与被打断这两种空闲落点,插件不区分具体结束原因。
|
|
44
|
+
- Windows 聚焦助手开启时系统通知可能被静音,但宿主播音不受影响。
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-notify-ding",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperties(exports, {
|
|
7
|
+
__esModule: { value: true },
|
|
8
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
9
|
+
});
|
|
10
|
+
//#region src/client/index.ts
|
|
11
|
+
/** 插件名,同时也是配置项 id。 */
|
|
12
|
+
const name = "dsh-notify-ding";
|
|
13
|
+
/** 本插件需要的客户端服务。 */
|
|
14
|
+
const inject = ["uiSession", "sessions"];
|
|
15
|
+
/** 宿主播音路由,与宿主半身注册的路径一致。 */
|
|
16
|
+
const DING_PATH = "/dsh-notify-ding/ding";
|
|
17
|
+
/** 两次响铃之间的最小间隔,窗口内的合并成一次。 */
|
|
18
|
+
const MIN_INTERVAL_MS = 800;
|
|
19
|
+
/** 跨标签页共享「上次响铃时刻」的 localStorage 键。 */
|
|
20
|
+
const LAST_DING_KEY = "dsh-notify-ding.lastDingAt";
|
|
21
|
+
/** 系统通知自动关闭的延时。 */
|
|
22
|
+
const NOTIFICATION_TTL_MS = 1e4;
|
|
23
|
+
/** 记录各会话上一帧状态,用来做边沿检测。 */
|
|
24
|
+
const tracks = /* @__PURE__ */ new Map();
|
|
25
|
+
/** 本页面上次响铃的时间戳,兜住同一帧内的重复触发。 */
|
|
26
|
+
let lastDingAt = 0;
|
|
27
|
+
/** 因节流被暂时压下的最新一条待响请求,窗口过后补发。 */
|
|
28
|
+
let deferred = null;
|
|
29
|
+
/** 补发定时器的句柄,0 表示当前没有挂起的补发。 */
|
|
30
|
+
let deferredTimer = 0;
|
|
31
|
+
/**
|
|
32
|
+
* 读取跨标签页共享的上次响铃时刻。
|
|
33
|
+
*
|
|
34
|
+
* localStorage 可能因隐私模式或禁用存储而抛错,抛错时按 0 处理即可,
|
|
35
|
+
* 最坏退化成只在单个标签页内节流。
|
|
36
|
+
*
|
|
37
|
+
* @returns 上次响铃的 Unix 毫秒时间戳。
|
|
38
|
+
*/
|
|
39
|
+
function readSharedLastDing() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = window.localStorage.getItem(LAST_DING_KEY);
|
|
42
|
+
const value = raw === null ? 0 : Number(raw);
|
|
43
|
+
return Number.isFinite(value) ? value : 0;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn("[dsh-notify-ding] 读取共享响铃时刻失败:" + String(error));
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** 写回跨标签页共享的上次响铃时刻,存储不可用时静默降级。 */
|
|
50
|
+
function writeSharedLastDing(stamp) {
|
|
51
|
+
try {
|
|
52
|
+
window.localStorage.setItem(LAST_DING_KEY, String(stamp));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn("[dsh-notify-ding] 写入共享响铃时刻失败:" + String(error));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 请求宿主播放提示音。
|
|
59
|
+
*
|
|
60
|
+
* 声音本身与系统通知互相独立:通知弹不出来时声音照样响,反之亦然。
|
|
61
|
+
* 这里不阻塞调用方,播音失败只记一条警告。
|
|
62
|
+
*/
|
|
63
|
+
function requestHostDing() {
|
|
64
|
+
fetch(DING_PATH, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
credentials: "same-origin"
|
|
67
|
+
}).catch((error) => {
|
|
68
|
+
console.warn("[dsh-notify-ding] 请求宿主播音失败:" + String(error));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 弹一条浏览器系统通知。
|
|
73
|
+
*
|
|
74
|
+
* 浏览器未授权时不强行索要权限,而是记下需要补授权,等到用户下一次
|
|
75
|
+
* 与页面交互时再请求——权限请求必须由用户手势触发。
|
|
76
|
+
*
|
|
77
|
+
* @param title 通知标题。
|
|
78
|
+
* @param body 通知正文。
|
|
79
|
+
*/
|
|
80
|
+
function showSystemNotification(title, body) {
|
|
81
|
+
if (typeof Notification === "undefined") return;
|
|
82
|
+
const present = () => {
|
|
83
|
+
try {
|
|
84
|
+
const notification = new Notification(title, { body });
|
|
85
|
+
notification.onclick = () => {
|
|
86
|
+
try {
|
|
87
|
+
window.focus();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.warn("[dsh-notify-ding] 聚焦窗口失败:" + String(error));
|
|
90
|
+
}
|
|
91
|
+
notification.close();
|
|
92
|
+
};
|
|
93
|
+
window.setTimeout(() => notification.close(), NOTIFICATION_TTL_MS);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn("[dsh-notify-ding] 弹出系统通知失败:" + String(error));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (Notification.permission === "granted") {
|
|
99
|
+
present();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (Notification.permission === "denied") return;
|
|
103
|
+
Notification.requestPermission().then((permission) => {
|
|
104
|
+
if (permission === "granted") present();
|
|
105
|
+
else armPermissionGesture();
|
|
106
|
+
}).catch((error) => {
|
|
107
|
+
console.warn("[dsh-notify-ding] 请求通知权限失败:" + String(error));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** 是否已经挂过一次手势补授权监听。 */
|
|
111
|
+
let gestureArmed = false;
|
|
112
|
+
/** 在下一次用户手势时补一次通知权限请求,只挂一次。 */
|
|
113
|
+
function armPermissionGesture() {
|
|
114
|
+
if (gestureArmed || typeof Notification === "undefined") return;
|
|
115
|
+
if (Notification.permission === "granted" || Notification.permission === "denied") return;
|
|
116
|
+
gestureArmed = true;
|
|
117
|
+
const onGesture = () => {
|
|
118
|
+
window.removeEventListener("pointerdown", onGesture, true);
|
|
119
|
+
window.removeEventListener("keydown", onGesture, true);
|
|
120
|
+
Notification.requestPermission().catch((error) => {
|
|
121
|
+
console.warn("[dsh-notify-ding] 手势补授权失败:" + String(error));
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
window.addEventListener("pointerdown", onGesture, true);
|
|
125
|
+
window.addEventListener("keydown", onGesture, true);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 发一次「叮咚」:弹系统通知并请宿主播音。
|
|
129
|
+
* @param title 通知标题。
|
|
130
|
+
* @param body 通知正文。
|
|
131
|
+
*/
|
|
132
|
+
function emit(title, body) {
|
|
133
|
+
showSystemNotification(title, body);
|
|
134
|
+
requestHostDing();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 发一次「叮咚」,并做节流。
|
|
138
|
+
*
|
|
139
|
+
* 节流时刻写进 localStorage,多标签页共享,避免多开时同一件事响好几轮。
|
|
140
|
+
* 被节流压下的请求不会直接丢弃,而是挂到窗口边界的定时器上补发,
|
|
141
|
+
* 这样连续两次状态变化仍然各响一次,只是间隔被拉开,不会出现漏报。
|
|
142
|
+
*
|
|
143
|
+
* @param title 通知标题。
|
|
144
|
+
* @param body 通知正文。
|
|
145
|
+
*/
|
|
146
|
+
function ding(title, body) {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const since = now - Math.max(lastDingAt, readSharedLastDing());
|
|
149
|
+
if (since < 0 || since >= MIN_INTERVAL_MS) {
|
|
150
|
+
lastDingAt = now;
|
|
151
|
+
writeSharedLastDing(now);
|
|
152
|
+
emit(title, body);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
deferred = {
|
|
156
|
+
title,
|
|
157
|
+
body
|
|
158
|
+
};
|
|
159
|
+
if (deferredTimer !== 0) return;
|
|
160
|
+
deferredTimer = window.setTimeout(() => {
|
|
161
|
+
deferredTimer = 0;
|
|
162
|
+
const pending = deferred;
|
|
163
|
+
deferred = null;
|
|
164
|
+
if (pending === null) return;
|
|
165
|
+
const stamp = Date.now();
|
|
166
|
+
lastDingAt = stamp;
|
|
167
|
+
writeSharedLastDing(stamp);
|
|
168
|
+
emit(pending.title, pending.body);
|
|
169
|
+
}, MIN_INTERVAL_MS - since);
|
|
170
|
+
}
|
|
171
|
+
/** 会话标题,缺失时退回会话 id,保证通知里总有个能认的名字。 */
|
|
172
|
+
function titleOf(summary, fallbackId) {
|
|
173
|
+
const raw = summary?.displayTitle;
|
|
174
|
+
return raw && raw.length > 0 ? raw : fallbackId;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 用一帧会话列表快照做边沿检测:新出现的待回答交互响一次,运行态从真落到假响一次。
|
|
178
|
+
*
|
|
179
|
+
* 首帧只建基线不响,否则每次刷新页面都会把既有状态补报一轮。
|
|
180
|
+
*
|
|
181
|
+
* @param state 当前会话列表快照。
|
|
182
|
+
*/
|
|
183
|
+
function scanSessions(state, baseline) {
|
|
184
|
+
const seen = /* @__PURE__ */ new Set();
|
|
185
|
+
for (const id of Object.keys(state.byId)) {
|
|
186
|
+
const summary = state.byId[id];
|
|
187
|
+
if (!summary) continue;
|
|
188
|
+
seen.add(id);
|
|
189
|
+
let track = tracks.get(id);
|
|
190
|
+
if (!track) {
|
|
191
|
+
track = {
|
|
192
|
+
running: summary.running,
|
|
193
|
+
pendingKeys: /* @__PURE__ */ new Set()
|
|
194
|
+
};
|
|
195
|
+
tracks.set(id, track);
|
|
196
|
+
}
|
|
197
|
+
if (!baseline && track.running && !summary.running) ding("DSH 完成了一轮", titleOf(summary, id) + " 已空闲");
|
|
198
|
+
track.running = summary.running;
|
|
199
|
+
}
|
|
200
|
+
for (const id of Array.from(tracks.keys())) if (!seen.has(id)) tracks.delete(id);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 用一帧待回答交互快照做边沿检测:某会话冒出新的 key 时响一次。
|
|
204
|
+
*
|
|
205
|
+
* 消失的 key 只清理记录,不触发任何声音。
|
|
206
|
+
*
|
|
207
|
+
* @param pending 当前按会话索引的待回答交互快照。
|
|
208
|
+
* @param state 与本次快照同时读到的会话列表,用来取标题。
|
|
209
|
+
*/
|
|
210
|
+
function scanPending(pending, state, baseline) {
|
|
211
|
+
for (const [id, interaction] of pending) {
|
|
212
|
+
const track = tracks.get(id);
|
|
213
|
+
if (!track) continue;
|
|
214
|
+
if (track.pendingKeys.has(interaction.key)) continue;
|
|
215
|
+
track.pendingKeys.add(interaction.key);
|
|
216
|
+
if (baseline) continue;
|
|
217
|
+
ding("DSH 需要你回答", titleOf(state.byId[id], id) + " 正在等你");
|
|
218
|
+
}
|
|
219
|
+
for (const [id, track] of tracks) {
|
|
220
|
+
if (track.pendingKeys.size === 0) continue;
|
|
221
|
+
const stillPending = pending.get(id);
|
|
222
|
+
if (stillPending === void 0) {
|
|
223
|
+
track.pendingKeys.clear();
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
for (const key of Array.from(track.pendingKeys)) if (key !== stillPending.key) track.pendingKeys.delete(key);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 同时盯住会话列表与待回答交互两个快照源。
|
|
231
|
+
*
|
|
232
|
+
* @param ctx 客户端根上下文,sessions 与 uiSession 已就绪。
|
|
233
|
+
*/
|
|
234
|
+
function apply(ctx) {
|
|
235
|
+
if (typeof Notification !== "undefined" && Notification.permission === "default") armPermissionGesture();
|
|
236
|
+
const pendingSource = ctx.uiSession.pendingInteractions;
|
|
237
|
+
const listSource = ctx.sessions.list;
|
|
238
|
+
/** 是否已经用首帧快照建立过基线;首帧只记录不发声。 */
|
|
239
|
+
let initialized = false;
|
|
240
|
+
/** 读两个源并各扫一遍,保证两处看到的是同一帧会话列表。 */
|
|
241
|
+
const scan = () => {
|
|
242
|
+
let state;
|
|
243
|
+
try {
|
|
244
|
+
state = listSource.getSnapshot();
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.warn("[dsh-notify-ding] 读取会话列表失败:" + String(error));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const baseline = !initialized;
|
|
250
|
+
initialized = true;
|
|
251
|
+
scanSessions(state, baseline);
|
|
252
|
+
try {
|
|
253
|
+
scanPending(pendingSource.getSnapshot(), state, baseline);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.warn("[dsh-notify-ding] 读取待回答交互失败:" + String(error));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const unsubscribeList = listSource.subscribe(scan);
|
|
259
|
+
const unsubscribePending = pendingSource.subscribe(scan);
|
|
260
|
+
scan();
|
|
261
|
+
ctx.effect(() => () => {
|
|
262
|
+
unsubscribeList();
|
|
263
|
+
unsubscribePending();
|
|
264
|
+
if (deferredTimer !== 0) {
|
|
265
|
+
window.clearTimeout(deferredTimer);
|
|
266
|
+
deferredTimer = 0;
|
|
267
|
+
}
|
|
268
|
+
deferred = null;
|
|
269
|
+
tracks.clear();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** 兼容旧产物的默认导出形态:同时提供命名导出与 default。 */
|
|
273
|
+
var client_default = {
|
|
274
|
+
name,
|
|
275
|
+
inject,
|
|
276
|
+
apply
|
|
277
|
+
};
|
|
278
|
+
//#endregion
|
|
279
|
+
exports.apply = apply;
|
|
280
|
+
exports.default = client_default;
|
|
281
|
+
exports.inject = inject;
|
|
282
|
+
exports.name = name;
|
|
283
|
+
return module.exports;
|
|
284
|
+
}
|
|
285
|
+
});
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
/** 插件名,同时也是配置项 id。 */
|
|
4
|
+
export declare const name = "dsh-notify-ding";
|
|
5
|
+
/** 依赖宿主 web 载体的具名路由注册能力。没有 web 载体时本插件不激活。 */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/** Windows 通知音的绝对路径,本家音色。 */
|
|
8
|
+
export declare const SOUND_FILE = "C:\\Windows\\Media\\Windows Notify System Generic.wav";
|
|
9
|
+
/** 一次「叮咚」播放的次数,一声即可,重复反而显得吵。 */
|
|
10
|
+
export declare const BEEP_COUNT = 1;
|
|
11
|
+
/**
|
|
12
|
+
* 挂载播音路由。
|
|
13
|
+
*
|
|
14
|
+
* @param ctx 宿主上下文,webServer 服务已就绪。
|
|
15
|
+
*/
|
|
16
|
+
export declare function apply(ctx: Context): void;
|
|
17
|
+
//#endregion
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* dsh-notify-ding 的宿主半身。
|
|
5
|
+
*
|
|
6
|
+
* 职责只有一件:在 dsh 的 web 载体上注册一条精确路由 POST /dsh-notify-ding/ding,
|
|
7
|
+
* 收到请求就用 PowerShell 的 System.Media.SoundPlayer 播放 Windows 自带的提示音。
|
|
8
|
+
* 之所以要绕宿主这一圈,是因为浏览器沙箱读不到 C:\Windows\Media 下的文件,
|
|
9
|
+
* 而 Windows 本家音色只有读得到那些 wav 的宿主进程才播得出来。
|
|
10
|
+
*
|
|
11
|
+
* 浏览器半身负责判断「该响了」;本半身不参与任何触发判断,只负责发声。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-notify-ding
|
|
14
|
+
*/
|
|
15
|
+
/** 插件名,同时也是配置项 id。 */
|
|
16
|
+
const name = "dsh-notify-ding";
|
|
17
|
+
/** 依赖宿主 web 载体的具名路由注册能力。没有 web 载体时本插件不激活。 */
|
|
18
|
+
const inject = ["webServer"];
|
|
19
|
+
/** 播音路由的绝对路径。 */
|
|
20
|
+
const ROUTE_PATH = "/dsh-notify-ding/ding";
|
|
21
|
+
/** Windows 通知音的绝对路径,本家音色。 */
|
|
22
|
+
const SOUND_FILE = "C:\\Windows\\Media\\Windows Notify System Generic.wav";
|
|
23
|
+
/** 一次「叮咚」播放的次数,一声即可,重复反而显得吵。 */
|
|
24
|
+
const BEEP_COUNT = 1;
|
|
25
|
+
/**
|
|
26
|
+
* 组装播音用的 PowerShell 脚本。
|
|
27
|
+
*
|
|
28
|
+
* 路径经单引号转义后拼进脚本字面量,避免文件名里的特殊字符被当成命令解析。
|
|
29
|
+
* 播放次数写进脚本,因此一次进程启动就能响完整段声音,不必每声各起一个进程。
|
|
30
|
+
*
|
|
31
|
+
* @returns 一段自包含的 PowerShell 脚本。
|
|
32
|
+
*/
|
|
33
|
+
function buildPlayScript() {
|
|
34
|
+
return [
|
|
35
|
+
"$ErrorActionPreference = 'Stop'",
|
|
36
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
37
|
+
"$player = New-Object System.Media.SoundPlayer '" + SOUND_FILE.replace(/'/g, "''") + "'",
|
|
38
|
+
"for ($i = 0; $i -lt 1; $i++) {",
|
|
39
|
+
" $player.PlaySync()",
|
|
40
|
+
"}"
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 把播音请求交给一个脱离宿主事件循环的 PowerShell 进程。
|
|
45
|
+
*
|
|
46
|
+
* 用 detached 启动,因此响铃不会被 dsh 的生命周期或响应结束打断;
|
|
47
|
+
* stderr 仍要接住,否则播放失败时宿主会留下无人消费的管道数据。
|
|
48
|
+
*
|
|
49
|
+
*/
|
|
50
|
+
function spawnPlayer() {
|
|
51
|
+
const script = buildPlayScript();
|
|
52
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
53
|
+
const child = spawn("powershell.exe", [
|
|
54
|
+
"-NoLogo",
|
|
55
|
+
"-NoProfile",
|
|
56
|
+
"-NonInteractive",
|
|
57
|
+
"-EncodedCommand",
|
|
58
|
+
encoded
|
|
59
|
+
], {
|
|
60
|
+
detached: true,
|
|
61
|
+
stdio: [
|
|
62
|
+
"ignore",
|
|
63
|
+
"ignore",
|
|
64
|
+
"pipe"
|
|
65
|
+
],
|
|
66
|
+
windowsHide: true
|
|
67
|
+
});
|
|
68
|
+
child.stderr?.on("data", (chunk) => {
|
|
69
|
+
const text = chunk.toString("utf8").trim();
|
|
70
|
+
if (text) console.warn("[dsh-notify-ding] 播音进程报错:" + text);
|
|
71
|
+
});
|
|
72
|
+
child.on("error", (error) => {
|
|
73
|
+
console.warn("[dsh-notify-ding] 播音进程启动失败:" + error.message);
|
|
74
|
+
});
|
|
75
|
+
child.on("close", (code) => {
|
|
76
|
+
if (code !== 0 && code !== null) console.warn("[dsh-notify-ding] 播音进程退出码:" + String(code));
|
|
77
|
+
});
|
|
78
|
+
child.unref();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 挂载播音路由。
|
|
82
|
+
*
|
|
83
|
+
* @param ctx 宿主上下文,webServer 服务已就绪。
|
|
84
|
+
*/
|
|
85
|
+
function apply(ctx) {
|
|
86
|
+
const webServer = ctx.get("webServer");
|
|
87
|
+
if (!webServer) {
|
|
88
|
+
console.warn("[dsh-notify-ding] 未找到 webServer 服务,播音路由不注册。");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
webServer.register({
|
|
92
|
+
kind: "exact",
|
|
93
|
+
path: ROUTE_PATH,
|
|
94
|
+
handler: (req, res) => {
|
|
95
|
+
if (req.method !== "POST") {
|
|
96
|
+
res.statusCode = 405;
|
|
97
|
+
res.end("method not allowed");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
spawnPlayer();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.warn("[dsh-notify-ding] 播音调用失败:" + error.message);
|
|
104
|
+
res.statusCode = 500;
|
|
105
|
+
res.end("spawn failed");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
res.statusCode = 200;
|
|
109
|
+
res.end("ding");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
export { BEEP_COUNT, SOUND_FILE, apply, inject, name };
|
package/package.json
CHANGED
|
@@ -1,16 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-ding",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "在 DSH 需要人工回答或一轮对话跑完时,弹出系统通知并让宿主播放 Windows 内置提示音。标准可安装的 dsh 客户端插件。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh",
|
|
8
|
-
"
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"notification",
|
|
10
|
+
"sound",
|
|
11
|
+
"notify"
|
|
9
12
|
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/better-er/dsh-notify-ding.git"
|
|
16
|
+
},
|
|
10
17
|
"author": "betterer",
|
|
11
|
-
"
|
|
18
|
+
"packageManager": "pnpm@9.15.9",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "lib/index.js",
|
|
21
|
+
"types": "lib/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./lib/index.d.ts",
|
|
25
|
+
"default": "./lib/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./client": "./lib/client.js",
|
|
28
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
12
31
|
"files": [
|
|
32
|
+
"lib/index.js",
|
|
33
|
+
"lib/client.js",
|
|
34
|
+
"lib/index.d.ts",
|
|
35
|
+
"cordis.patch.yml",
|
|
13
36
|
"README.md",
|
|
14
|
-
"
|
|
15
|
-
]
|
|
37
|
+
"docs/"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=26.3.1"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web",
|
|
49
|
+
"inject": [
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"prepare": "tsdown",
|
|
56
|
+
"build": "tsdown",
|
|
57
|
+
"typecheck": "tsc --noEmit"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
61
|
+
"@types/node": "^22.20.1",
|
|
62
|
+
"tsdown": "^0.23.0",
|
|
63
|
+
"typescript": "^5.9.3"
|
|
64
|
+
}
|
|
16
65
|
}
|