dsh-notify-me 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +436 -0
- package/lib/index.js +18 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chromoany
|
|
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,110 @@
|
|
|
1
|
+
# dsh-notify-me
|
|
2
|
+
|
|
3
|
+
DSH(DeepSeek Harness)web GUI 的**桌面提醒**插件:当你切到别的软件、看不到 DSH 页面时,在"模型需要你操作"和"回复完成"两种时刻提醒你。
|
|
4
|
+
|
|
5
|
+
| 时刻 | 提醒 | 默认 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| 🔔 需要你操作 | 审批请求 / 方案待确认(plan review)/ 提问(`ask_user_question`) | 页面可见也提醒 |
|
|
8
|
+
| ✅ 回复完成 | 一次回复(turn)跑完;后台会话完成也提醒 | 仅页面隐藏/后台时提醒 |
|
|
9
|
+
|
|
10
|
+
提醒方式(浏览器层实现,**需要 DSH 页面保持打开**):
|
|
11
|
+
|
|
12
|
+
- **系统通知**:Windows 通知中心 Toast(点通知可切回 DSH 窗口)
|
|
13
|
+
- **提示音**:Web Audio 合成提示音(需先在页面上点击过,浏览器才允许发声)
|
|
14
|
+
- **标签页标题标记**:有待处理提醒时标题前出现 `🔔 需要你 · …`
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
# 从 npm 安装(发布后)
|
|
20
|
+
dsh plugin --profile web add dsh-notify-me
|
|
21
|
+
|
|
22
|
+
# 或本地开发安装(指向本项目目录)
|
|
23
|
+
dsh plugin --profile web add "D:\chromoany\dsh-notify-me"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
然后**重启 dsh web**(新增插件需重启后进入页面清单),刷新页面即生效。
|
|
27
|
+
|
|
28
|
+
### 首次授权
|
|
29
|
+
|
|
30
|
+
第一次在 DSH 页面上**点击**(或按键)时,浏览器会请求"显示通知"权限,选**允许**,否则只有提示音没有系统通知。误点拒绝后:点地址栏左侧锁 → 站点设置 → 通知 → 允许 → 刷新。
|
|
31
|
+
|
|
32
|
+
### 快速自测
|
|
33
|
+
|
|
34
|
+
切到别的软件(让页面隐藏)后,在 F12 控制台执行:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
window.__dshNotifyMe.test("attention") // "需要你"提醒
|
|
38
|
+
window.__dshNotifyMe.test("done") // "完成"提醒
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 设置(F12 控制台)
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
window.__dshNotifyMe.config // 查看
|
|
45
|
+
window.__dshNotifyMe.setConfig({
|
|
46
|
+
attentionHiddenOnly: false, // true = 页面可见时"需要你"不提醒
|
|
47
|
+
doneHiddenOnly: true, // false = 页面可见时"完成"也提醒
|
|
48
|
+
toast: true, // 系统通知开关
|
|
49
|
+
sound: true, // 提示音开关
|
|
50
|
+
volume: 0.5, // 音量 0~1
|
|
51
|
+
autoFocus: true // 点通知切回 DSH 窗口
|
|
52
|
+
})
|
|
53
|
+
window.__dshNotifyMe.resetConfig() // 恢复默认
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 卸载
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
dsh plugin --profile web remove dsh-notify-me
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
然后重启。
|
|
63
|
+
|
|
64
|
+
## 工作原理
|
|
65
|
+
|
|
66
|
+
浏览器半身订阅客户端 `sessions` 服务:
|
|
67
|
+
|
|
68
|
+
- 当前会话 `ConversationSnapshot`:`running` true→false = 回复完成;`pending[]` 出现 `approval / plan-review / question` = 需要你操作;
|
|
69
|
+
- 其余会话列表摘要:`pendingInteraction` 出现 / `completed` 边沿 = 后台会话需要你或已完成。
|
|
70
|
+
|
|
71
|
+
无 React/UI、无 settings 命名空间依赖;偏好存 `localStorage`(键 `dshNotifyMe.config`)。
|
|
72
|
+
|
|
73
|
+
## 发布与上架
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
# 1. 登录 npm(npmjs.com 注册账号后)
|
|
77
|
+
npm login
|
|
78
|
+
|
|
79
|
+
# 2. 打包并发布(files 白名单已限制包内容)
|
|
80
|
+
npm pack --dry-run # 先看包里有什么
|
|
81
|
+
npm publish
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
发布后即可:`dsh plugin --profile web add dsh-notify-me`。
|
|
85
|
+
|
|
86
|
+
**上架 dshmarket(插件市场)**:市场数据源为 [awesome-dsh-plugin.com](https://awesome-dsh-plugin.com) 的 `plugins.json`(GitHub 仓库维护),收录后可在 设置 → 插件市场 被搜索到("notify" 类目)。提交入口与要求见仓库 CONTRIBUTING;通常需要:npm 已发布 + GitHub 仓库 + 在 plugins.json 里按字段新增一条记录(类别 `notify`)。
|
|
87
|
+
|
|
88
|
+
## 目录结构
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
package.json 插件声明(dsh.client / exports ./client / bundle patch / files 白名单)
|
|
92
|
+
cordis.patch.yml loader 条目(id: notify-me, name: dsh-notify-me)
|
|
93
|
+
lib/index.js 服务端半身(空壳,仅为 loader 条目存在)
|
|
94
|
+
lib/client.js 浏览器半身:通知 / 声音 / 标题标记 + sessions 订阅
|
|
95
|
+
smoke/ 离线冒烟测试(node smoke/smoke-test.cjs)
|
|
96
|
+
LICENSE MIT
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 开发自检
|
|
100
|
+
|
|
101
|
+
```powershell
|
|
102
|
+
node --check lib\client.js
|
|
103
|
+
node --check lib\index.js
|
|
104
|
+
node smoke\smoke-test.cjs # 全绿表示状态机逻辑正常
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 局限与替代
|
|
108
|
+
|
|
109
|
+
- 浏览器层方案:页面必须开着(最小化/后台可以)。若需"浏览器全关也能收到"的 Host 层原生 Windows 通知,可另装 Host 层通知插件。
|
|
110
|
+
- 本插件代码完全自包含、可审计(无第三方运行时依赖,未执行任何本机命令)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-notify-me profile patch layer — insert one loader entry for the
|
|
2
|
+
# plugin package. The entry is a normal cordis loader entry (id + package
|
|
3
|
+
# name); the browser half is picked up by dsh-client-modules through the
|
|
4
|
+
# package's dsh.client declaration, exactly like the shipped ui-* packages.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: notify-me
|
|
7
|
+
name: 'dsh-notify-me'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// dsh-notify-me — browser half (client plugin bundle).
|
|
2
|
+
//
|
|
3
|
+
// Loaded by dsh-client-modules at /plugins/dsh-notify-me/client.js and
|
|
4
|
+
// executed through the vendored cordis Loader's lazy-CJS module table
|
|
5
|
+
// (window.__ModuleLoader__.load). The factory body is plain CJS with
|
|
6
|
+
// require() resolved against the shell's module table — the same shape the
|
|
7
|
+
// shipped ui-* packages' tsdown bundles emit.
|
|
8
|
+
//
|
|
9
|
+
// What this plugin does (all client-side, no settings transport needed):
|
|
10
|
+
// - watches the "sessions" service (SessionRuntime): the selected
|
|
11
|
+
// session's ConversationSnapshot (running / pending interactions) and the
|
|
12
|
+
// session-list summaries of every other session;
|
|
13
|
+
// - fires a Windows desktop Notification (+ optional sound) when the agent
|
|
14
|
+
// needs the user's input — pending interaction kinds 'approval',
|
|
15
|
+
// 'plan-review' or 'question' — and when a reply/turn finishes;
|
|
16
|
+
// - preferences persist in localStorage (origin-scoped, like other
|
|
17
|
+
// third-party client plugins) and are tweakable on window.__dshNotifyMe.
|
|
18
|
+
|
|
19
|
+
window.__ModuleLoader__.load({
|
|
20
|
+
id: "dsh-notify-me",
|
|
21
|
+
factory: (require) => {
|
|
22
|
+
var module = { exports: {} };
|
|
23
|
+
var exports = module.exports;
|
|
24
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
25
|
+
|
|
26
|
+
// ───────────────────────── configuration ─────────────────────────
|
|
27
|
+
var LS_CONFIG = "dshNotifyMe.config";
|
|
28
|
+
var DEFAULTS = {
|
|
29
|
+
// toast + sound are only useful while the user is elsewhere; the tab
|
|
30
|
+
// being hidden is the reliable "away" signal.
|
|
31
|
+
attentionHiddenOnly: false, // needs-input alerts even while the page is visible
|
|
32
|
+
doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
|
|
33
|
+
toast: true,
|
|
34
|
+
sound: true,
|
|
35
|
+
volume: 0.5,
|
|
36
|
+
autoFocus: true // clicking the toast focuses the DSH window
|
|
37
|
+
};
|
|
38
|
+
function readConfig() {
|
|
39
|
+
var cfg = {};
|
|
40
|
+
try {
|
|
41
|
+
var raw = localStorage.getItem(LS_CONFIG);
|
|
42
|
+
if (raw) cfg = JSON.parse(raw);
|
|
43
|
+
} catch (e) { /* corrupt storage -> defaults */ }
|
|
44
|
+
for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
|
|
45
|
+
return cfg;
|
|
46
|
+
}
|
|
47
|
+
function writeConfig(patch) {
|
|
48
|
+
var cfg = readConfig();
|
|
49
|
+
for (var k in patch) cfg[k] = patch[k];
|
|
50
|
+
try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
|
|
51
|
+
return cfg;
|
|
52
|
+
}
|
|
53
|
+
var config = readConfig();
|
|
54
|
+
|
|
55
|
+
// ─────────────────────── notification plumbing ───────────────────
|
|
56
|
+
var permAsked = false;
|
|
57
|
+
function requestPermission() {
|
|
58
|
+
if (!("Notification" in window) || permAsked) return;
|
|
59
|
+
permAsked = true;
|
|
60
|
+
try {
|
|
61
|
+
if (Notification.permission === "default") {
|
|
62
|
+
Notification.requestPermission().catch(function () {});
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {}
|
|
65
|
+
}
|
|
66
|
+
function canToast() {
|
|
67
|
+
return config.toast && "Notification" in window && Notification.permission === "granted";
|
|
68
|
+
}
|
|
69
|
+
function showToast(kind, title, body) {
|
|
70
|
+
if (!canToast()) return;
|
|
71
|
+
try {
|
|
72
|
+
var n = new Notification(title, {
|
|
73
|
+
body: body || "",
|
|
74
|
+
tag: "dsh-notify-me-" + kind,
|
|
75
|
+
renotify: true,
|
|
76
|
+
icon: (location.origin || "") + "/favicon.svg"
|
|
77
|
+
});
|
|
78
|
+
if (config.autoFocus) {
|
|
79
|
+
n.onclick = function () {
|
|
80
|
+
try { window.focus(); } catch (e) {}
|
|
81
|
+
n.close();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
setTimeout(function () { n.close(); }, 15000);
|
|
85
|
+
} catch (e) {}
|
|
86
|
+
}
|
|
87
|
+
// Sound: WebAudio beeps (no asset needed). The context only runs after a
|
|
88
|
+
// user gesture; we unlock it on the first pointer/key event.
|
|
89
|
+
var audioCtx = null;
|
|
90
|
+
function unlockAudio() {
|
|
91
|
+
try {
|
|
92
|
+
if (!audioCtx) {
|
|
93
|
+
var AC = window.AudioContext || window.webkitAudioContext;
|
|
94
|
+
if (!AC) return;
|
|
95
|
+
audioCtx = new AC();
|
|
96
|
+
}
|
|
97
|
+
if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
|
|
98
|
+
} catch (e) {}
|
|
99
|
+
}
|
|
100
|
+
function tone(freq, startIn, durMs, vol) {
|
|
101
|
+
if (!audioCtx || audioCtx.state !== "running") return;
|
|
102
|
+
try {
|
|
103
|
+
var t0 = audioCtx.currentTime + startIn;
|
|
104
|
+
var osc = audioCtx.createOscillator();
|
|
105
|
+
var gain = audioCtx.createGain();
|
|
106
|
+
osc.type = "sine";
|
|
107
|
+
osc.frequency.value = freq;
|
|
108
|
+
gain.gain.setValueAtTime(0.0001, t0);
|
|
109
|
+
gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
|
|
110
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
|
|
111
|
+
osc.connect(gain).connect(audioCtx.destination);
|
|
112
|
+
osc.start(t0);
|
|
113
|
+
osc.stop(t0 + durMs / 1000 + 0.05);
|
|
114
|
+
} catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
function playAttentionSound() {
|
|
117
|
+
if (!config.sound) return;
|
|
118
|
+
unlockAudio();
|
|
119
|
+
tone(880, 0.0, 140, config.volume);
|
|
120
|
+
tone(1174, 0.16, 140, config.volume);
|
|
121
|
+
tone(1568, 0.32, 200, config.volume);
|
|
122
|
+
}
|
|
123
|
+
function playDoneSound() {
|
|
124
|
+
if (!config.sound) return;
|
|
125
|
+
unlockAudio();
|
|
126
|
+
tone(659, 0.0, 150, config.volume);
|
|
127
|
+
tone(988, 0.18, 260, config.volume);
|
|
128
|
+
}
|
|
129
|
+
function unlockOnce() {
|
|
130
|
+
unlockAudio();
|
|
131
|
+
requestPermission();
|
|
132
|
+
}
|
|
133
|
+
function onFirstGesture() {
|
|
134
|
+
unlockOnce();
|
|
135
|
+
window.removeEventListener("pointerdown", onFirstGesture, true);
|
|
136
|
+
window.removeEventListener("keydown", onFirstGesture, true);
|
|
137
|
+
}
|
|
138
|
+
window.addEventListener("pointerdown", onFirstGesture, true);
|
|
139
|
+
window.addEventListener("keydown", onFirstGesture, true);
|
|
140
|
+
|
|
141
|
+
// ────────────────────────── title marker ─────────────────────────
|
|
142
|
+
var originalTitle = null;
|
|
143
|
+
var attentionActive = 0;
|
|
144
|
+
function applyTitle() {
|
|
145
|
+
try {
|
|
146
|
+
if (originalTitle === null) originalTitle = document.title;
|
|
147
|
+
document.title = attentionActive > 0 ? "🔔 需要你 · " + originalTitle : originalTitle;
|
|
148
|
+
} catch (e) {}
|
|
149
|
+
}
|
|
150
|
+
function acquireTitleMark() {
|
|
151
|
+
attentionActive += 1;
|
|
152
|
+
applyTitle();
|
|
153
|
+
}
|
|
154
|
+
function releaseTitleMark() {
|
|
155
|
+
if (attentionActive > 0) attentionActive -= 1;
|
|
156
|
+
applyTitle();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─────────────────────── shared alert core ───────────────────────
|
|
160
|
+
var attentionKeysFired = Object.create(null); // dedupe per pending key
|
|
161
|
+
var lastAttentionAt = 0;
|
|
162
|
+
|
|
163
|
+
function hidden() { return document.hidden || document.visibilityState === "hidden"; }
|
|
164
|
+
|
|
165
|
+
function fire(kind, title, body) {
|
|
166
|
+
if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return;
|
|
167
|
+
if (kind === "done" && config.doneHiddenOnly && !hidden()) return;
|
|
168
|
+
showToast(kind, title, body);
|
|
169
|
+
if (kind === "attention") {
|
|
170
|
+
playAttentionSound();
|
|
171
|
+
acquireTitleMark();
|
|
172
|
+
} else {
|
|
173
|
+
playDoneSound();
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
|
|
177
|
+
window.__dshNotifyMe.onEvent(kind, { title: title, body: body });
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {}
|
|
180
|
+
}
|
|
181
|
+
function fireAttention(kind, sessionLabel, detail) {
|
|
182
|
+
var label = { approval: "审批请求", "plan-review": "方案待确认", question: "提问" }[kind] || "需要操作";
|
|
183
|
+
var d = detail || "";
|
|
184
|
+
fire("attention", "DSH · " + label, (sessionLabel ? sessionLabel + ":" : "") + d);
|
|
185
|
+
}
|
|
186
|
+
function fireDone(sessionLabel, snippet) {
|
|
187
|
+
var body = sessionLabel ? sessionLabel : "回复已完成";
|
|
188
|
+
if (snippet) body = body + "\n" + snippet;
|
|
189
|
+
fire("done", "DSH · 回复完成", body);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ───────────────────────── state machine ─────────────────────────
|
|
193
|
+
// prevById: per-session-id baseline of what we have already reported.
|
|
194
|
+
// { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
|
|
195
|
+
var prevById = Object.create(null);
|
|
196
|
+
var faceSub = null; // current session face unsubscribe
|
|
197
|
+
var currentFaceId = null;
|
|
198
|
+
var listStore = null;
|
|
199
|
+
var rootCtx = null; // client cordis context, captured at apply()
|
|
200
|
+
|
|
201
|
+
function textFromPayload(payload) {
|
|
202
|
+
if (!payload || typeof payload !== "object") return "";
|
|
203
|
+
var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "name", "tool"];
|
|
204
|
+
for (var i = 0; i < keys.length; i++) {
|
|
205
|
+
var v = payload[keys[i]];
|
|
206
|
+
if (typeof v === "string" && v.trim()) return v.length > 160 ? v.slice(0, 160) + "…" : v;
|
|
207
|
+
if (typeof v === "object" && v !== null && typeof v.text === "string" && v.text.trim()) {
|
|
208
|
+
var s = v.text;
|
|
209
|
+
return s.length > 160 ? s.slice(0, 160) + "…" : s;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
function snippetOfLastAssistant(snap) {
|
|
215
|
+
try {
|
|
216
|
+
var nodes = snap.nodes || [];
|
|
217
|
+
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
218
|
+
var nd = nodes[i];
|
|
219
|
+
if (nd && nd.kind === "assistant" && nd.blocks) {
|
|
220
|
+
for (var b = nd.blocks.length - 1; b >= 0; b--) {
|
|
221
|
+
if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
|
|
222
|
+
var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
|
|
223
|
+
if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch (e) {}
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
// Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
|
|
232
|
+
function evaluateRow(row) {
|
|
233
|
+
var prev = prevById[row.id];
|
|
234
|
+
var runningNow = !!row.running;
|
|
235
|
+
var pendingNow = row.pending || [];
|
|
236
|
+
var prevPendingKeys = prev ? prev.pendingKeys : [];
|
|
237
|
+
var wasRunning = prev ? prev.running : false;
|
|
238
|
+
var newKeys = [];
|
|
239
|
+
for (var i = 0; i < pendingNow.length; i++) {
|
|
240
|
+
var pk = pendingNow[i].key;
|
|
241
|
+
if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
|
|
242
|
+
newKeys.push(pendingNow[i]);
|
|
243
|
+
attentionKeysFired[pk] = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
var hasPendingNow = pendingNow.length > 0;
|
|
247
|
+
|
|
248
|
+
// 1) a pending interaction just appeared -> the agent is waiting on the user.
|
|
249
|
+
// First evaluation of a session also lands here (prevPendingKeys empty),
|
|
250
|
+
// so a wait that already existed when the plugin (or page) mounted still
|
|
251
|
+
// alerts once.
|
|
252
|
+
if (newKeys.length > 0) {
|
|
253
|
+
var it = newKeys[0];
|
|
254
|
+
var detail = textFromPayload(it.payload);
|
|
255
|
+
if (!detail) detail = { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" }[it.kind] || "需要你操作";
|
|
256
|
+
var label = row.label && row.label !== "current" ? row.label : "当前对话";
|
|
257
|
+
fireAttention(it.kind, label, detail);
|
|
258
|
+
lastAttentionAt = Date.now();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2) a non-selected session finished while we were away (sidebar "done")
|
|
262
|
+
var notified = false;
|
|
263
|
+
if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
|
|
264
|
+
fireDone(row.label || "后台会话", "");
|
|
265
|
+
notified = true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3) running true -> false on a session we were watching: a reply finished
|
|
269
|
+
if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
|
|
270
|
+
// ignore the edge if it happened in the same tick as an attention alert
|
|
271
|
+
if (Date.now() - lastAttentionAt < 300) {
|
|
272
|
+
/* answered by an attention alert above */
|
|
273
|
+
} else {
|
|
274
|
+
fireDone(row.label && row.label !== "current" ? row.label : "当前对话", row.face ? snippetOfLastAssistant(row.faceSnap) : "");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// store the next baseline
|
|
279
|
+
prevById[row.id] = {
|
|
280
|
+
running: runningNow,
|
|
281
|
+
pendingKeys: pendingNow.map(function (p) { return p.key; }),
|
|
282
|
+
completed: !!row.completed,
|
|
283
|
+
seeded: true
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// a wait we alerted about has been resolved -> drop the title marker
|
|
287
|
+
if (prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
|
|
288
|
+
releaseTitleMark();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// face -> row adapter (selected session, rich data)
|
|
293
|
+
function evalSessionFace(sessionId, snap) {
|
|
294
|
+
if (sessionId !== currentFaceId) return;
|
|
295
|
+
var pend = (snap.pending || []).map(function (p) {
|
|
296
|
+
return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
|
|
297
|
+
});
|
|
298
|
+
var label = "current";
|
|
299
|
+
evaluateRow({
|
|
300
|
+
id: sessionId,
|
|
301
|
+
running: !!snap.running,
|
|
302
|
+
pending: pend,
|
|
303
|
+
completed: false,
|
|
304
|
+
face: true,
|
|
305
|
+
faceSnap: snap,
|
|
306
|
+
label: label
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// list row -> adapter (every other listed session)
|
|
310
|
+
function evalListRow(sid, sum, currentId) {
|
|
311
|
+
evaluateRow({
|
|
312
|
+
id: sid,
|
|
313
|
+
running: !!sum.running,
|
|
314
|
+
pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
|
|
315
|
+
completed: !!sum.completed,
|
|
316
|
+
face: false,
|
|
317
|
+
label: sum.displayTitle || sum.title || sid
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─────────────────────── subscription wiring ─────────────────────
|
|
322
|
+
function bindCurrentFace() {
|
|
323
|
+
if (!listStore) return;
|
|
324
|
+
try {
|
|
325
|
+
var st = listStore.getSnapshot();
|
|
326
|
+
var cur = st && st.current;
|
|
327
|
+
if (!cur) { detachFace(); return; }
|
|
328
|
+
if (cur === currentFaceId && faceSub) return;
|
|
329
|
+
detachFace();
|
|
330
|
+
currentFaceId = cur;
|
|
331
|
+
var b = rootCtx.sessions.binding(cur);
|
|
332
|
+
if (!b || !b.session || typeof b.session.subscribe !== "function") {
|
|
333
|
+
// binding not minted yet — the retry timer will re-check
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
faceSub = b.session.subscribe(function () {
|
|
337
|
+
try {
|
|
338
|
+
evalSessionFace(cur, b.session.getSnapshot());
|
|
339
|
+
} catch (e) {}
|
|
340
|
+
});
|
|
341
|
+
// First evaluation seeds the baseline (no false completion edges) while
|
|
342
|
+
// still alerting for interactions that were already pending on bind.
|
|
343
|
+
try {
|
|
344
|
+
evalSessionFace(cur, b.session.getSnapshot());
|
|
345
|
+
} catch (e) {}
|
|
346
|
+
} catch (e) {}
|
|
347
|
+
}
|
|
348
|
+
function detachFace() {
|
|
349
|
+
if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
|
|
350
|
+
currentFaceId = null;
|
|
351
|
+
}
|
|
352
|
+
function onListChanged() {
|
|
353
|
+
if (!listStore) return;
|
|
354
|
+
try {
|
|
355
|
+
var st = listStore.getSnapshot();
|
|
356
|
+
var cur = st && st.current;
|
|
357
|
+
bindCurrentFace();
|
|
358
|
+
var byId = (st && st.byId) || {};
|
|
359
|
+
for (var sid in byId) {
|
|
360
|
+
if (sid === cur) continue; // current handled by the face watcher
|
|
361
|
+
var sum = byId[sid];
|
|
362
|
+
if (!sum) continue;
|
|
363
|
+
evalListRow(sid, sum, cur);
|
|
364
|
+
}
|
|
365
|
+
// sessions that vanished: keep baseline (they may reappear) - no action.
|
|
366
|
+
} catch (e) {}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ──────────────────────── global control API ─────────────────────
|
|
370
|
+
var api = {
|
|
371
|
+
version: "0.1.0",
|
|
372
|
+
get config() { return readConfig(); },
|
|
373
|
+
setConfig: function (patch) { config = writeConfig(patch); return config; },
|
|
374
|
+
resetConfig: function () {
|
|
375
|
+
try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
|
|
376
|
+
config = readConfig();
|
|
377
|
+
return config;
|
|
378
|
+
},
|
|
379
|
+
// manual test; run it after clicking anywhere so permission can be granted.
|
|
380
|
+
test: function (kind) {
|
|
381
|
+
kind = kind === "attention" ? "attention" : "done";
|
|
382
|
+
unlockOnce();
|
|
383
|
+
if (kind === "attention") fireAttention("question", "测试", "这是一条测试提醒:DSH 需要你的操作");
|
|
384
|
+
else fireDone("测试提醒", "");
|
|
385
|
+
return "sent " + kind + " (permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
window.__dshNotifyMe = api;
|
|
389
|
+
|
|
390
|
+
// ────────────────────────── plugin body ──────────────────────────
|
|
391
|
+
var inject = ["sessions"];
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Client plugin body: watch the sessions service and raise desktop
|
|
395
|
+
* notifications + sounds when the agent needs input or a reply finishes.
|
|
396
|
+
* @param ctx - client root context.
|
|
397
|
+
*/
|
|
398
|
+
function apply(ctx) {
|
|
399
|
+
rootCtx = ctx;
|
|
400
|
+
ctx.effect(function () {
|
|
401
|
+
var disposed = false;
|
|
402
|
+
var unsubs = [];
|
|
403
|
+
var retryTimer = null;
|
|
404
|
+
try {
|
|
405
|
+
var sessionsSvc = ctx.sessions;
|
|
406
|
+
if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
|
|
407
|
+
try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
listStore = sessionsSvc.list;
|
|
411
|
+
unsubs.push(sessionsSvc.list.subscribe(onListChanged));
|
|
412
|
+
// initial scan (baseline only; no alerts for pre-existing state)
|
|
413
|
+
onListChanged();
|
|
414
|
+
// binding may resolve a tick later; re-check periodically while idle
|
|
415
|
+
retryTimer = setInterval(function () {
|
|
416
|
+
if (disposed) return;
|
|
417
|
+
try { bindCurrentFace(); } catch (e) {}
|
|
418
|
+
}, 1500);
|
|
419
|
+
console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (toast=" + config.toast + ", sound=" + config.sound + ")");
|
|
420
|
+
} catch (e) {
|
|
421
|
+
try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
|
|
422
|
+
}
|
|
423
|
+
return function () {
|
|
424
|
+
disposed = true;
|
|
425
|
+
if (retryTimer) clearInterval(retryTimer);
|
|
426
|
+
for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
|
|
427
|
+
detachFace();
|
|
428
|
+
};
|
|
429
|
+
}, "dsh-notify-me: watchers");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
exports.inject = inject;
|
|
433
|
+
exports.apply = apply;
|
|
434
|
+
return module.exports;
|
|
435
|
+
}
|
|
436
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-me — host half (no-op).
|
|
3
|
+
*
|
|
4
|
+
* Everything this plugin does lives in the browser half (lib/client.js):
|
|
5
|
+
* desktop notifications and sounds when the agent needs the user's input or a
|
|
6
|
+
* reply finishes. This module exists only so the loader entry mounts the
|
|
7
|
+
* package, letting dsh-client-modules pick up the dsh.client declaration and
|
|
8
|
+
* serve /plugins/dsh-notify-me/client.js to the page.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Plugin identity for cordis.yml rows. */
|
|
12
|
+
export const name = "dsh-notify-me";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Host loader entry: nothing to mount on the server side.
|
|
16
|
+
* @param ctx - host cordis context.
|
|
17
|
+
*/
|
|
18
|
+
export function apply() {}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-notify-me",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH web plugin (桌面提醒): desktop notifications and sounds when the agent needs your input (approval / question / plan review) and when a reply finishes, so you notice while working in other apps.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/index.js",
|
|
15
|
+
"lib/client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"deepseek-harness",
|
|
22
|
+
"dsh",
|
|
23
|
+
"dsh-plugin",
|
|
24
|
+
"notification",
|
|
25
|
+
"desktop",
|
|
26
|
+
"notify",
|
|
27
|
+
"提醒"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node smoke/smoke-test.cjs",
|
|
34
|
+
"pack": "npm pack --dry-run"
|
|
35
|
+
},
|
|
36
|
+
"author": "chromoany",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/chromoany/dsh-notify-me.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/chromoany/dsh-notify-me#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/chromoany/dsh-notify-me/issues"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"inject": [
|
|
51
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
52
|
+
"@deepseek-ai/dsh-client-locale",
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
54
|
+
],
|
|
55
|
+
"platform": "web"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6 || ^0.1.1-rc.2"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@deepseek-ai/dsh-client-runtime": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|