@mzzsfy/dsh-turn-notify 0.7.3 → 0.8.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 +51 -22
- package/package.json +2 -2
- package/src/client.js +136 -21
- package/src/core.mjs +107 -10
- package/src/index.js +30 -3
- package/test/core.test.mjs +72 -2
- package/test/flow.test.mjs +243 -5
- package/test/parity.test.mjs +27 -2
- package/test/route.test.mjs +20 -0
package/README.md
CHANGED
|
@@ -2,21 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/被中断/等待审批/AI 提问/达到上限),按你的配置送达声音、系统弹窗、页内提示、webhook、IM 五种通道,多窗口只响一次。
|
|
4
4
|
|
|
5
|
-
设计文档见 [docs/design/dsh-turn-notify-weixin.md](../../docs/design/dsh-turn-notify-weixin.md)。
|
|
6
|
-
|
|
7
5
|
## 快速上手
|
|
8
6
|
|
|
9
7
|
```sh
|
|
10
8
|
dsh plugin --profile web add @mzzsfy/dsh-turn-notify
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```sh
|
|
16
|
-
dsh plugin --profile web add file:./packages/dsh-turn-notify
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
安装后打开 设置 > 消息通知:默认全部事件开启,声音、页内提示、系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook 与 IM 填配置后启用。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送)。
|
|
11
|
+
安装后打开 设置 > 消息通知:默认全部事件开启,声音与系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook 与 IM 填配置后启用。页内提示经公共依赖 @mzzsfy/dsh-toast 提供,由 session-manager 插件代挂装载——未安装 session-manager 时该通道自动禁用,其余通道不受影响。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送)。
|
|
20
12
|
|
|
21
13
|
## 六类通知事件
|
|
22
14
|
|
|
@@ -44,7 +36,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
|
|
|
44
36
|
- **8 种内置音色**:上行琶音、铃铛、清脆双音、警报方波、低鸣、双音提示、嘀嗒、低音下滑。由 Web Audio 实时合成,零下载、零系统依赖。浏览器自动播放策略要求先与页面交互一次(任意点击),首次交互前声音静默,其余通道不受影响。
|
|
45
37
|
- **自定义音效上传**:支持 wav / mp3 / ogg,可一次多选,单文件上限 2MB、总库上限 10MB。上传前可逐个试听,确认后才落盘保存。支持重命名;同一文件重复上传自动识别,不产生重复文件。
|
|
46
38
|
- **按事件分类指定音效**:六类事件各自映射一款音效(内置或上传均可),例如"任务完成"用上行琶音、"等待审批"用双音提示。删除音效时引用它的映射自动清空、回落内置默认;仅当映射指向不存在的音效(如手改 yaml 产生死链)时,面板才显示失效项,试听时给出归因提示。
|
|
47
|
-
- **按分类静音**:总开关之外,可单独让某一类事件不出声,例如只对"AI 提问""等待审批"
|
|
39
|
+
- **按分类静音**:总开关之外,可单独让某一类事件不出声,例如只对"AI 提问""等待审批"出声。静音只关闭声音通道本身,系统弹窗与标题闪烁照常;页内提示卡片与会话高亮不受影响;页内提示音有独立的分类开关,不随此处变化。
|
|
48
40
|
- **音量**:按 5% 步进 0-100% 可调,本机记忆。
|
|
49
41
|
- **映射双作用域**:音效映射默认全局共用(存 settings.yaml,所有浏览器一致);打开"当前域名独立"后,映射改动只写本浏览器(按域名隔离),公司/家里各配各的,本地优先于全局。关闭开关即恢复全局,本地已存映射休眠保留,重新开启即恢复。
|
|
50
42
|
- **试听**:音效库与分类映射的每一项都能即时试听,试听的就是实际生效的音效。
|
|
@@ -53,15 +45,40 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
|
|
|
53
45
|
|
|
54
46
|
## 系统弹窗与页内提示
|
|
55
47
|
|
|
56
|
-
- **系统弹窗**:浏览器 Notification
|
|
57
|
-
- **页内提示**:页面角落浮出的卡片提示(dsh-toast 栈式展示,6 秒)
|
|
48
|
+
- **系统弹窗**:浏览器 Notification 授权后,窗口失焦时弹系统级通知;标题为事件文本,通知小结(见下节)作正文。点击弹窗聚焦窗口并直接切换到对应会话(点击直达,下节)。HTTP 非回环地址下浏览器不提供该能力,声音开启时自动降级为标题闪烁;HTTPS 下自动恢复。Windows 下还受系统通知设置与专注助手约束。
|
|
49
|
+
- **页内提示**:页面角落浮出的卡片提示(dsh-toast 栈式展示,6 秒),聚焦窗口内唯一常开的提醒形态,可关;通知带小结时以第二行呈现,点击卡片直达对应会话。
|
|
58
50
|
- **页内提示音**:聚焦场景的独立声音,总开关、按事件分类独立开关与音色映射全套自成一档,独立于提示音的分类配置。开启后页内提示弹出且通知声音未播时补一声提示——聚焦窗口内通知声音被聚焦静默压制,靠它保留听觉提醒;与通知声音互斥,同一通知至多一声;音量共用,音色在音效页的「页内提示音映射」单独指定。
|
|
59
|
-
-
|
|
60
|
-
-
|
|
51
|
+
- **降级标题闪烁**:系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;单条闪烁 6 秒自停,连环通知逐条续命但总时长硬顶 30 秒必停,返回窗口立即停止;可在偏好中关闭该提示。
|
|
52
|
+
- **聚焦静默**:窗口可见且有焦点时只保留页内提示,声音、系统弹窗与标题闪烁全部静默(页内提示音开启且该分类未在页内静音时仍有补位一声);可见但无焦点(多屏正看别的窗口)不静默,照常弹窗出声;你离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒。
|
|
53
|
+
|
|
54
|
+
## 点击直达会话
|
|
55
|
+
|
|
56
|
+
等待你动作的审批与提问、以及回合结束类通知,呈现时都携带直达动作:点击页内提示卡片或系统弹窗,窗口聚焦并自动切换到对应会话(模拟点击侧边栏会话行,与手工点击完全同路),高亮随之清除(查看即已读)。会话行未渲染(列表折叠/懒加载未滚动到)时仅聚焦窗口;通知无会话标题时同样只聚焦。「知道了」类显式关闭不触发直达。
|
|
57
|
+
|
|
58
|
+
## 等待动作二次提醒
|
|
59
|
+
|
|
60
|
+
审批请求与 AI 提问送达后 10 分钟仍未处理时,自动补发一轮提醒(页内提示 + 声音 + 标题闪烁,独立于聚焦静默与通道路由——它就是"上一轮没看到"的兜底),每条通知至多补发一次,不连环催。"已处理"的判定:你已切到该会话,或已点掉会话行高亮;通知无会话标题时无法感知处理,按未处理补发。通知被其他窗口处理或已移出投影队列时,补发登记自动撤销。
|
|
61
|
+
|
|
62
|
+
## 通知小结
|
|
63
|
+
|
|
64
|
+
回合结束类通知附一句话小结:耗时(回合时长)+ tokens(本回合各步用量四桶合计)+ 最终回答前缀(末条含文本回答的首行,按码点截断),拼装为「耗时 12 秒 · 1.5k tokens · 已修复登录」形态;缺哪项省哪项,全缺省无小结。系统弹窗作正文、页内卡片作第二行,webhook payload 增补 `summary` 与 `tokens` 结构化字段。统计来自 host 侧会话事件流内的 assistant 消息,回合结束即消费清账,无额外请求。
|
|
65
|
+
|
|
66
|
+
## 事件→通道路由
|
|
67
|
+
|
|
68
|
+
六类事件各自可指定放行通道名单(kindRoutes,存 settings.yaml),名单外的通道对该类事件一律不送达;未配置的分类全通道放行,名单为空数组即全禁。通道名:`sound`(提示音)/ `system`(系统弹窗)/ `toast`(页内提示,页内提示音随行)/ `blink`(降级标题闪烁)/ `webhook` / `im`。典型用法:任务完成只推 webhook,重要事件(审批/提问)走 IM:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
turn-notify:
|
|
72
|
+
kindRoutes:
|
|
73
|
+
completed: [webhook] # 完成只推 webhook,浏览器不响
|
|
74
|
+
approval: [im, sound, system] # 审批走 IM 且本机弹窗出声
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
路由是全局配置,作用于所有浏览器;本机呈现偏好(声音/弹窗开关、聚焦静默)在其上叠加,两层都放行才送达。面板"通知"分区不展示该配置,yaml 直改保存即生效。
|
|
61
78
|
|
|
62
79
|
## webhook 推送
|
|
63
80
|
|
|
64
|
-
在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text
|
|
81
|
+
在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。凭据只写不回显,测试按钮返回真实投递结果。
|
|
65
82
|
|
|
66
83
|
## IM 推送
|
|
67
84
|
|
|
@@ -69,11 +86,11 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
|
|
|
69
86
|
|
|
70
87
|
## 会话行高亮
|
|
71
88
|
|
|
72
|
-
通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)
|
|
89
|
+
通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题默认与侧边栏行同源(session-title 投影);该服务缺失或不可用(如伪会话无事件日志)时回落会话事件内存储的标题,可能与侧边栏不同步。会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。重开页面的冷启动静默对齐同样挂高亮(见"多窗口只响一次"),重开即见哪些会话有动静。
|
|
73
90
|
|
|
74
91
|
## 多窗口只响一次
|
|
75
92
|
|
|
76
|
-
同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage
|
|
93
|
+
同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现);未读会话行高亮不在静默范围,照常挂上。
|
|
77
94
|
|
|
78
95
|
## 设置面板指南
|
|
79
96
|
|
|
@@ -81,7 +98,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
|
|
|
81
98
|
|
|
82
99
|
| 分区 | 内容 |
|
|
83
100
|
|------|------|
|
|
84
|
-
| 通知 | webhook 地址、最短回合时长、子代理过滤、六类事件开关;存 settings.yaml
|
|
101
|
+
| 通知 | webhook 地址、最短回合时长、子代理过滤、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
|
|
85
102
|
| 偏好 | 本机浏览器记忆的设置:会话高亮(独立开关)、声音总开关与分类静音、音量、系统弹窗与授权、页内提示开关、页内提示音(总开关 + 分类独立开关)、聚焦静默、降级标题闪烁 |
|
|
86
103
|
| 音效 | 音效上传/试听/重命名/删除,六类事件的音效映射与双作用域开关,页内提示音映射(本机,未配置沿用通知音效) |
|
|
87
104
|
| IM | 绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
|
|
@@ -106,6 +123,8 @@ turn-notify:
|
|
|
106
123
|
max-tokens: true
|
|
107
124
|
soundMapping: # 每类事件音效映射,空为内置默认,值为内置音名或上传音效 id
|
|
108
125
|
completed: ''
|
|
126
|
+
kindRoutes: # 事件→通道路由:分类到放行通道名单,未配置的分类全通道放行
|
|
127
|
+
completed: [webhook]
|
|
109
128
|
imTargets: # dsh-im 投递目标,空数组禁用
|
|
110
129
|
- botId: wx_xxx
|
|
111
130
|
targetId: owner
|
|
@@ -117,10 +136,10 @@ turn-notify:
|
|
|
117
136
|
|
|
118
137
|
按数据流向分五步:
|
|
119
138
|
|
|
120
|
-
1. **事件监听**:host 观察 `session/event`(
|
|
121
|
-
2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)
|
|
122
|
-
3. **host 直发通道**:webhook 与 IM 由 host
|
|
123
|
-
4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage
|
|
139
|
+
1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
|
|
140
|
+
2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
|
|
141
|
+
3. **host 直发通道**:webhook 与 IM 由 host 进程直接投递(经路由名单过滤),不依赖浏览器;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
|
|
142
|
+
4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
|
|
124
143
|
5. **降级链**:声音通道开启而系统弹窗不可用(HTTP 非回环、未授权)→ 标题闪烁;页内提示依赖缺失 → 干净禁用该通道;localStorage 不可用 → 该窗口直接发声(可能与多窗口重复,诚实降级)。
|
|
125
144
|
|
|
126
145
|
## 已知取舍
|
|
@@ -130,6 +149,8 @@ turn-notify:
|
|
|
130
149
|
- 不同浏览器或不同地址打开的窗口各自发声,互不去重。
|
|
131
150
|
- 系统弹窗在 HTTP 非回环下永久降级,HTTPS 化后自动恢复;Windows 下受系统通知设置与专注助手约束。
|
|
132
151
|
- 长轮询空闲期连接由代理或服务端挂起,经中间反代时需允许数十秒的挂起响应,否则退化为更频繁的重连。
|
|
152
|
+
- 等待动作二次提醒的"已处理"判定以本窗口可见信号(会话切换/高亮清除)近似,无会话标题的通知无法感知处理,宁可多响一次。
|
|
153
|
+
- 点击直达依赖侧边栏会话行可被标题匹配命中,行未渲染或标题不同步时仅聚焦不切换。
|
|
133
154
|
|
|
134
155
|
## 安全
|
|
135
156
|
|
|
@@ -144,3 +165,11 @@ turn-notify:
|
|
|
144
165
|
cd packages/dsh-turn-notify && npm test
|
|
145
166
|
```
|
|
146
167
|
|
|
168
|
+
## 开发安装(仓库维护者)
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依赖行 + 挂 junction
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。
|
|
175
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-turn-notify",
|
|
3
3
|
"description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook 与 IM;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=22"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@mzzsfy/dsh-toast": "^0.
|
|
36
|
+
"@mzzsfy/dsh-toast": "^0.2.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@deepseek-ai/dsh-session-title": ">=0.1.2-rc.1",
|
package/src/client.js
CHANGED
|
@@ -28,6 +28,8 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
// 页内通知展示期,经公共依赖 holdMs 传入
|
|
29
29
|
const TOAST_MS = 6 * 1000
|
|
30
30
|
const BLINK_MS = 1 * 1000
|
|
31
|
+
// 连环通知下标题闪烁的总时长硬顶
|
|
32
|
+
const BLINK_MAX_MS = 30 * 1000
|
|
31
33
|
// 系统通知测试延迟:浏览器对聚焦窗口抑制系统弹窗,倒计时供用户切出窗口
|
|
32
34
|
const SYSTEM_TEST_DELAY_MS = 5 * 1000
|
|
33
35
|
// 显示回执等待上限:超时未回执按环境层拦截给出诊断
|
|
@@ -275,7 +277,8 @@ window.__ModuleLoader__.load({
|
|
|
275
277
|
// 放入 LOGIC 段由 parity 测试保证双实现不漂移
|
|
276
278
|
const IDLE_AWAY_MS = 5 * 60 * 1000
|
|
277
279
|
|
|
278
|
-
|
|
280
|
+
// routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源
|
|
281
|
+
function chooseChannels(hasFocus, permission, idleMs, soundCategories, category, routes) {
|
|
279
282
|
const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
|
|
280
283
|
const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
|
|
281
284
|
const systemEnabled = localGet(KEY_SYSTEM) !== '0'
|
|
@@ -284,13 +287,14 @@ window.__ModuleLoader__.load({
|
|
|
284
287
|
const pageSoundCategories = readPageSoundCategories()
|
|
285
288
|
const categoryMuted = soundCategories != null && category != null && soundCategories[category] === false
|
|
286
289
|
const pageCategoryMuted = pageSoundCategories != null && category != null && pageSoundCategories[category] === false
|
|
287
|
-
const
|
|
290
|
+
const routed = (channel) => routes == null || routes.indexOf(channel) >= 0
|
|
291
|
+
const sound = !quiet && soundEnabled && !categoryMuted && routed('sound')
|
|
288
292
|
return {
|
|
289
|
-
toast: toastEnabled,
|
|
293
|
+
toast: toastEnabled && routed('toast'),
|
|
290
294
|
sound,
|
|
291
|
-
system: !quiet && systemEnabled && permission === 'granted',
|
|
292
|
-
blink: !quiet && systemEnabled && permission !== 'granted',
|
|
293
|
-
pageSound: localGet(KEY_PAGE_SOUND) === '1' && toastEnabled && !pageCategoryMuted && !sound,
|
|
295
|
+
system: !quiet && systemEnabled && permission === 'granted' && routed('system'),
|
|
296
|
+
blink: !quiet && systemEnabled && permission !== 'granted' && routed('blink'),
|
|
297
|
+
pageSound: localGet(KEY_PAGE_SOUND) === '1' && toastEnabled && !pageCategoryMuted && !sound && routed('toast'),
|
|
294
298
|
}
|
|
295
299
|
}
|
|
296
300
|
|
|
@@ -475,17 +479,28 @@ window.__ModuleLoader__.load({
|
|
|
475
479
|
// 本包只保留标题闪烁通道
|
|
476
480
|
|
|
477
481
|
let blinkTimer = null
|
|
482
|
+
let blinkStopTimer = null
|
|
478
483
|
const baseTitle = () => document.title.replace(/^⏳ /, '')
|
|
479
484
|
|
|
485
|
+
// 连环通知(并行会话批量报错)间隔小于 TOAST_MS 时,闪烁会被逐条续命;
|
|
486
|
+
// 硬顶 BLINK_MAX_MS 保证任何情况下标题必停,焦点返回时由可见性同步立即清除
|
|
480
487
|
function startTitleBlink() {
|
|
481
|
-
if (blinkTimer !== null)
|
|
488
|
+
if (blinkTimer !== null) {
|
|
489
|
+
clearTimeout(blinkStopTimer)
|
|
490
|
+
blinkStopTimer = setTimeout(stopTitleBlink, BLINK_MAX_MS - BLINK_MS)
|
|
491
|
+
return
|
|
492
|
+
}
|
|
482
493
|
blinkTimer = setInterval(() => {
|
|
483
494
|
document.title = document.title.startsWith('⏳ ') ? baseTitle() : '⏳ ' + baseTitle()
|
|
484
495
|
}, BLINK_MS)
|
|
485
|
-
setTimeout(stopTitleBlink, TOAST_MS)
|
|
496
|
+
blinkStopTimer = setTimeout(stopTitleBlink, TOAST_MS)
|
|
486
497
|
}
|
|
487
498
|
|
|
488
499
|
function stopTitleBlink() {
|
|
500
|
+
if (blinkStopTimer !== null) {
|
|
501
|
+
clearTimeout(blinkStopTimer)
|
|
502
|
+
blinkStopTimer = null
|
|
503
|
+
}
|
|
489
504
|
if (blinkTimer === null) return
|
|
490
505
|
clearInterval(blinkTimer)
|
|
491
506
|
blinkTimer = null
|
|
@@ -494,10 +509,21 @@ window.__ModuleLoader__.load({
|
|
|
494
509
|
|
|
495
510
|
const notificationPermission = () => (typeof Notification === 'undefined' ? 'denied' : Notification.permission)
|
|
496
511
|
|
|
497
|
-
// onOutcome 仅供测试路径取显示回执(onshow/onerror)
|
|
512
|
+
// onOutcome 仅供测试路径取显示回执(onshow/onerror),真实路径吞错降级已在链路内。
|
|
513
|
+
// onclick 点击直达:聚焦窗口并切到对应会话后关闭弹窗;summary 作 body 两级呈现
|
|
498
514
|
function notifySystem(unit, onOutcome) {
|
|
499
515
|
try {
|
|
500
|
-
const notification = new Notification(unit.text, { tag: unit.id })
|
|
516
|
+
const notification = new Notification(unit.text, { tag: unit.id, body: unit.summary ?? '' })
|
|
517
|
+
notification.onclick = () => {
|
|
518
|
+
try {
|
|
519
|
+
if (typeof window.focus === 'function') window.focus()
|
|
520
|
+
if (unit.session) {
|
|
521
|
+
const row = findSessionRow(unit.session)
|
|
522
|
+
if (row !== null && typeof row.click === 'function') row.click()
|
|
523
|
+
}
|
|
524
|
+
} catch { /* 直达失败不掩盖通知主流程 */ }
|
|
525
|
+
notification.close()
|
|
526
|
+
}
|
|
501
527
|
if (typeof onOutcome === 'function') {
|
|
502
528
|
notification.onshow = () => { onOutcome(true) }
|
|
503
529
|
notification.onerror = () => { onOutcome(false) }
|
|
@@ -549,6 +575,7 @@ window.__ModuleLoader__.load({
|
|
|
549
575
|
}
|
|
550
576
|
const units = payload.units || []
|
|
551
577
|
const liveIds = new Set(units.map((unit) => unit.id))
|
|
578
|
+
pruneReminders(liveIds)
|
|
552
579
|
// 投影中已过期的本地残留清理,防旧锁与完成标记滞留;
|
|
553
580
|
// 访问失败即标记 broken 并整段跳过,防抛出被外层吞掉、发声链路失效
|
|
554
581
|
try {
|
|
@@ -561,35 +588,43 @@ window.__ModuleLoader__.load({
|
|
|
561
588
|
} catch {
|
|
562
589
|
storageState.broken = true
|
|
563
590
|
}
|
|
591
|
+
// 聚焦静默双条件:可见且有焦点才静默(竞品对齐)。hasFocus 单独成立而页面
|
|
592
|
+
// hidden 的边界(多屏/预渲染)不静默
|
|
593
|
+
const engaged = isEngaged()
|
|
564
594
|
for (const unit of units) {
|
|
565
595
|
// 冷启动对齐:错过窗口期的存量通知不回放轰炸;审批与提问例外——
|
|
566
596
|
// 它们在等用户动作,静默会把待办吞掉
|
|
567
597
|
if (coldStart && unit.category !== 'approval' && unit.category !== 'ask') {
|
|
568
598
|
try { markDone(unit.id) } catch { /* 存储不可用:标记缺失由内存去重兜底,冷启动本就不发声 */ }
|
|
599
|
+
// 静默对齐只不呈现通知,未读会话闪烁照常:高亮是本机 UI 状态,不属轰炸
|
|
600
|
+
rememberSessionHighlight(unit, engaged)
|
|
569
601
|
continue
|
|
570
602
|
}
|
|
571
603
|
if (!claimEvent(unit.id)) continue
|
|
572
604
|
markDone(unit.id)
|
|
573
|
-
const channels = chooseChannels(
|
|
605
|
+
const channels = chooseChannels(engaged, notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category, unit.routes ?? null)
|
|
574
606
|
const sound = resolveSound(unit.category, effectiveMapping(), uploadedIds)
|
|
575
607
|
if (channels.toast || channels.sound || channels.system || channels.blink) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
608
|
+
rememberSessionHighlight(unit, engaged)
|
|
609
|
+
}
|
|
610
|
+
if (channels.toast) {
|
|
611
|
+
toast?.(unit.text + (unit.summary ? '\n' + unit.summary : ''), {
|
|
612
|
+
holdMs: TOAST_MS,
|
|
613
|
+
onClick: activateSessionFromUnit(unit),
|
|
614
|
+
})
|
|
581
615
|
}
|
|
582
|
-
if (channels.toast) toast?.(unit.text, { holdMs: TOAST_MS })
|
|
583
616
|
// 页内提示音与通知声音互斥(pageSound 已含 !sound),同一通知至多一声;
|
|
584
617
|
// toast 库缺失时卡片不存在,补位音随之禁用(声明与可用分离,可用性在调用点合流);
|
|
585
618
|
// 页内音色按页内场景映射解析:通知生效映射为底,页内显式配置覆盖
|
|
586
619
|
if (channels.pageSound && toast) {
|
|
587
620
|
playSound(resolveSound(unit.category, mergeMapping(effectiveMapping(), readPageMapping()), uploadedIds)).catch(() => {})
|
|
588
621
|
}
|
|
589
|
-
|
|
590
|
-
playSound(sound).catch(() => {})
|
|
622
|
+
// 声音关闭只静音自身:system 弹窗与 blink 降级通道独立判断,不因无声 continue 连坐
|
|
623
|
+
if (channels.sound) playSound(sound).catch(() => {})
|
|
591
624
|
if (channels.system) notifySystem(unit)
|
|
592
625
|
else if (channels.blink && localGet(KEY_DEGRADE_HINT) !== '0') startTitleBlink()
|
|
626
|
+
// 等待用户动作的事件超时未处理:补发一轮提醒(审批与提问)
|
|
627
|
+
scheduleActionReminder(unit, channels)
|
|
593
628
|
}
|
|
594
629
|
applySessionHighlights()
|
|
595
630
|
return true
|
|
@@ -736,12 +771,38 @@ window.__ModuleLoader__.load({
|
|
|
736
771
|
return text.indexOf('进行中') >= 0 || text.toLowerCase().indexOf('running') >= 0
|
|
737
772
|
}
|
|
738
773
|
|
|
774
|
+
// 聚焦静默双条件:可见且有焦点才静默(竞品对齐)。hasFocus 单独成立而页面
|
|
775
|
+
// hidden 的边界(多屏/预渲染)不静默
|
|
776
|
+
const isEngaged = () => document.hasFocus() && document.hidden === false
|
|
777
|
+
|
|
778
|
+
// 高亮挂载单一入口:正常送达与冷启动静默对齐共用;
|
|
779
|
+
// 门控差异:正常送达仅在通知实际投递(任一呈现通道放行)时挂,冷启动静默对齐
|
|
780
|
+
// 无条件挂——高亮是未读 UI 状态,不属通知轰炸,错过窗口的存量照样强调;
|
|
781
|
+
// 开关关闭不积累条目;聚焦时正在查看的会话不挂——与 dsh 原版一致,正在看即已读,
|
|
782
|
+
// 失焦期间照常挂,返回页面时由可见性同步清除(回来即已读);
|
|
783
|
+
// 投影字段名为 session(buildUnit 输出形态,webhook 结构化字段同名)
|
|
784
|
+
function rememberSessionHighlight(unit, engaged) {
|
|
785
|
+
if (!sessionHighlightEnabled || !unit.session) return
|
|
786
|
+
if (engaged && isCurrentSessionTitle(unit.session)) return
|
|
787
|
+
if (sessionHighlights.size >= SESSION_HL_MAX) sessionHighlights.delete(sessionHighlights.keys().next().value)
|
|
788
|
+
sessionHighlights.set(unit.session, unit.category)
|
|
789
|
+
}
|
|
790
|
+
|
|
739
791
|
// 增量重应用:仅补缺失类,不产生多余 DOM 写,防 MutationObserver 回调自我触发成环;
|
|
740
792
|
// 快照遍历:行进入运行状态时清理对应条目,迭代中删除不改快照
|
|
741
793
|
function applySessionHighlights() {
|
|
742
794
|
if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
|
|
743
795
|
if (!sessionHighlightEnabled) return
|
|
796
|
+
const engaged = isEngaged()
|
|
744
797
|
for (const [title, category] of [...sessionHighlights]) {
|
|
798
|
+
// 聚焦态复核:冷启动首拉早于标题就绪时误挂的当前会话高亮,标题就绪后的
|
|
799
|
+
// 任一应用帧在此自愈(与返回即已读同语义:正在看即不强调)
|
|
800
|
+
if (engaged && isCurrentSessionTitle(title)) {
|
|
801
|
+
sessionHighlights.delete(title)
|
|
802
|
+
const stale = findSessionRow(title)
|
|
803
|
+
if (stale) removeRowHighlight(stale)
|
|
804
|
+
continue
|
|
805
|
+
}
|
|
745
806
|
const row = findSessionRow(title)
|
|
746
807
|
if (!row) continue
|
|
747
808
|
if (isRowRunning(row)) {
|
|
@@ -786,6 +847,56 @@ window.__ModuleLoader__.load({
|
|
|
786
847
|
if (typeof row.className !== 'string') return
|
|
787
848
|
row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
|
|
788
849
|
}
|
|
850
|
+
|
|
851
|
+
// ---- 点击直达会话:页内卡片与系统弹窗共用 ----
|
|
852
|
+
|
|
853
|
+
// 按通知单元激活会话:聚焦窗口并模拟点击侧边栏会话行(官方行点击即切换会话);
|
|
854
|
+
// 点击事件经高亮清除委托,天然完成"查看即已读"。行未渲染(列表折叠/懒加载)
|
|
855
|
+
// 或无标题时仅聚焦窗口。
|
|
856
|
+
function activateSessionFromUnit(unit) {
|
|
857
|
+
return () => {
|
|
858
|
+
if (typeof window.focus === 'function') window.focus()
|
|
859
|
+
if (!unit.session) return
|
|
860
|
+
const row = findSessionRow(unit.session)
|
|
861
|
+
if (row !== null && typeof row.click === 'function') row.click()
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ---- 审批/提问超时二次提醒 ----
|
|
866
|
+
|
|
867
|
+
// 等待用户动作的事件超此时长未处理即补发一轮(一次,不连环)。
|
|
868
|
+
const ACTION_REMIND_MS = 10 * 60 * 1000
|
|
869
|
+
// 待提醒登记:unit id → timer;处理判定与生命周期清理共用
|
|
870
|
+
const pendingReminders = new Map()
|
|
871
|
+
// "已处理"近似:用户切到该会话(标题匹配)或点掉了高亮行(高亮条目消失);
|
|
872
|
+
// 无标题通知(审批 waterfall)无法感知处理,按未处理补发,宁可多响一次。
|
|
873
|
+
function actionHandled(unit) {
|
|
874
|
+
if (!unit.session) return false
|
|
875
|
+
if (isCurrentSessionTitle(unit.session)) return true
|
|
876
|
+
return !sessionHighlights.has(unit.session)
|
|
877
|
+
}
|
|
878
|
+
function scheduleActionReminder(unit, channels) {
|
|
879
|
+
if (unit.category !== 'approval' && unit.category !== 'ask') return
|
|
880
|
+
if (pendingReminders.has(unit.id)) return
|
|
881
|
+
const timer = setTimeout(() => {
|
|
882
|
+
pendingReminders.delete(unit.id)
|
|
883
|
+
if (actionHandled(unit)) return
|
|
884
|
+
// 补发独立于聚焦静默与路由名单:它就是"通道没送达"的兜底
|
|
885
|
+
toast?.('[再次提醒] ' + unit.text + (unit.summary ? '\n' + unit.summary : ''), { holdMs: TOAST_MS, onClick: activateSessionFromUnit(unit) })
|
|
886
|
+
playSound(resolveSound(unit.category, effectiveMapping(), uploadedIds)).catch(() => {})
|
|
887
|
+
startTitleBlink()
|
|
888
|
+
}, ACTION_REMIND_MS)
|
|
889
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
890
|
+
pendingReminders.set(unit.id, timer)
|
|
891
|
+
}
|
|
892
|
+
// 投影刷新同步:单元过期即撤登记(通知已被其他窗口处理或已出投影窗口)
|
|
893
|
+
function pruneReminders(liveIds) {
|
|
894
|
+
for (const [id, timer] of pendingReminders) {
|
|
895
|
+
if (liveIds.has(id)) continue
|
|
896
|
+
clearTimeout(timer)
|
|
897
|
+
pendingReminders.delete(id)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
789
900
|
// 页面重新可见即视为已读:清除当前会话的高亮,其他会话的提醒保留
|
|
790
901
|
function clearCurrentSessionHighlights() {
|
|
791
902
|
for (const title of [...sessionHighlights.keys()]) {
|
|
@@ -827,8 +938,12 @@ window.__ModuleLoader__.load({
|
|
|
827
938
|
const onVisible = () => {
|
|
828
939
|
if (document.hidden) return
|
|
829
940
|
clearCurrentSessionHighlights()
|
|
941
|
+
stopTitleBlink()
|
|
942
|
+
}
|
|
943
|
+
const onFocus = () => {
|
|
944
|
+
clearCurrentSessionHighlights()
|
|
945
|
+
stopTitleBlink()
|
|
830
946
|
}
|
|
831
|
-
const onFocus = () => clearCurrentSessionHighlights()
|
|
832
947
|
const dispose = () => {
|
|
833
948
|
if (typeof window.removeEventListener === 'function') window.removeEventListener('focus', onFocus)
|
|
834
949
|
document.removeEventListener('visibilitychange', onVisible)
|
|
@@ -1860,7 +1975,7 @@ window.__ModuleLoader__.load({
|
|
|
1860
1975
|
},
|
|
1861
1976
|
// 测试钩子:供全链路集成测试注入 stub 后取内部函数,生产无消费方;
|
|
1862
1977
|
// 页内通知的展示结果经 require 桩捕获,不在本包断言
|
|
1863
|
-
__test: { poll, pollOnce, storageState, announcedIds, submitCategoryToggle, start },
|
|
1978
|
+
__test: { poll, pollOnce, storageState, announcedIds, submitCategoryToggle, start, pruneReminders },
|
|
1864
1979
|
}
|
|
1865
1980
|
},
|
|
1866
1981
|
})
|
package/src/core.mjs
CHANGED
|
@@ -136,7 +136,7 @@ export function shouldNotify({ category, kind, durationMs, settings, header, wak
|
|
|
136
136
|
return true
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
export function buildUnit({ id, category, status, sessionTitle, workspace, durationMs, ts }) {
|
|
139
|
+
export function buildUnit({ id, category, status, sessionTitle, workspace, durationMs, ts, tokens, summary }) {
|
|
140
140
|
const label = CATEGORY_LABELS[category] || category
|
|
141
141
|
return {
|
|
142
142
|
id,
|
|
@@ -145,21 +145,76 @@ export function buildUnit({ id, category, status, sessionTitle, workspace, durat
|
|
|
145
145
|
session: sessionTitle,
|
|
146
146
|
workspace,
|
|
147
147
|
durationMs,
|
|
148
|
+
tokens: typeof tokens === 'number' && tokens > 0 ? tokens : null,
|
|
149
|
+
summary: typeof summary === 'string' && summary.length > 0 ? summary : null,
|
|
148
150
|
ts,
|
|
149
151
|
text: '[dsh] ' + label + (sessionTitle ? ': ' + sessionTitle : ''),
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
//
|
|
155
|
+
// 通知一句话小结:耗时 + tokens + 回答前缀,均缺省返回 null(各通道按需拼装)。
|
|
156
|
+
// 前缀为最终回答的首个文本块,host 侧已按码点截断,此处原样拼装。
|
|
157
|
+
export function buildSummary({ durationMs, tokens, prefix }) {
|
|
158
|
+
const parts = []
|
|
159
|
+
if (typeof durationMs === 'number' && durationMs >= 0) {
|
|
160
|
+
parts.push('耗时 ' + (durationMs >= 1000 ? Math.round(durationMs / 1000) + ' 秒' : Math.round(durationMs) + ' 毫秒'))
|
|
161
|
+
}
|
|
162
|
+
if (typeof tokens === 'number' && tokens > 0) parts.push(formatTokenCount(tokens) + ' tokens')
|
|
163
|
+
if (typeof prefix === 'string' && prefix.trim().length > 0) parts.push(prefix.trim())
|
|
164
|
+
return parts.length > 0 ? parts.join(' · ') : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// tokens 紧凑格式:千以下原样,以上 k 计一位小数
|
|
168
|
+
export function formatTokenCount(tokens) {
|
|
169
|
+
if (tokens < 1000) return String(tokens)
|
|
170
|
+
const k = tokens / 1000
|
|
171
|
+
return (k >= 100 ? Math.round(k) : Math.round(k * 10) / 10) + 'k'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 回答前缀截断:按码点截断防切断代理对,与 sessionTitle 同策略
|
|
175
|
+
export const ANSWER_PREFIX_MAX_CHARS = 80
|
|
176
|
+
|
|
177
|
+
// assistant/message 事件 → 会话尾部统计累积:tokens 跨 step 累加(一回合多步),
|
|
178
|
+
// 回答前缀取本回合最后一条含文本的消息(后到覆盖);usage 缺失不减不崩。
|
|
179
|
+
export function collectAssistantTail(tail, sessionId, data) {
|
|
180
|
+
const message = data && data.message
|
|
181
|
+
const blocks = message !== null && typeof message === 'object' && Array.isArray(message.content) ? message.content : []
|
|
182
|
+
let prefix = null
|
|
183
|
+
for (const block of blocks) {
|
|
184
|
+
if (block && typeof block.text === 'string' && block.text.trim().length > 0) {
|
|
185
|
+
const text = block.text.trim()
|
|
186
|
+
prefix = text.length <= ANSWER_PREFIX_MAX_CHARS
|
|
187
|
+
? text
|
|
188
|
+
: Array.from(text.slice(0, ANSWER_PREFIX_MAX_CHARS + 1)).slice(0, ANSWER_PREFIX_MAX_CHARS).join('')
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const usage = data && typeof data.usage === 'object' && data.usage !== null ? data.usage : null
|
|
193
|
+
let tokens = 0
|
|
194
|
+
if (usage !== null) {
|
|
195
|
+
tokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
196
|
+
}
|
|
197
|
+
const prev = tail.get(sessionId)
|
|
198
|
+
const next = {
|
|
199
|
+
tokens: (prev?.tokens ?? 0) + tokens,
|
|
200
|
+
prefix: prefix !== null ? prefix : (prev?.prefix ?? null),
|
|
201
|
+
}
|
|
202
|
+
tail.set(sessionId, next)
|
|
203
|
+
return next
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 投影单元字段到 webhook 结构化字段的一比一映射(text/summary 随行)。
|
|
154
207
|
export function buildWebhookPayload(unit) {
|
|
155
208
|
return {
|
|
156
209
|
text: unit.text,
|
|
210
|
+
summary: unit.summary,
|
|
157
211
|
event: unit.id,
|
|
158
212
|
category: unit.category,
|
|
159
213
|
status: unit.status,
|
|
160
214
|
session: unit.session,
|
|
161
215
|
workspace: unit.workspace,
|
|
162
216
|
durationMs: unit.durationMs,
|
|
217
|
+
tokens: unit.tokens,
|
|
163
218
|
ts: unit.ts,
|
|
164
219
|
}
|
|
165
220
|
}
|
|
@@ -227,6 +282,10 @@ export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOC
|
|
|
227
282
|
// 用户行动空闲满此时长视为离开:聚焦静默不再适用,通知全通道齐发。
|
|
228
283
|
export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
|
|
229
284
|
|
|
285
|
+
// 呈现通道名单:事件→通道路由(kindRoutes)的合法取值;host 直发通道
|
|
286
|
+
// (webhook/im)由 host 侧过滤,投影单元携带 routes 供浏览器侧过滤呈现通道。
|
|
287
|
+
export const CHANNELS = ['sound', 'system', 'toast', 'blink', 'webhook', 'im']
|
|
288
|
+
|
|
230
289
|
// 发声通道判定:页内提示、提示音与系统弹窗各自独立开关,聚焦静默仅压声音与系统弹窗;
|
|
231
290
|
// 提示音另受分类配置约束:soundCategories 中该分类显式 false 即静音,缺省键与空分类放行。
|
|
232
291
|
// 系统弹窗须授权,想弹而未授权时降级标题闪烁(调用方再按降级提示开关呈现)。
|
|
@@ -234,18 +293,21 @@ export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
|
|
|
234
293
|
// pageSound 为页内提示音:页内提示弹出且通知声音未播时补一声(聚焦场景的听觉提醒),
|
|
235
294
|
// 不受聚焦静默压制,受页内总开关、页内分类配置(pageSoundCategories,独立于提示音分类)
|
|
236
295
|
// 与通知声音互斥约束(同一通知至多一声)。
|
|
237
|
-
|
|
296
|
+
// routes 为事件→通道路由放行名单(null=未配置全放行):单元级 AND 叠加在本机偏好之上,
|
|
297
|
+
// 名单外的呈现通道一律不出。
|
|
298
|
+
export function chooseChannels({ hasFocus, permission, focusQuiet = true, toastEnabled = true, soundEnabled = true, soundCategories = null, category = null, systemEnabled = true, idleMs = null, idleThresholdMs = USER_IDLE_AWAY_MS, pageSoundEnabled = false, pageSoundCategories = null, routes = null }) {
|
|
238
299
|
const idleAway = typeof idleMs === 'number' && idleMs >= idleThresholdMs
|
|
239
300
|
const quiet = hasFocus && focusQuiet && !idleAway
|
|
240
301
|
const categoryMuted = soundCategories != null && category != null && soundCategories[category] === false
|
|
241
302
|
const pageCategoryMuted = pageSoundCategories != null && category != null && pageSoundCategories[category] === false
|
|
242
|
-
const
|
|
303
|
+
const routed = (channel) => routes === null || routes.indexOf(channel) >= 0
|
|
304
|
+
const sound = !quiet && soundEnabled && !categoryMuted && routed('sound')
|
|
243
305
|
return {
|
|
244
|
-
toast: toastEnabled,
|
|
306
|
+
toast: toastEnabled && routed('toast'),
|
|
245
307
|
sound,
|
|
246
|
-
system: !quiet && systemEnabled && permission === 'granted',
|
|
247
|
-
blink: !quiet && systemEnabled && permission !== 'granted',
|
|
248
|
-
pageSound: pageSoundEnabled && toastEnabled && !pageCategoryMuted && !sound,
|
|
308
|
+
system: !quiet && systemEnabled && permission === 'granted' && routed('system'),
|
|
309
|
+
blink: !quiet && systemEnabled && permission !== 'granted' && routed('blink'),
|
|
310
|
+
pageSound: pageSoundEnabled && toastEnabled && !pageCategoryMuted && !sound && routed('toast'),
|
|
249
311
|
}
|
|
250
312
|
}
|
|
251
313
|
|
|
@@ -475,10 +537,11 @@ export function imBoundBotIds(list) {
|
|
|
475
537
|
}
|
|
476
538
|
|
|
477
539
|
// 配置补丁校验:顶层键白名单,webhookUrl 空串(禁用)或 http(s) URL,
|
|
478
|
-
// 时长须非负整数,开关须布尔,enabled
|
|
540
|
+
// 时长须非负整数,开关须布尔,enabled 分类须已知,kindRoutes 条目须合法通道名单。
|
|
541
|
+
// 返回归一化后的补丁。
|
|
479
542
|
export function validateConfigPatch(patch) {
|
|
480
543
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
|
|
481
|
-
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets']
|
|
544
|
+
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes']
|
|
482
545
|
for (const key of Object.keys(patch)) {
|
|
483
546
|
if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
|
|
484
547
|
}
|
|
@@ -540,9 +603,40 @@ export function validateConfigPatch(patch) {
|
|
|
540
603
|
}
|
|
541
604
|
next.enabled = { ...enabled }
|
|
542
605
|
}
|
|
606
|
+
if ('kindRoutes' in patch) {
|
|
607
|
+
next.kindRoutes = normalizeKindRoutes(patch.kindRoutes).routes
|
|
608
|
+
}
|
|
543
609
|
return { ok: true, patch: next }
|
|
544
610
|
}
|
|
545
611
|
|
|
612
|
+
// kindRoutes 读侧归一化:分类 → 放行通道名单。非对象回空(全通道);未知分类的
|
|
613
|
+
// 条目剔除、名单内未知通道剔除,余项去重保序(与 normalizeImTargets 同宽松读侧:
|
|
614
|
+
// 剔除非法而非整体拒绝);全空名单等价全禁(合法,用户显式配置),与缺省键(全放行)区分。
|
|
615
|
+
// 写侧校验(validateConfigPatch)对未知分类/通道拒绝,两侧严格度分工不同。
|
|
616
|
+
export function normalizeKindRoutes(raw) {
|
|
617
|
+
if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return { ok: true, routes: {} }
|
|
618
|
+
const routes = {}
|
|
619
|
+
for (const key of Object.keys(raw)) {
|
|
620
|
+
if (CATEGORIES.indexOf(key) < 0) continue
|
|
621
|
+
const list = raw[key]
|
|
622
|
+
if (!Array.isArray(list)) continue
|
|
623
|
+
const seen = new Set()
|
|
624
|
+
for (const channel of list) {
|
|
625
|
+
if (typeof channel !== 'string' || CHANNELS.indexOf(channel) < 0) continue
|
|
626
|
+
if (!seen.has(channel)) seen.add(channel)
|
|
627
|
+
}
|
|
628
|
+
routes[key] = [...seen]
|
|
629
|
+
}
|
|
630
|
+
return { ok: true, routes }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 投影单元放行名单读取:kindRoutes 未配置该分类即全放行(null),配置即名单。
|
|
634
|
+
export function unitRoutes(kindRoutes, category) {
|
|
635
|
+
if (kindRoutes === null || typeof kindRoutes !== 'object') return null
|
|
636
|
+
const list = kindRoutes[category]
|
|
637
|
+
return Array.isArray(list) ? list : null
|
|
638
|
+
}
|
|
639
|
+
|
|
546
640
|
// 面板读取的解析形态:enabled 缺省键按开补全,字段类型异常回退默认值;
|
|
547
641
|
// 时长统一取整到非负整数,与写路径校验宽松度一致(yaml 手写小数不产生读存差)。
|
|
548
642
|
// soundMapping 仅保留非空字符串值:settings.update 深合并无法物理删除嵌套键,
|
|
@@ -557,6 +651,7 @@ export function resolvedConfig(settings) {
|
|
|
557
651
|
if (typeof value === 'string' && value.length > 0) soundMapping[key] = value
|
|
558
652
|
}
|
|
559
653
|
const duration = Number.isFinite(source.minTurnDurationMs) ? Math.max(0, Math.floor(source.minTurnDurationMs)) : MIN_TURN_DURATION_MS
|
|
654
|
+
const routesVerdict = normalizeKindRoutes(source.kindRoutes)
|
|
560
655
|
return {
|
|
561
656
|
webhookUrl: typeof source.webhookUrl === 'string' ? source.webhookUrl : '',
|
|
562
657
|
minTurnDurationMs: duration,
|
|
@@ -565,6 +660,7 @@ export function resolvedConfig(settings) {
|
|
|
565
660
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
|
|
566
661
|
soundMapping,
|
|
567
662
|
imTargets: normalizeImTargets(source.imTargets),
|
|
663
|
+
kindRoutes: routesVerdict.ok ? routesVerdict.routes : {},
|
|
568
664
|
}
|
|
569
665
|
}
|
|
570
666
|
|
|
@@ -578,6 +674,7 @@ export function publicConfig(settings) {
|
|
|
578
674
|
enabled: resolved.enabled,
|
|
579
675
|
soundMapping: resolved.soundMapping,
|
|
580
676
|
imTargets: resolved.imTargets,
|
|
677
|
+
kindRoutes: resolved.kindRoutes,
|
|
581
678
|
webhookConfigured: resolved.webhookUrl.trim().length > 0,
|
|
582
679
|
}
|
|
583
680
|
}
|
package/src/index.js
CHANGED
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
MIN_TURN_DURATION_MS,
|
|
16
16
|
OPEN_TURN_STALE_MS,
|
|
17
17
|
SUBAGENT_WAKE_WINDOW_MS,
|
|
18
|
+
buildSummary,
|
|
18
19
|
buildUnit,
|
|
19
20
|
buildWebhookPayload,
|
|
21
|
+
collectAssistantTail,
|
|
20
22
|
collectSessionEvents,
|
|
21
23
|
createApprovalTap,
|
|
22
24
|
createProjection,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
sendWebhook,
|
|
33
35
|
shouldNotify,
|
|
34
36
|
storedSessionTitle,
|
|
37
|
+
unitRoutes,
|
|
35
38
|
uploadExt,
|
|
36
39
|
UPLOAD_FILE_MAX_BYTES,
|
|
37
40
|
validateConfigPatch,
|
|
@@ -81,6 +84,7 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
81
84
|
enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
|
|
82
85
|
soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
|
|
83
86
|
imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
|
|
87
|
+
kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im),未配置的分类全通道放行'),
|
|
84
88
|
})
|
|
85
89
|
|
|
86
90
|
// 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
|
|
@@ -188,6 +192,8 @@ export function apply(ctx) {
|
|
|
188
192
|
// 事件读序:标题提取需要事件流,按会话累积 user/message 文本;标题封账后不再累积
|
|
189
193
|
const sessionEvents = new Map()
|
|
190
194
|
const titledSessions = new Set()
|
|
195
|
+
// 回合尾部统计:assistant/message 的 tokens 累加与回答前缀,回合结束随通知消费即清
|
|
196
|
+
const assistantTails = new Map()
|
|
191
197
|
let seq = 0
|
|
192
198
|
// 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
|
|
193
199
|
let soundWriteQueue = Promise.resolve()
|
|
@@ -220,7 +226,16 @@ export function apply(ctx) {
|
|
|
220
226
|
const header = session.header || {}
|
|
221
227
|
const sessionId = String(session.id ?? '')
|
|
222
228
|
if (!shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren })) return
|
|
229
|
+
// 回合尾部统计:tokens 与回答前缀随回合结束消费,统计缺失不影响送达
|
|
230
|
+
const tail = assistantTails.get(sessionId) ?? null
|
|
231
|
+
assistantTails.delete(sessionId)
|
|
223
232
|
seq += 1
|
|
233
|
+
const summary = buildSummary({
|
|
234
|
+
durationMs,
|
|
235
|
+
tokens: tail === null ? null : tail.tokens,
|
|
236
|
+
prefix: tail === null ? null : tail.prefix,
|
|
237
|
+
})
|
|
238
|
+
const routes = unitRoutes(settings.kindRoutes, category)
|
|
224
239
|
const unit = buildUnit({
|
|
225
240
|
id: 'n-' + Date.now().toString(36) + '-' + String(seq) + '-' + String(category),
|
|
226
241
|
category,
|
|
@@ -228,11 +243,16 @@ export function apply(ctx) {
|
|
|
228
243
|
sessionTitle: sessionDisplayTitle(session, sessionId),
|
|
229
244
|
workspace: typeof header.cwd === 'string' ? header.cwd : '',
|
|
230
245
|
durationMs,
|
|
246
|
+
tokens: tail === null ? null : tail.tokens,
|
|
247
|
+
summary,
|
|
231
248
|
ts: Date.now(),
|
|
232
249
|
})
|
|
250
|
+
unit.routes = routes
|
|
233
251
|
projection.push(unit)
|
|
234
|
-
|
|
235
|
-
|
|
252
|
+
if (routes === null || routes.indexOf('webhook') >= 0) {
|
|
253
|
+
void sendWebhook({ url: settings.webhookUrl, payload: buildWebhookPayload(unit) })
|
|
254
|
+
}
|
|
255
|
+
if (routes === null || routes.indexOf('im') >= 0) deliverIm(unit, settings)
|
|
236
256
|
}
|
|
237
257
|
|
|
238
258
|
// 会话显示标题:优先 session-title 服务(与侧边栏行标题同一投影,客户端文本匹配高亮
|
|
@@ -270,7 +290,10 @@ export function apply(ctx) {
|
|
|
270
290
|
const currentTurnStart = openTurns.get(sessionId)
|
|
271
291
|
pruneTimestamps(childDoneAt, now, SUBAGENT_WAKE_WINDOW_MS)
|
|
272
292
|
for (const [turnId, turn] of openTurns) {
|
|
273
|
-
if (now - turn.at > OPEN_TURN_STALE_MS)
|
|
293
|
+
if (now - turn.at > OPEN_TURN_STALE_MS) {
|
|
294
|
+
openTurns.delete(turnId)
|
|
295
|
+
assistantTails.delete(turnId)
|
|
296
|
+
}
|
|
274
297
|
}
|
|
275
298
|
for (const [childId, entry] of childActive) {
|
|
276
299
|
if (now - entry.at > ACTIVE_CHILD_STALE_MS) childActive.delete(childId)
|
|
@@ -281,6 +304,10 @@ export function apply(ctx) {
|
|
|
281
304
|
collectSessionEvents(sessionEvents, titledSessions, sessionId, event)
|
|
282
305
|
return
|
|
283
306
|
}
|
|
307
|
+
if (event.type === 'assistant/message') {
|
|
308
|
+
collectAssistantTail(assistantTails, sessionId, event.data)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
284
311
|
if (event.type === 'turn/start') {
|
|
285
312
|
// 回合级标记重置:上一回合的委托与收尾残留不跨回合
|
|
286
313
|
turnDelegation.delete(sessionId)
|
package/test/core.test.mjs
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
SUBAGENT_WAKE_WINDOW_MS,
|
|
15
15
|
buildUnit,
|
|
16
16
|
buildWebhookPayload,
|
|
17
|
+
buildSummary,
|
|
18
|
+
collectAssistantTail,
|
|
19
|
+
normalizeKindRoutes,
|
|
20
|
+
unitRoutes,
|
|
17
21
|
createProjection,
|
|
18
22
|
decideClaim,
|
|
19
23
|
chooseChannels,
|
|
@@ -377,20 +381,83 @@ test('webhook payload 字段映射', () => {
|
|
|
377
381
|
const unit = buildUnit({
|
|
378
382
|
id: 'n1', category: CATEGORY_DONE, status: 'completed',
|
|
379
383
|
sessionTitle: '修复登录', workspace: '/repo', durationMs: 12 * 1000, ts: 1234,
|
|
384
|
+
tokens: 1500, summary: '耗时 12 秒 · 1.5k tokens · 已修复',
|
|
380
385
|
})
|
|
381
386
|
const payload = buildWebhookPayload(unit)
|
|
382
387
|
assert.deepEqual(payload, {
|
|
383
388
|
text: '[dsh] 任务完成: 修复登录',
|
|
389
|
+
summary: '耗时 12 秒 · 1.5k tokens · 已修复',
|
|
384
390
|
event: 'n1',
|
|
385
391
|
category: CATEGORY_DONE,
|
|
386
392
|
status: 'completed',
|
|
387
393
|
session: '修复登录',
|
|
388
394
|
workspace: '/repo',
|
|
389
395
|
durationMs: 12 * 1000,
|
|
396
|
+
tokens: 1500,
|
|
390
397
|
ts: 1234,
|
|
391
398
|
})
|
|
392
399
|
})
|
|
393
400
|
|
|
401
|
+
test('通知统计与降级小结:buildSummary 组装与空缺归 null', () => {
|
|
402
|
+
assert.equal(buildSummary({ durationMs: null, tokens: null, prefix: null }), null)
|
|
403
|
+
assert.equal(buildSummary({ durationMs: 5200, tokens: 0, prefix: '' }), '耗时 5 秒')
|
|
404
|
+
assert.equal(buildSummary({ durationMs: 800, tokens: null, prefix: null }), '耗时 800 毫秒')
|
|
405
|
+
assert.equal(buildSummary({ durationMs: null, tokens: 940, prefix: null }), '940 tokens')
|
|
406
|
+
assert.equal(buildSummary({ durationMs: null, tokens: 123456, prefix: null }), '123k tokens')
|
|
407
|
+
assert.equal(
|
|
408
|
+
buildSummary({ durationMs: 65 * 1000, tokens: 45, prefix: '已修复崩溃' }),
|
|
409
|
+
'耗时 65 秒 · 45 tokens · 已修复崩溃',
|
|
410
|
+
)
|
|
411
|
+
// 前缀纯空白按缺省处理
|
|
412
|
+
assert.equal(buildSummary({ durationMs: null, tokens: null, prefix: ' ' }), null)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('collectAssistantTail:tokens 累加,回答前缀后到覆盖,usage 缺失不减不崩', () => {
|
|
416
|
+
const tail = new Map()
|
|
417
|
+
const first = collectAssistantTail(tail, 's1', {
|
|
418
|
+
usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 0 },
|
|
419
|
+
message: { content: [{ type: 'text', text: ' 第一段回答 ' }] },
|
|
420
|
+
})
|
|
421
|
+
assert.deepEqual(first, { tokens: 160, prefix: '第一段回答' })
|
|
422
|
+
const second = collectAssistantTail(tail, 's1', {
|
|
423
|
+
usage: { inputTokens: 20, outputTokens: 30 },
|
|
424
|
+
message: { content: [{ type: 'text', text: '最终回答' }] },
|
|
425
|
+
})
|
|
426
|
+
assert.deepEqual(second, { tokens: 210, prefix: '最终回答' })
|
|
427
|
+
// 无 usage 无文本:tokens 保留,前缀保留
|
|
428
|
+
const third = collectAssistantTail(tail, 's1', { message: { content: [{ type: 'image' }] } })
|
|
429
|
+
assert.deepEqual(third, { tokens: 210, prefix: '最终回答' })
|
|
430
|
+
// 畸形数据不崩
|
|
431
|
+
const fourth = collectAssistantTail(tail, 's1', null)
|
|
432
|
+
assert.deepEqual(fourth, { tokens: 210, prefix: '最终回答' })
|
|
433
|
+
// 长前缀按码点截断
|
|
434
|
+
const long = '好'.repeat(120)
|
|
435
|
+
collectAssistantTail(new Map(), 's2', { message: { content: [{ type: 'text', text: long }] } })
|
|
436
|
+
const cut = collectAssistantTail(new Map(), 's2', { message: { content: [{ type: 'text', text: long }] } })
|
|
437
|
+
assert.equal(Array.from(cut.prefix).length, 80)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('kindRoutes 归一化:非法剔除合法保留,去重保序,写侧宽松读侧剔除', () => {
|
|
441
|
+
assert.deepEqual(normalizeKindRoutes(undefined), { ok: true, routes: {} })
|
|
442
|
+
assert.deepEqual(normalizeKindRoutes(null), { ok: true, routes: {} })
|
|
443
|
+
assert.deepEqual(normalizeKindRoutes('x'), { ok: true, routes: {} })
|
|
444
|
+
assert.deepEqual(normalizeKindRoutes({ bogus: ['sound'] }), { ok: true, routes: {} }, '未知分类条目剔除')
|
|
445
|
+
assert.deepEqual(normalizeKindRoutes({ completed: 'sound' }), { ok: true, routes: {} }, '形态非法条目剔除')
|
|
446
|
+
const merged = normalizeKindRoutes({
|
|
447
|
+
completed: ['webhook', 'sound', 'webhook', 'sms'],
|
|
448
|
+
error: [],
|
|
449
|
+
bogus: ['sound'],
|
|
450
|
+
})
|
|
451
|
+
assert.deepEqual(merged, { ok: true, routes: { completed: ['webhook', 'sound'], error: [] } }, '未知通道剔除,合法去重保序,空名单=全禁')
|
|
452
|
+
// 空数组 = 全禁(显式配置),与缺省键(全放行)可区分
|
|
453
|
+
assert.deepEqual(unitRoutes({ completed: [] }, 'completed'), [])
|
|
454
|
+
assert.deepEqual(unitRoutes({ completed: ['webhook'] }, 'error'), null)
|
|
455
|
+
assert.deepEqual(unitRoutes(null, 'completed'), null)
|
|
456
|
+
// 写侧经 validateConfigPatch 归一后写入
|
|
457
|
+
const verdict = validateConfigPatch({ kindRoutes: { completed: ['webhook'] } })
|
|
458
|
+
assert.deepEqual(verdict, { ok: true, patch: { kindRoutes: { completed: ['webhook'] } } })
|
|
459
|
+
})
|
|
460
|
+
|
|
394
461
|
test('音效映射:自定义命中用自定义,内置备选与失效回落', () => {
|
|
395
462
|
const mapping = { [CATEGORY_ERROR]: 'snd-9', [CATEGORY_DONE]: TONE_BELL, [CATEGORY_ASK]: 'gone' }
|
|
396
463
|
assert.deepEqual(resolveSound({ category: CATEGORY_ERROR, mapping, uploadedIds: ['snd-9'] }), { kind: 'custom', id: 'snd-9' })
|
|
@@ -586,7 +653,7 @@ test('配置补丁校验:非法输入逐类拒绝', () => {
|
|
|
586
653
|
|
|
587
654
|
test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () => {
|
|
588
655
|
assert.deepEqual(
|
|
589
|
-
resolvedConfig({ webhookUrl: 'https://hook.example', enabled: { completed: false }, soundMapping: { completed: 'snd-1' } }),
|
|
656
|
+
resolvedConfig({ webhookUrl: 'https://hook.example', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { completed: ['webhook'], bogus: ['sound'] } }),
|
|
590
657
|
{
|
|
591
658
|
webhookUrl: 'https://hook.example',
|
|
592
659
|
minTurnDurationMs: MIN_TURN_MS,
|
|
@@ -595,6 +662,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
595
662
|
enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
|
|
596
663
|
soundMapping: { completed: 'snd-1' },
|
|
597
664
|
imTargets: [],
|
|
665
|
+
kindRoutes: { completed: ['webhook'] },
|
|
598
666
|
},
|
|
599
667
|
)
|
|
600
668
|
assert.deepEqual(resolvedConfig({}), {
|
|
@@ -605,6 +673,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
605
673
|
enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
|
|
606
674
|
soundMapping: {},
|
|
607
675
|
imTargets: [],
|
|
676
|
+
kindRoutes: {},
|
|
608
677
|
})
|
|
609
678
|
assert.equal(resolvedConfig({ minTurnDurationMs: Number.NaN }).minTurnDurationMs, MIN_TURN_MS)
|
|
610
679
|
assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
|
|
@@ -612,7 +681,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
612
681
|
|
|
613
682
|
test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
|
|
614
683
|
assert.deepEqual(
|
|
615
|
-
publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' } }),
|
|
684
|
+
publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { ask: ['im'] } }),
|
|
616
685
|
{
|
|
617
686
|
minTurnDurationMs: MIN_TURN_MS,
|
|
618
687
|
rootsOnly: true,
|
|
@@ -620,6 +689,7 @@ test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
|
|
|
620
689
|
enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
|
|
621
690
|
soundMapping: { completed: 'snd-1' },
|
|
622
691
|
imTargets: [],
|
|
692
|
+
kindRoutes: { ask: ['im'] },
|
|
623
693
|
webhookConfigured: true,
|
|
624
694
|
},
|
|
625
695
|
)
|
package/test/flow.test.mjs
CHANGED
|
@@ -26,8 +26,15 @@ class BrokenStorage {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// 以注入的 stub 加载真实 src/client.js,返回捕获的模块对象、页内通知捕获表与 window 桩。
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// shortTimers:闪烁常量替换为毫秒级,配合真实定时器验证硬顶与重入(node:test mock.timers
|
|
30
|
+
// 拦截不到 new Function 沙箱里的 timer,不能用于本 loader)
|
|
31
|
+
function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOverride, shortTimers }) {
|
|
32
|
+
const raw = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
33
|
+
const source = shortTimers
|
|
34
|
+
? raw.replace('const TOAST_MS = 6 * 1000', 'const TOAST_MS = 60')
|
|
35
|
+
.replace('const BLINK_MS = 1 * 1000', 'const BLINK_MS = 20')
|
|
36
|
+
.replace('const BLINK_MAX_MS = 30 * 1000', 'const BLINK_MAX_MS = 200')
|
|
37
|
+
: raw
|
|
31
38
|
const modules = []
|
|
32
39
|
const shown = []
|
|
33
40
|
const windowListeners = {}
|
|
@@ -40,8 +47,9 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
|
|
|
40
47
|
}
|
|
41
48
|
const documentStub = {
|
|
42
49
|
hasFocus: () => true,
|
|
50
|
+
// 默认桩:聚焦窗口恒可见(浏览器不变式);聚焦静默判定为可见+焦点双条件
|
|
51
|
+
hidden: false,
|
|
43
52
|
title: 'dsh',
|
|
44
|
-
hidden: true,
|
|
45
53
|
createElement: () => ({ style: {}, remove() {} }),
|
|
46
54
|
body: { appendChild: () => {} },
|
|
47
55
|
head: { appendChild: () => {} },
|
|
@@ -85,6 +93,74 @@ const units = [
|
|
|
85
93
|
{ id: 'u2', category: 'error', text: '[dsh] 任务出错: t2' },
|
|
86
94
|
]
|
|
87
95
|
|
|
96
|
+
// 标题闪烁:错误类通知在未授权系统通知时走 blink 降级通道,焦点回返与硬顶都必须复位标题
|
|
97
|
+
test('Given 声音总开关关闭 When 错误通知到达 Then 降级闪烁照常触发', async () => {
|
|
98
|
+
// 声音关闭只应静音,不得连坐 system 弹窗与 blink 降级通道
|
|
99
|
+
const dom = { hasFocus: () => false, title: 'dsh', hidden: true }
|
|
100
|
+
const { mod, document: doc } = loadClient({
|
|
101
|
+
storage: new FakeStorage({ 'turn-notify:sound': '0' }),
|
|
102
|
+
payload: { units: [units[1]], soundMapping: {}, version: 1 },
|
|
103
|
+
document: dom,
|
|
104
|
+
})
|
|
105
|
+
const { poll } = mod.__test
|
|
106
|
+
await poll()
|
|
107
|
+
await poll()
|
|
108
|
+
assert.ok(await scanTitleBlink(doc, 1400), '声音关闭时 blink 不应被 continue 连坐跳过')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('Given 标题闪烁进行中 When 窗口重获焦点 Then 标题立即复位', async () => {
|
|
112
|
+
const docListeners = {}
|
|
113
|
+
const dom = {
|
|
114
|
+
hasFocus: () => false, title: 'dsh', hidden: true,
|
|
115
|
+
addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn) },
|
|
116
|
+
removeEventListener: (type, fn) => { docListeners[type] = (docListeners[type] || []).filter((f) => f !== fn) },
|
|
117
|
+
}
|
|
118
|
+
const { mod, window: winStub, document: doc } = loadClient({
|
|
119
|
+
storage: new FakeStorage(),
|
|
120
|
+
payload: { units: [units[1]], soundMapping: {}, version: 1 },
|
|
121
|
+
document: dom,
|
|
122
|
+
})
|
|
123
|
+
const { poll } = mod.__test
|
|
124
|
+
await poll()
|
|
125
|
+
await poll()
|
|
126
|
+
mod.__test.start()
|
|
127
|
+
const focusHandlers = (winStub.listeners.focus || []).slice()
|
|
128
|
+
assert.ok(focusHandlers.length > 0, 'window focus 未监听')
|
|
129
|
+
doc.title = '⏳ dsh'
|
|
130
|
+
for (const fn of focusHandlers) fn()
|
|
131
|
+
assert.equal(doc.title, 'dsh', '焦点返回后标题应复位')
|
|
132
|
+
winStub['turn-notify:polling'].abort()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('Given 连环错误通知重入闪烁 When 硬顶到期 Then 标题必停', async () => {
|
|
136
|
+
const dom = { hasFocus: () => false, title: 'dsh', hidden: true }
|
|
137
|
+
const { mod, document: doc } = loadClient({
|
|
138
|
+
storage: new FakeStorage(),
|
|
139
|
+
payload: { units: [{ id: 'e1', category: 'error', text: '[dsh] 任务出错: e1' }, { id: 'e2', category: 'error', text: '[dsh] 任务出错: e2' }], soundMapping: {}, version: 1 },
|
|
140
|
+
document: dom,
|
|
141
|
+
shortTimers: true,
|
|
142
|
+
})
|
|
143
|
+
const { poll } = mod.__test
|
|
144
|
+
await poll()
|
|
145
|
+
await poll()
|
|
146
|
+
// 同批两事件:第一条启动闪烁(单条 60ms 即停),第二条重入重排至硬顶 200ms
|
|
147
|
+
const sawBlink = await scanTitleBlink(doc, 150)
|
|
148
|
+
assert.ok(sawBlink, '重入应延长闪烁,不得按单条时长提前复位')
|
|
149
|
+
await sleep(200)
|
|
150
|
+
assert.equal(doc.title.indexOf('⏳'), -1, '硬顶到期标题必复位,不允许无限续闪')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
async function scanTitleBlink(doc, ms) {
|
|
154
|
+
const deadline = Date.now() + ms
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
if (doc.title.indexOf('⏳') >= 0) return true
|
|
157
|
+
await sleep(5)
|
|
158
|
+
}
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
163
|
+
|
|
88
164
|
test('broken localStorage:清理段不抛,降级发声恰好一次,第二轮不再发声', async () => {
|
|
89
165
|
const { mod, shown } = loadClient({ storage: new BrokenStorage(), payload: { units, soundMapping: {}, version: 1 } })
|
|
90
166
|
const { poll, storageState } = mod.__test
|
|
@@ -189,6 +265,8 @@ function makeSidebarDom(rows) {
|
|
|
189
265
|
querySelector: (sel) => (sel.indexOf('_title') >= 0 ? leaf : null),
|
|
190
266
|
querySelectorAll: (sel) => (sel.indexOf('_title') >= 0 ? [leaf] : []),
|
|
191
267
|
children: [], classList: rowClassList,
|
|
268
|
+
clicks: 0,
|
|
269
|
+
click() { row.clicks += 1 },
|
|
192
270
|
}
|
|
193
271
|
Object.defineProperty(row, 'className', {
|
|
194
272
|
get: () => [...rowClassList.set].join(' '),
|
|
@@ -216,6 +294,93 @@ function makeSidebarDom(rows) {
|
|
|
216
294
|
|
|
217
295
|
const clickEventOn = (row) => ({ target: { closest: (sel) => (sel === '.tn-sess-hl' ? row : null) } })
|
|
218
296
|
|
|
297
|
+
test('Given 冷启动存量通知带会话标题 When 静默对齐 Then 不呈现但行挂高亮', async () => {
|
|
298
|
+
// 冷启动对齐只静默通知呈现,未读会话闪烁是本机 UI 状态,不属轰炸,必须保留
|
|
299
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
300
|
+
const hlUnits = [
|
|
301
|
+
{ id: 'u-cold', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
|
|
302
|
+
]
|
|
303
|
+
const { mod, shown } = loadClient({
|
|
304
|
+
storage: new FakeStorage(),
|
|
305
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
|
|
306
|
+
document: { title: '会话乙 — DeepSeek Harness', ...dom },
|
|
307
|
+
})
|
|
308
|
+
await mod.__test.poll()
|
|
309
|
+
await mod.__test.poll()
|
|
310
|
+
assert.equal(shown.length, 0, '冷启动对齐不轰炸呈现')
|
|
311
|
+
assert.ok(dom.rows[0].classList.set.has('tn-sess-hl'), '静默对齐仍应挂未读会话高亮')
|
|
312
|
+
assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '冷启动高亮携带分类色')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('Given 冷启动存量通知是当前查看的会话 When 静默对齐 Then 不挂高亮', async () => {
|
|
316
|
+
// 与正常路径同语义:正在查看即已读,不做未读强调
|
|
317
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
318
|
+
const hlUnits = [
|
|
319
|
+
{ id: 'u-cold-cur', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
|
|
320
|
+
]
|
|
321
|
+
const { mod } = loadClient({
|
|
322
|
+
storage: new FakeStorage(),
|
|
323
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
|
|
324
|
+
document: { title: '会话甲 — DeepSeek Harness', ...dom },
|
|
325
|
+
})
|
|
326
|
+
await mod.__test.poll()
|
|
327
|
+
await mod.__test.poll()
|
|
328
|
+
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '正在查看的会话冷启动也不挂高亮')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('Given 高亮开关关闭且冷启动带存量通知 When 静默对齐 Then 不挂任何类', async () => {
|
|
332
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
333
|
+
const hlUnits = [
|
|
334
|
+
{ id: 'u-off', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
|
|
335
|
+
]
|
|
336
|
+
const { mod } = loadClient({
|
|
337
|
+
storage: new FakeStorage({ 'turn-notify:session-hl': '0' }),
|
|
338
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
|
|
339
|
+
document: { title: '会话乙 — DeepSeek Harness', ...dom },
|
|
340
|
+
})
|
|
341
|
+
await mod.__test.poll()
|
|
342
|
+
await mod.__test.poll()
|
|
343
|
+
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '开关关闭时冷启动也不挂高亮')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('Given 冷启动存量超过高亮容量上限 When 静默对齐 Then 仅保留最新一批,最旧被淘汰', async () => {
|
|
347
|
+
const titles = Array.from({ length: 22 }, (unused, index) => '会话' + (index + 1))
|
|
348
|
+
const dom = makeSidebarDom(titles.map((leafText) => ({ leafText })))
|
|
349
|
+
const hlUnits = titles.map((title, index) => ({
|
|
350
|
+
id: 'u-cap-' + index, category: 'completed', text: '[dsh] 任务完成: ' + title, session: title,
|
|
351
|
+
}))
|
|
352
|
+
const { mod } = loadClient({
|
|
353
|
+
storage: new FakeStorage(),
|
|
354
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
|
|
355
|
+
document: { title: 'dsh', ...dom },
|
|
356
|
+
})
|
|
357
|
+
await mod.__test.poll()
|
|
358
|
+
await mod.__test.poll()
|
|
359
|
+
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '最旧条目被淘汰')
|
|
360
|
+
assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), false, '最旧条目被淘汰')
|
|
361
|
+
assert.ok(dom.rows[2].classList.set.has('tn-sess-hl'), '上限内条目保留')
|
|
362
|
+
assert.ok(dom.rows[21].classList.set.has('tn-sess-hl'), '最新条目保留')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('Given 冷启动标题未就绪误挂当前会话 When 标题就绪后任一应用帧 Then 自愈摘除', async () => {
|
|
366
|
+
// 首拉早于宿主设置 title 时,"正在看即已读"排除失效误挂;就绪后的应用帧复核自愈
|
|
367
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
368
|
+
const hlUnits = [
|
|
369
|
+
{ id: 'u-race', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
|
|
370
|
+
]
|
|
371
|
+
const { mod, document: docStub } = loadClient({
|
|
372
|
+
storage: new FakeStorage(),
|
|
373
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
|
|
374
|
+
document: { title: 'dsh', ...dom },
|
|
375
|
+
})
|
|
376
|
+
await mod.__test.poll()
|
|
377
|
+
await mod.__test.poll()
|
|
378
|
+
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true, '标题未就绪时误挂,复现竞态')
|
|
379
|
+
docStub.title = '会话甲 — DeepSeek Harness'
|
|
380
|
+
await mod.__test.poll()
|
|
381
|
+
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '标题就绪后应用帧自愈摘除')
|
|
382
|
+
})
|
|
383
|
+
|
|
219
384
|
test('Given 通知带 session 标题 When 认领 Then 会话行挂上高亮类', async () => {
|
|
220
385
|
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
221
386
|
const hlUnits = [
|
|
@@ -377,8 +542,81 @@ test('Given 通知标题字段缺失 When 认领 Then 不挂高亮类且链路
|
|
|
377
542
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false)
|
|
378
543
|
})
|
|
379
544
|
|
|
380
|
-
|
|
381
|
-
|
|
545
|
+
// 点击直达:页内卡片 onClick 聚焦窗口并模拟点击侧边栏会话行(切换会话即已读);
|
|
546
|
+
// 无标题通知只聚焦不点击
|
|
547
|
+
test('Given 带标题通知 When 点击页内卡片 Then 聚焦窗口并点击会话行;无标题仅聚焦', async () => {
|
|
548
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
549
|
+
const hlUnits = [
|
|
550
|
+
{ id: 'u-jump', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲', summary: '耗时 12 秒' },
|
|
551
|
+
{ id: 'u-bare', category: 'error', text: '[dsh] 任务出错: 无标题' },
|
|
552
|
+
]
|
|
553
|
+
const focuses = []
|
|
554
|
+
const { mod, shown, window: winStub } = loadClient({
|
|
555
|
+
storage: new FakeStorage(),
|
|
556
|
+
payload: { units: hlUnits, soundMapping: {}, version: 1 },
|
|
557
|
+
document: { title: '会话乙 — DeepSeek Harness', ...dom },
|
|
558
|
+
})
|
|
559
|
+
winStub.focus = () => { focuses.push('focus') }
|
|
560
|
+
await mod.__test.poll()
|
|
561
|
+
await mod.__test.poll()
|
|
562
|
+
assert.equal(shown.length, 2)
|
|
563
|
+
// 摘要随文本两行呈现
|
|
564
|
+
assert.ok(shown[0].text.includes('\n耗时 12 秒'), '卡片文本含小结行')
|
|
565
|
+
assert.equal(typeof shown[0].opts.onClick, 'function', '带标题通知携带直达回调')
|
|
566
|
+
assert.equal(typeof shown[1].opts.onClick, 'function', '无标题通知仍携带回调(仅聚焦)')
|
|
567
|
+
shown[0].opts.onClick()
|
|
568
|
+
assert.deepEqual(focuses, ['focus'], '直达先聚焦窗口')
|
|
569
|
+
assert.equal(dom.rows[0].clicks, 1, '模拟点击侧边栏会话行切换会话')
|
|
570
|
+
const clicksBefore = dom.rows[0].clicks
|
|
571
|
+
shown[1].opts.onClick()
|
|
572
|
+
assert.deepEqual(focuses, ['focus', 'focus'])
|
|
573
|
+
assert.equal(dom.rows[0].clicks, clicksBefore, '无标题通知不点击任何行')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
// 二次提醒:等待用户动作的事件超时未处理补发一轮;已处理(点击行/切到会话)与投影过期不补
|
|
577
|
+
test('Given 审批通知超时未处理 When 提醒到期 Then 补发一轮;已处理与投影过期不补', async () => {
|
|
578
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }, { leafText: '会话乙' }])
|
|
579
|
+
const hlUnits = [
|
|
580
|
+
{ id: 'u-appr', category: 'approval', text: '[dsh] 等待审批: 会话甲', session: '会话甲' },
|
|
581
|
+
{ id: 'u-done', category: 'completed', text: '[dsh] 任务完成: 会话乙', session: '会话乙' },
|
|
582
|
+
]
|
|
583
|
+
const { mod, shown } = loadClient({
|
|
584
|
+
storage: new FakeStorage(),
|
|
585
|
+
payload: { units: hlUnits, soundMapping: {}, version: 1 },
|
|
586
|
+
document: { title: '会话乙 — DeepSeek Harness', ...dom },
|
|
587
|
+
shortTimers: false,
|
|
588
|
+
})
|
|
589
|
+
const { poll } = mod.__test
|
|
590
|
+
await poll()
|
|
591
|
+
await poll()
|
|
592
|
+
const before = shown.length
|
|
593
|
+
// 推进到提醒到期:approval 未读(高亮仍在)→ 补发;completed 非等待类从不登记
|
|
594
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
595
|
+
assert.equal(shown.length, before, '提醒未到期不补发')
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
test('Given 审批提醒登记 When 单元过期(prune)Then 不再补发', async () => {
|
|
599
|
+
const dom = makeSidebarDom([{ leafText: '会话甲' }])
|
|
600
|
+
const hlUnits = [
|
|
601
|
+
{ id: 'u-appr-gone', category: 'approval', text: '[dsh] 等待审批: 会话甲', session: '会话甲' },
|
|
602
|
+
]
|
|
603
|
+
// 第二轮投影不再含该单元:prune 撤销登记
|
|
604
|
+
const { mod, shown } = loadClient({
|
|
605
|
+
storage: new FakeStorage(),
|
|
606
|
+
payload: { units: hlUnits, soundMapping: {}, version: 1 },
|
|
607
|
+
document: { title: '会话乙 — DeepSeek Harness', ...dom },
|
|
608
|
+
})
|
|
609
|
+
const { poll } = mod.__test
|
|
610
|
+
await poll()
|
|
611
|
+
await poll()
|
|
612
|
+
// 第三轮投影单元消失
|
|
613
|
+
mod.__test.pruneReminders(new Set())
|
|
614
|
+
const before = shown.length
|
|
615
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
616
|
+
assert.equal(shown.length, before, '登记撤销后不补发')
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确命中自身行', async () => { // 长标题行在前,短标题是长标题的子串:短标题通知必须挂到全等行,不得被子串误吸
|
|
382
620
|
const dom = makeSidebarDom([
|
|
383
621
|
{ leafText: '修复通知插件长轮询问题' },
|
|
384
622
|
{ leafText: '通知插件' },
|
package/test/parity.test.mjs
CHANGED
|
@@ -144,7 +144,7 @@ defineDeadScenarios('[core.mjs deadCustomIds] ', coreDeadCustomIds)
|
|
|
144
144
|
defineDeadScenarios('[client.js deadCustomIds] ', client.deadCustomIds)
|
|
145
145
|
|
|
146
146
|
// 四通道矩阵对照:client 版开关取自 localStorage,经 stub 注入后与 core 参数化版本逐场景比对。
|
|
147
|
-
function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled, soundEnabled, soundCategories, category, systemEnabled, idleMs, idleThresholdMs, pageSoundEnabled, pageSoundCategories }) {
|
|
147
|
+
function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled, soundEnabled, soundCategories, category, systemEnabled, idleMs, idleThresholdMs, pageSoundEnabled, pageSoundCategories, routes }) {
|
|
148
148
|
const backing = new Map()
|
|
149
149
|
if (focusQuiet === false) backing.set(client.KEY_DND, '0')
|
|
150
150
|
if (systemEnabled === false) backing.set(client.KEY_SYSTEM, '0')
|
|
@@ -155,7 +155,7 @@ function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled,
|
|
|
155
155
|
globalThis.window = { localStorage: { getItem: (key) => (backing.has(key) ? backing.get(key) : null) } }
|
|
156
156
|
try {
|
|
157
157
|
if (idleThresholdMs !== undefined) assert.equal(client.IDLE_AWAY_MS, idleThresholdMs)
|
|
158
|
-
return client.chooseChannels(hasFocus, permission, idleMs ?? undefined, soundCategories ?? null, category ?? null)
|
|
158
|
+
return client.chooseChannels(hasFocus, permission, idleMs ?? undefined, soundCategories ?? null, category ?? null, routes ?? null)
|
|
159
159
|
} finally {
|
|
160
160
|
delete globalThis.window
|
|
161
161
|
}
|
|
@@ -196,6 +196,31 @@ function defineChannelScenarios(prefix, channels) {
|
|
|
196
196
|
assert.equal(channels({ ...base, idleMs: 0 }).sound, false)
|
|
197
197
|
assert.deepEqual(channels(base), { toast: true, sound: false, system: false, blink: false, pageSound: false })
|
|
198
198
|
})
|
|
199
|
+
test(prefix + '事件→通道路由:放行名单叠加本机偏好,缺省全放行', () => {
|
|
200
|
+
const base = { hasFocus: false, permission: 'granted' }
|
|
201
|
+
// 未配置(null):全通道,与旧行为一致
|
|
202
|
+
assert.deepEqual(channels({ ...base, routes: null }), { toast: true, sound: true, system: true, blink: false, pageSound: false })
|
|
203
|
+
// 名单外通道一律不出
|
|
204
|
+
assert.deepEqual(
|
|
205
|
+
channels({ ...base, routes: ['webhook', 'im'] }),
|
|
206
|
+
{ toast: false, sound: false, system: false, blink: false, pageSound: false },
|
|
207
|
+
'呈现通道全被路由排除',
|
|
208
|
+
)
|
|
209
|
+
assert.equal(channels({ ...base, routes: ['sound'] }).sound, true)
|
|
210
|
+
assert.equal(channels({ ...base, routes: ['sound', 'toast'] }).toast, true)
|
|
211
|
+
assert.equal(channels({ ...base, routes: ['toast'] }).sound, false, '名单外声音通道关闭')
|
|
212
|
+
// 空名单 = 全禁(显式配置)
|
|
213
|
+
assert.deepEqual(
|
|
214
|
+
channels({ ...base, routes: [] }),
|
|
215
|
+
{ toast: false, sound: false, system: false, blink: false, pageSound: false },
|
|
216
|
+
)
|
|
217
|
+
// 路由与聚焦静默叠加:名单放行 + 聚焦静默仍压声音
|
|
218
|
+
assert.equal(channels({ ...base, hasFocus: true, routes: ['sound', 'system'] }).sound, false)
|
|
219
|
+
assert.equal(channels({ ...base, hasFocus: true, routes: ['sound', 'system'] }).system, false)
|
|
220
|
+
// 页内提示音随 toast 路由:toast 被排除即补位音不出
|
|
221
|
+
assert.equal(channels({ ...base, hasFocus: true, pageSoundEnabled: true, routes: ['toast'] }).pageSound, true)
|
|
222
|
+
assert.equal(channels({ ...base, hasFocus: true, pageSoundEnabled: true, routes: ['sound'] }).pageSound, false)
|
|
223
|
+
})
|
|
199
224
|
}
|
|
200
225
|
|
|
201
226
|
defineChannelScenarios('[core.mjs chooseChannels] ', coreChooseChannels)
|
package/test/route.test.mjs
CHANGED
|
@@ -199,6 +199,26 @@ test('config POST 非法 imTargets 400', async () => {
|
|
|
199
199
|
assert.equal(res.status, 400)
|
|
200
200
|
})
|
|
201
201
|
|
|
202
|
+
test('config POST 持久化 kindRoutes 并回显面板状态,部分补丁不触碰其他配置', async () => {
|
|
203
|
+
const { ctx, routes } = makeCtx()
|
|
204
|
+
apply(ctx)
|
|
205
|
+
const handler = routes.get('/api/turn-notify/config')
|
|
206
|
+
const res = makeRes()
|
|
207
|
+
await handler(makeReq('POST', { kindRoutes: { completed: ['webhook'], approval: ['im', 'sound', 'system'] } }, JSON_HEADERS), res)
|
|
208
|
+
assert.equal(res.status, 200)
|
|
209
|
+
assert.deepEqual(res.body.kindRoutes, { completed: ['webhook'], approval: ['im', 'sound', 'system'] })
|
|
210
|
+
const readback = makeRes()
|
|
211
|
+
await handler(makeReq('GET'), readback)
|
|
212
|
+
assert.deepEqual(readback.body.kindRoutes, { completed: ['webhook'], approval: ['im', 'sound', 'system'] })
|
|
213
|
+
// 部分补丁按分类合并进已存路由(与 enabled 同语义):新键并入,既有键不动
|
|
214
|
+
const patch = makeRes()
|
|
215
|
+
await handler(makeReq('POST', { kindRoutes: { ask: [] } }, JSON_HEADERS), patch)
|
|
216
|
+
assert.equal(patch.status, 200)
|
|
217
|
+
const final = makeRes()
|
|
218
|
+
await handler(makeReq('GET'), final)
|
|
219
|
+
assert.deepEqual(final.body.kindRoutes, { completed: ['webhook'], approval: ['im', 'sound', 'system'], ask: [] })
|
|
220
|
+
})
|
|
221
|
+
|
|
202
222
|
test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应含完整面板状态', async () => {
|
|
203
223
|
// 勾选即存契约:面板勾选/取消注册只 POST {imTargets},不得清掉已存的 webhook 与分类开关
|
|
204
224
|
const dshIm = { send: async () => ({ sent: true }), listTargets: async () => [] }
|