@mzzsfy/dsh-turn-notify 0.10.0 → 0.11.1
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 +15 -8
- package/package.json +1 -1
- package/src/client.js +175 -6
- package/src/core.mjs +20 -20
- package/src/index.js +22 -22
- package/test/client-fold.test.mjs +405 -0
- package/test/core.test.mjs +3 -0
- package/test/flow.test.mjs +15 -5
- package/test/host-notify.test.mjs +96 -113
package/README.md
CHANGED
|
@@ -82,12 +82,12 @@ turn-notify:
|
|
|
82
82
|
|
|
83
83
|
## 宿主桌面通知
|
|
84
84
|
|
|
85
|
-
开启面板"通知"页的宿主通知开关后,通知触发时由宿主进程直接弹操作系统级桌面通知,零依赖:macOS 走 osascript,Linux 走 notify-send,Windows 走 PowerShell 调 WinRT toast;超时 10
|
|
85
|
+
开启面板"通知"页的宿主通知开关后,通知触发时由宿主进程直接弹操作系统级桌面通知,零依赖:macOS 走 osascript,Linux 走 notify-send,Windows 走 PowerShell 调 WinRT toast;超时 10 秒,失败即弃不重试,不影响其余通道。去重方向为**浏览器优先**:浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,宿主让位,正常路径零重复;浏览器离场(标签页全关)时宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与网络抖动),宁可重复不可漏。两个开关判定一致,任一开启即生效:
|
|
86
86
|
|
|
87
87
|
- **宿主机弹桌面通知**:启用宿主通知通道(浏览器优先去重内建)。
|
|
88
|
-
-
|
|
88
|
+
- **仅当无浏览器接收时补位**:与总开关等效,作为显式的补位语义表达保留。
|
|
89
89
|
|
|
90
|
-
"
|
|
90
|
+
"浏览器在场"按投影长轮询记账:任意来源 + 续传 cursor + 同源形态的 GET 在服务端挂起期间计为在途,断连(标签页关闭/请求中止)经连接关闭事件即时出账,最近活动在 2 秒认可窗口内仍算在场——优雅断连的盲区不超过 2 秒;异常断连(睡眠、强杀、断网)服务端无感知,受长轮询挂起上限约束,最坏约 25 秒后自愈出账。来源不限地址:浏览器可在任意机器访问 dsh(局域网部署),在场信号看的是"有无 dsh 页面在轮询",与浏览器同不同机无关。浏览器在场但在场浏览器的呈现偏好全关(声音/弹窗/页内全关或系统通知未授权)时,宿主同样让位,该事件在浏览器侧无 OS 级通知(仅剩会话行高亮)。宿主在无桌面的服务器上时 spawn 会静默失败,无害;测试页"测试宿主通知"返回真实结果,可据此验证宿主机桌面环境是否可用。聚焦时宿主通知照弹(宿主无法感知窗口焦点,聚焦静默是浏览器侧偏好)。
|
|
91
91
|
|
|
92
92
|
## IM 推送
|
|
93
93
|
|
|
@@ -97,6 +97,10 @@ turn-notify:
|
|
|
97
97
|
|
|
98
98
|
通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题默认与侧边栏行同源(session-title 投影);该服务缺失或不可用(如伪会话无事件日志)时回落会话事件内存储的标题,可能与侧边栏不同步。会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。重开页面的冷启动静默对齐同样挂高亮(见"多窗口只响一次"),重开即见哪些会话有动静。
|
|
99
99
|
|
|
100
|
+
## 工作区文件夹运行标记
|
|
101
|
+
|
|
102
|
+
侧边栏会话列表按工作区分组(官方 WorkspaceBrowser 分组模式)时,有运行中会话的工作区文件夹组头显示脉冲运行点并给文件夹图标着色,游离(未归属任何工作区)的可见运行中会话使末位「未分组」组头同样标记——折叠与展开同样生效。归属与运行态一律取服务快照投影,DOM 仅按下标对位官方组容器;组容器数漂移(单列表模式/搜索态/结构变更)即清除标记不误标。图标着色让位官方 folderActive(展开且含当前会话)态。开关在面板"偏好"页"会话列表"卡(存宿主配置,各浏览器共用),默认开启,变更刷新页面生效。
|
|
103
|
+
|
|
100
104
|
## 多窗口只响一次
|
|
101
105
|
|
|
102
106
|
同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现);未读会话行高亮不在静默范围,照常挂上。
|
|
@@ -123,8 +127,8 @@ turn-notify:
|
|
|
123
127
|
minTurnDurationMs: 5000 # 最短回合时长(毫秒),回合结束类通知短于此不送达
|
|
124
128
|
rootsOnly: true # 子代理会话不通知
|
|
125
129
|
suppressSubagentWake: true # 子代理相关回合不通知:后台委托未收尾或收尾唤醒(仅任务完成类)
|
|
126
|
-
hostNotify: false #
|
|
127
|
-
hostNotifyFallback: false #
|
|
130
|
+
hostNotify: false # 宿主机桌面通知总开关;浏览器优先,浏览器在场(2 秒内有在途长轮询)时让位
|
|
131
|
+
hostNotifyFallback: false # 宿主通知补位:浏览器离场(2 秒内无在途长轮询)时弹宿主桌面通知,与总开关判定一致
|
|
128
132
|
enabled: # 六类事件独立开关
|
|
129
133
|
completed: true
|
|
130
134
|
error: true
|
|
@@ -149,18 +153,18 @@ turn-notify:
|
|
|
149
153
|
|
|
150
154
|
1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
|
|
151
155
|
2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
|
|
152
|
-
3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤)
|
|
156
|
+
3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤),不依赖浏览器;宿主通知按浏览器在场(在途长轮询,来源不限本机)浏览器优先去重,离场补位;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
|
|
153
157
|
4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
|
|
154
158
|
5. **降级链**:声音通道开启而系统弹窗不可用(HTTP 非回环、未授权)→ 标题闪烁;页内提示依赖缺失 → 干净禁用该通道;localStorage 不可用 → 该窗口直接发声(可能与多窗口重复,诚实降级)。
|
|
155
159
|
|
|
156
160
|
## 已知取舍
|
|
157
161
|
|
|
158
162
|
- 标签页全关时,仅 webhook、IM 与宿主桌面通知(如开启)送达(声音与弹窗的浏览器前提)。
|
|
159
|
-
-
|
|
163
|
+
- 宿主桌面通知为浏览器优先去重:浏览器在场(2 秒内有在途长轮询)时宿主让位,边界情况(认可窗口边界、网络抖动)宁可不弹不误弹,优雅断连的离场盲区不超过 2 秒,异常断连受长轮询挂起上限约束(最坏约 25 秒);聚焦时宿主通知照弹(宿主无法感知窗口焦点);无桌面的宿主上 spawn 静默失败,无害。在场记账的形态校验(续传 cursor + 同源 fetch 形态)为启发式,防无意探活误压制与网页伪造在场(DNS rebinding 将域解析至 dsh 的页面同源可过校验,但该页面同时已握有全部写配置 API,通知压制非最大损失面),不构成对抗本机进程的安全边界。
|
|
160
164
|
- IM 依赖 dsh-im 的 bot 在线,离线时该次通知弃置不补发;成功回执仅代表平台受理。
|
|
161
165
|
- 不同浏览器或不同地址打开的窗口各自发声,互不去重。
|
|
162
166
|
- 系统弹窗在 HTTP 非回环下永久降级,HTTPS 化后自动恢复;Windows 下受系统通知设置与专注助手约束。
|
|
163
|
-
-
|
|
167
|
+
- 长轮询空闲期连接由代理或服务端挂起,经中间反代时需允许数十秒的挂起响应,否则退化为更频繁的重连;反代还需透传 Host 头(如 nginx `proxy_set_header Host $host`),否则同源对账失败、真实浏览器不入账,宿主通知每次重复弹(失败方向与"宁可重复不可漏"一致)。
|
|
164
168
|
- 等待动作二次提醒的"已处理"判定以本窗口可见信号(会话切换/高亮清除)近似,无会话标题的通知无法感知处理,宁可多响一次。
|
|
165
169
|
- 点击直达依赖侧边栏会话行可被标题匹配命中,行未渲染或标题不同步时仅聚焦不切换。
|
|
166
170
|
|
|
@@ -185,3 +189,6 @@ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依
|
|
|
185
189
|
|
|
186
190
|
工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。
|
|
187
191
|
|
|
192
|
+
## dsh 版本兼容
|
|
193
|
+
|
|
194
|
+
三版本全部通过:通知开关与播报形态、激活 live。0.1.6 控件数 13→19 属宿主演进,不构成不兼容。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -278,7 +278,7 @@ window.__ModuleLoader__.load({
|
|
|
278
278
|
const IDLE_AWAY_MS = 5 * 60 * 1000
|
|
279
279
|
|
|
280
280
|
// routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源。
|
|
281
|
-
//
|
|
281
|
+
// 宿主通知去重由浏览器优先模型承载:浏览器在场时宿主让位,本窗口照常弹系统通知
|
|
282
282
|
function chooseChannels(hasFocus, permission, idleMs, soundCategories, category, routes) {
|
|
283
283
|
const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
|
|
284
284
|
const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
|
|
@@ -864,6 +864,55 @@ window.__ModuleLoader__.load({
|
|
|
864
864
|
row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
|
|
865
865
|
}
|
|
866
866
|
|
|
867
|
+
// ---- 工作区文件夹运行标记 ----
|
|
868
|
+
|
|
869
|
+
// 官方组容器 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)与自家标记属性;
|
|
870
|
+
// 扫描合并窗口覆盖聊天流式输出的高频 DOM mutation
|
|
871
|
+
const FOLD_SECTION_SELECTOR = 'div[class$="_groupSection"]'
|
|
872
|
+
const FOLD_RUNNING_FLAG = 'data-tn-folder-running'
|
|
873
|
+
const FOLD_SCAN_DELAY_MS = 50
|
|
874
|
+
|
|
875
|
+
// 运行标记状态:官方 dsh-client-ui-workspace 分组语义的投影——
|
|
876
|
+
// 归属走 workspace 账本 sessionIds 一级映射(官方 owningGroupKey 同构,无 cwd 回退),
|
|
877
|
+
// 游离会话入末位未分组桶;可见性镜像官方 sessionVisible(subagent / 已归档 /
|
|
878
|
+
// 空白且非当前排除),组内任一可见会话 running 即该工作区运行中。
|
|
879
|
+
// ids 保工作区登记序(官方组渲染序 = 快照 items 序),供 DOM 组容器按下标对位
|
|
880
|
+
function folderRunningState(sessionsState, workspacesState) {
|
|
881
|
+
const byId = (sessionsState && sessionsState.byId) || {}
|
|
882
|
+
const ids = (sessionsState && sessionsState.ids) || []
|
|
883
|
+
const current = sessionsState ? sessionsState.current : undefined
|
|
884
|
+
const workspaces = workspacesState && Array.isArray(workspacesState.items) ? workspacesState.items : []
|
|
885
|
+
const archived = new Set((workspacesState && workspacesState.archivedSessionIds) || [])
|
|
886
|
+
const visible = (row) => Boolean(row)
|
|
887
|
+
&& row.origin !== 'subagent'
|
|
888
|
+
&& !archived.has(row.id)
|
|
889
|
+
&& (!row.blank || row.id === current)
|
|
890
|
+
const running = new Set()
|
|
891
|
+
const accounted = new Set()
|
|
892
|
+
const workspaceIds = []
|
|
893
|
+
for (const workspace of workspaces) {
|
|
894
|
+
// 主键不做防御过滤,与官方 groupByWorkspace 同构:items 长度即组容器对位基准,
|
|
895
|
+
// 过滤会让 ids 短于容器数,恰差一时错位成「末位未分组桶」对位
|
|
896
|
+
workspaceIds.push(workspace.workspaceId)
|
|
897
|
+
const sessionIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []
|
|
898
|
+
for (const sessionId of sessionIds) {
|
|
899
|
+
const row = byId[sessionId]
|
|
900
|
+
if (!row) continue
|
|
901
|
+
accounted.add(sessionId)
|
|
902
|
+
if (row.running && visible(row)) running.add(workspace.workspaceId)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
let ungrouped = false
|
|
906
|
+
for (const sessionId of ids) {
|
|
907
|
+
if (accounted.has(sessionId)) continue
|
|
908
|
+
const row = byId[sessionId]
|
|
909
|
+
if (row && row.running && visible(row)) ungrouped = true
|
|
910
|
+
}
|
|
911
|
+
return { ids: workspaceIds, running, ungrouped }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const FOLD_SWITCH_TITLE = '侧边栏会话列表按工作区分组时,有运行中会话的文件夹组头显示运行点并给文件夹图标着色,文件夹折叠与展开同样生效;游离会话所在的「未分组」组同样标记;单列表模式下无此标记。停用即全部隐藏,刷新页面生效。'
|
|
915
|
+
|
|
867
916
|
// ---- 点击直达会话:页内卡片与系统弹窗共用 ----
|
|
868
917
|
|
|
869
918
|
// 按通知单元激活会话:聚焦窗口并模拟点击侧边栏会话行(官方行点击即切换会话);
|
|
@@ -1019,6 +1068,7 @@ window.__ModuleLoader__.load({
|
|
|
1019
1068
|
suppressSubagentWake: true,
|
|
1020
1069
|
hostNotify: false,
|
|
1021
1070
|
hostNotifyFallback: false,
|
|
1071
|
+
folderRunningEnabled: true,
|
|
1022
1072
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
|
|
1023
1073
|
imTargets: [],
|
|
1024
1074
|
}
|
|
@@ -1136,6 +1186,18 @@ window.__ModuleLoader__.load({
|
|
|
1136
1186
|
'@media (prefers-reduced-motion: reduce) {',
|
|
1137
1187
|
' .tn-sess-hl { animation:none; background-color:color-mix(in srgb, var(--tn-sess-color, #4c8dff) 50%, var(--dsw-alias-bg-base, #202020) 50%); }',
|
|
1138
1188
|
'}',
|
|
1189
|
+
// 工作区文件夹运行标记:官方组容器自带 position:relative,运行点绝对定位于
|
|
1190
|
+
// 组容器左缘、垂直对齐组头行中线(行高 34px 之半即 top 17px,落于组头行左内边距
|
|
1191
|
+
// 区),零布局位移;图标着色让位官方 folderActive(展开含当前)态;色值取官方
|
|
1192
|
+
// ongoing 运行点的实际色值(--dsh-state-ongoing 在官方仅定义于其状态点组件局部,
|
|
1193
|
+
// 组容器上下文读不到,直接引用其回退源)
|
|
1194
|
+
'div[class$="_groupSection"][data-tn-folder-running] { --tn-fold-running-color: var(--dsw-static-deepseek-450); }',
|
|
1195
|
+
'div[class$="_groupSection"][data-tn-folder-running]::before { content:""; position:absolute; left:0; top:17px; width:5px; height:5px;',
|
|
1196
|
+
' margin-top:-2.5px; border-radius:999px; background:var(--tn-fold-running-color);',
|
|
1197
|
+
' animation:tn-fold-running-pulse 1.2s ease-in-out infinite; }',
|
|
1198
|
+
'div[class$="_groupSection"][data-tn-folder-running] span[class$="_folder"]:not([class$="_folderActive"]) { color:var(--tn-fold-running-color); }',
|
|
1199
|
+
'@keyframes tn-fold-running-pulse { 0%, 100% { opacity:1; } 50% { opacity:.35; } }',
|
|
1200
|
+
'@media (prefers-reduced-motion: reduce) { div[class$="_groupSection"][data-tn-folder-running]::before { animation:none; } }',
|
|
1139
1201
|
].join('\n')
|
|
1140
1202
|
|
|
1141
1203
|
function h(type, props) {
|
|
@@ -1408,6 +1470,21 @@ window.__ModuleLoader__.load({
|
|
|
1408
1470
|
})
|
|
1409
1471
|
}
|
|
1410
1472
|
|
|
1473
|
+
// 文件夹运行标记开关:乐观回填,服务端失败回滚;变更刷新页面生效(标记
|
|
1474
|
+
// 注入在插件激活时按配置装配)
|
|
1475
|
+
async function toggleFolderRunning(checked) {
|
|
1476
|
+
const previous = config.folderRunningEnabled
|
|
1477
|
+
setConfig({ ...config, folderRunningEnabled: checked })
|
|
1478
|
+
try {
|
|
1479
|
+
const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ folderRunningEnabled: checked }) })
|
|
1480
|
+
setConfig({ ...DEFAULT_CONFIG, ...res })
|
|
1481
|
+
patch('工作区文件夹运行标记已' + (res.folderRunningEnabled !== false ? '启用' : '停用') + ',刷新页面后生效')
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
setConfig((prev) => ({ ...prev, folderRunningEnabled: previous }))
|
|
1484
|
+
patch('切换失败:' + (error && error.message ? error.message : String(error)), 'error')
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1411
1488
|
async function requestPermission() {
|
|
1412
1489
|
try {
|
|
1413
1490
|
const next = await Notification.requestPermission()
|
|
@@ -1678,19 +1755,19 @@ window.__ModuleLoader__.load({
|
|
|
1678
1755
|
' 后台委托未收尾或收尾唤醒的回合不通知(仅完成类)'),
|
|
1679
1756
|
]),
|
|
1680
1757
|
field('宿主通知', [
|
|
1681
|
-
h('label', { className: 'tn-meta tn-switch', title: '通知触发时由宿主进程弹系统级桌面通知(osascript/notify-send/PowerShell toast)
|
|
1758
|
+
h('label', { className: 'tn-meta tn-switch', title: '通知触发时由宿主进程弹系统级桌面通知(osascript/notify-send/PowerShell toast);浏览器优先:有浏览器窗口在线(2 秒内有在途长轮询)时由浏览器呈现,宿主不重复弹;浏览器全关(标签页全关)时宿主弹' },
|
|
1682
1759
|
...switchToggle({
|
|
1683
1760
|
checked: config.hostNotify,
|
|
1684
1761
|
onChange: (e) => setConfig({ ...config, hostNotify: e.target.checked }),
|
|
1685
1762
|
}),
|
|
1686
1763
|
' 宿主机弹桌面通知'),
|
|
1687
|
-
h('label', { className: 'tn-meta tn-switch', title: '
|
|
1764
|
+
h('label', { className: 'tn-meta tn-switch', title: '浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,离场时宿主补位桌面通知;与总开关判定一致,任一开启即生效' },
|
|
1688
1765
|
...switchToggle({
|
|
1689
1766
|
checked: config.hostNotifyFallback,
|
|
1690
1767
|
onChange: (e) => setConfig({ ...config, hostNotifyFallback: e.target.checked }),
|
|
1691
1768
|
}),
|
|
1692
|
-
'
|
|
1693
|
-
], '由宿主进程直接弹 OS
|
|
1769
|
+
' 仅当无浏览器接收时补位'),
|
|
1770
|
+
], '由宿主进程直接弹 OS 桌面通知,服务器/浏览器全关场景可达;浏览器优先——浏览器在线时由浏览器呈现,宿主不重复弹,离场(2 秒内无在途长轮询)时宿主补位,边界情况(窗口边界与网络抖动)宁重复不漏。需点保存生效,测试页可逐条点火验证。'),
|
|
1694
1771
|
field('事件分类', h('div', { className: 'tn-pills' },
|
|
1695
1772
|
CATEGORIES.map((category) => h('span', {
|
|
1696
1773
|
className: 'tn-pill' + (config.enabled[category] ? ' tn-pill--on' : ''),
|
|
@@ -1794,6 +1871,20 @@ window.__ModuleLoader__.load({
|
|
|
1794
1871
|
' 弹窗不可用时以标题闪烁替代'),
|
|
1795
1872
|
]),
|
|
1796
1873
|
) : null,
|
|
1874
|
+
activeTab === '偏好' ? h('div', { className: 'tn-card' },
|
|
1875
|
+
h('div', { className: 'tn-card__head' },
|
|
1876
|
+
h('span', { className: 'tn-card__title' }, '会话列表'),
|
|
1877
|
+
h('span', { className: 'tn-card__sub' }, '存宿主配置,各浏览器共用'),
|
|
1878
|
+
),
|
|
1879
|
+
field('运行标记', [
|
|
1880
|
+
h('label', { className: 'tn-meta tn-switch', title: FOLD_SWITCH_TITLE },
|
|
1881
|
+
...switchToggle({
|
|
1882
|
+
checked: config.folderRunningEnabled !== false,
|
|
1883
|
+
onChange: (e) => void toggleFolderRunning(e.target.checked),
|
|
1884
|
+
}),
|
|
1885
|
+
' 有运行中会话的文件夹显示运行点'),
|
|
1886
|
+
], '侧边栏按工作区分组时,运行中的文件夹组头显示脉冲运行点并给图标着色,折叠与展开同样生效;变更刷新页面生效。'),
|
|
1887
|
+
) : null,
|
|
1797
1888
|
activeTab === '音效' ? [
|
|
1798
1889
|
h('div', { className: 'tn-card' },
|
|
1799
1890
|
h('div', { className: 'tn-card__head' },
|
|
@@ -2001,17 +2092,95 @@ window.__ModuleLoader__.load({
|
|
|
2001
2092
|
}
|
|
2002
2093
|
|
|
2003
2094
|
return {
|
|
2004
|
-
inject: ['slots'],
|
|
2095
|
+
inject: ['slots', 'sessions', 'workspaces'],
|
|
2005
2096
|
apply(ctx) {
|
|
2097
|
+
const sessions = ctx.get('sessions')
|
|
2098
|
+
const workspaces = ctx.get('workspaces')
|
|
2006
2099
|
// 样式挂载宿主文档级:通知栈在面板未打开时也要有完整样式
|
|
2007
2100
|
ctx.effect(() => {
|
|
2008
2101
|
const style = document.createElement('style')
|
|
2102
|
+
// 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
|
|
2103
|
+
style.setAttribute('data-plugin', '@mzzsfy/dsh-turn-notify')
|
|
2009
2104
|
style.textContent = CSS
|
|
2010
2105
|
document.head.appendChild(style)
|
|
2011
2106
|
return () => style.remove()
|
|
2012
2107
|
}, 'turn-notify styles')
|
|
2013
2108
|
// 激活即轮询:通知链路不依赖设置面板是否打开过
|
|
2014
2109
|
start()
|
|
2110
|
+
// 工作区文件夹运行标记:服务快照投影 + DOM 组容器下标对位,状态变化经
|
|
2111
|
+
// 订阅与 DOM 变更(合并窗口)双通道驱动;开关读宿主配置,变更刷新页面生效
|
|
2112
|
+
let foldEnabled = null
|
|
2113
|
+
ctx.effect(() => {
|
|
2114
|
+
if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return
|
|
2115
|
+
let disposed = false
|
|
2116
|
+
let timer = null
|
|
2117
|
+
function clearAll() {
|
|
2118
|
+
document.querySelectorAll('[' + FOLD_RUNNING_FLAG + ']').forEach((section) => {
|
|
2119
|
+
section.removeAttribute(FOLD_RUNNING_FLAG)
|
|
2120
|
+
})
|
|
2121
|
+
}
|
|
2122
|
+
function scan() {
|
|
2123
|
+
const sections = document.querySelectorAll(FOLD_SECTION_SELECTOR)
|
|
2124
|
+
if (sections.length === 0) {
|
|
2125
|
+
clearAll()
|
|
2126
|
+
return
|
|
2127
|
+
}
|
|
2128
|
+
// 开关未决时仅清除:终态正确且停用用户刷新页面不闪现标记
|
|
2129
|
+
if (foldEnabled === null) {
|
|
2130
|
+
clearAll()
|
|
2131
|
+
return
|
|
2132
|
+
}
|
|
2133
|
+
const state = foldEnabled
|
|
2134
|
+
? folderRunningState(sessions.list.getSnapshot(), workspaces.list.getSnapshot())
|
|
2135
|
+
: { ids: [], running: new Set(), ungrouped: false }
|
|
2136
|
+
const expected = state.ids.length
|
|
2137
|
+
// 数量不变量:组容器数 = 工作区数 或 工作区数+1(末位未分组桶);
|
|
2138
|
+
// 漂移(单列表模式 / 搜索态 / 官方结构变更)即清除标记,不误标
|
|
2139
|
+
const ungroupedLast = sections.length === expected + 1
|
|
2140
|
+
if (sections.length !== expected && !ungroupedLast) {
|
|
2141
|
+
clearAll()
|
|
2142
|
+
return
|
|
2143
|
+
}
|
|
2144
|
+
sections.forEach((section, index) => {
|
|
2145
|
+
const running = index < expected ? state.running.has(state.ids[index]) : ungroupedLast && state.ungrouped
|
|
2146
|
+
// 同值跳过:流式输出期间扫描高频,重复写属性触发无谓的 CSS 重匹配
|
|
2147
|
+
if (running === section.hasAttribute(FOLD_RUNNING_FLAG)) return
|
|
2148
|
+
if (running) section.setAttribute(FOLD_RUNNING_FLAG, '')
|
|
2149
|
+
else section.removeAttribute(FOLD_RUNNING_FLAG)
|
|
2150
|
+
})
|
|
2151
|
+
}
|
|
2152
|
+
let scheduled = false
|
|
2153
|
+
const schedule = () => {
|
|
2154
|
+
if (disposed || scheduled) return
|
|
2155
|
+
scheduled = true
|
|
2156
|
+
timer = setTimeout(() => {
|
|
2157
|
+
scheduled = false
|
|
2158
|
+
timer = null
|
|
2159
|
+
if (!disposed) scan()
|
|
2160
|
+
}, FOLD_SCAN_DELAY_MS)
|
|
2161
|
+
}
|
|
2162
|
+
api('/api/turn-notify/config')
|
|
2163
|
+
.then((payload) => {
|
|
2164
|
+
foldEnabled = payload ? payload.folderRunningEnabled !== false : true
|
|
2165
|
+
})
|
|
2166
|
+
.catch(() => {
|
|
2167
|
+
foldEnabled = true
|
|
2168
|
+
})
|
|
2169
|
+
.finally(schedule)
|
|
2170
|
+
const unsubscribeSessions = sessions.list.subscribe(schedule)
|
|
2171
|
+
const unsubscribeWorkspaces = workspaces.list.subscribe(schedule)
|
|
2172
|
+
const observer = new MutationObserver(schedule)
|
|
2173
|
+
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
2174
|
+
schedule()
|
|
2175
|
+
return () => {
|
|
2176
|
+
disposed = true
|
|
2177
|
+
if (timer !== null) clearTimeout(timer)
|
|
2178
|
+
observer.disconnect()
|
|
2179
|
+
unsubscribeSessions()
|
|
2180
|
+
unsubscribeWorkspaces()
|
|
2181
|
+
clearAll()
|
|
2182
|
+
}
|
|
2183
|
+
}, 'turn-notify folder running mark')
|
|
2015
2184
|
ctx.slots.inject('settings.section', () =>
|
|
2016
2185
|
ctx.slots.register(
|
|
2017
2186
|
{ name: 'settings.section', id: 'turn-notify', order: 41, label: '消息通知' },
|
package/src/core.mjs
CHANGED
|
@@ -499,35 +499,29 @@ const CONFIG_WEBHOOK_SCHEMES = ['http:', 'https:']
|
|
|
499
499
|
// 宿主桌面通知 spawn 超时:与 webhook 同语义,超时强杀不重试
|
|
500
500
|
export const HOST_NOTIFY_TIMEOUT_MS = 10 * 1000
|
|
501
501
|
|
|
502
|
-
//
|
|
503
|
-
// 回环来源的浏览器窗口即与宿主同机,是本机去重的判定依据
|
|
504
|
-
export function isLoopbackAddress(address) {
|
|
505
|
-
if (typeof address !== 'string' || address.length === 0) return false
|
|
506
|
-
return address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// 宿主通知决策:浏览器优先——本机浏览器在场由浏览器呈现,宿主让位(正常路径
|
|
502
|
+
// 宿主通知决策:浏览器优先——浏览器在场由浏览器呈现,宿主让位(正常路径
|
|
510
503
|
// 零重复);离场由宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与
|
|
511
504
|
// 网络抖动),宁可重复不可漏。两开关均关不弹,任一开启即启用该通道。
|
|
512
|
-
export function hostNotifyWanted({ hostNotify, hostNotifyFallback,
|
|
505
|
+
export function hostNotifyWanted({ hostNotify, hostNotifyFallback, browserPresent }) {
|
|
513
506
|
if (hostNotify !== true && hostNotifyFallback !== true) return false
|
|
514
|
-
return
|
|
507
|
+
return browserPresent !== true
|
|
515
508
|
}
|
|
516
509
|
|
|
517
|
-
//
|
|
510
|
+
// 浏览器在场判定:有在途长轮询即在场信号(无论时间戳新旧);无在途时,
|
|
518
511
|
// 最近活动在认可窗口内(长轮询刚完成、断连刚出账)仍算在场,超窗或从未在场为假
|
|
519
|
-
export function
|
|
512
|
+
export function isBrowserPresent({ inFlightPolls, lastSeenAt, now, windowMs }) {
|
|
520
513
|
if (typeof inFlightPolls === 'number' && inFlightPolls > 0) return true
|
|
521
514
|
return typeof lastSeenAt === 'number' && lastSeenAt > 0 && now - lastSeenAt <= windowMs
|
|
522
515
|
}
|
|
523
516
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
517
|
+
// 浏览器轮询判定:长轮询续传形态(带 cursor)+ fetch 请求形态 + Origin 对账。
|
|
518
|
+
// 来源不限地址:浏览器可在任意机器访问 dsh(局域网部署),在场信号看的是
|
|
519
|
+
// "有无 dsh 页面在轮询",与浏览器同不同机无关。投影为无守卫 GET,记账须排除
|
|
520
|
+
// 可伪造在场的形态:no-cors(img 跨站探活)、无 cursor 的手工探测、跨源 fetch
|
|
521
|
+
// (恶意网页,sec-fetch-mode 同为 cors 无法区分,凭 Origin 与 Host 不符识别;
|
|
522
|
+
// 同源 GET fetch 不带 Origin,无 Origin 放行,旧浏览器同样覆盖)——只有 dsh
|
|
523
|
+
// 同源页面自身能发出合格形态,伪造面与来源地址无关
|
|
524
|
+
export function isBrowserPoll({ hasCursor, secFetchMode, origin, host }) {
|
|
531
525
|
if (hasCursor !== true) return false
|
|
532
526
|
if (secFetchMode !== undefined && secFetchMode !== 'cors') return false
|
|
533
527
|
if (typeof origin === 'string' && origin.length > 0) {
|
|
@@ -641,7 +635,7 @@ export function imBoundBotIds(list) {
|
|
|
641
635
|
// 返回归一化后的补丁。
|
|
642
636
|
export function validateConfigPatch(patch) {
|
|
643
637
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
|
|
644
|
-
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback']
|
|
638
|
+
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
|
|
645
639
|
for (const key of Object.keys(patch)) {
|
|
646
640
|
if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
|
|
647
641
|
}
|
|
@@ -702,6 +696,10 @@ export function validateConfigPatch(patch) {
|
|
|
702
696
|
if (typeof patch.hostNotifyFallback !== 'boolean') return { ok: false, reason: 'hostNotifyFallback 须为布尔' }
|
|
703
697
|
next.hostNotifyFallback = patch.hostNotifyFallback
|
|
704
698
|
}
|
|
699
|
+
if ('folderRunningEnabled' in patch) {
|
|
700
|
+
if (typeof patch.folderRunningEnabled !== 'boolean') return { ok: false, reason: 'folderRunningEnabled 须为布尔' }
|
|
701
|
+
next.folderRunningEnabled = patch.folderRunningEnabled
|
|
702
|
+
}
|
|
705
703
|
if ('enabled' in patch) {
|
|
706
704
|
const enabled = patch.enabled
|
|
707
705
|
if (enabled === null || typeof enabled !== 'object' || Array.isArray(enabled)) return { ok: false, reason: 'enabled 须为对象' }
|
|
@@ -767,6 +765,7 @@ export function resolvedConfig(settings) {
|
|
|
767
765
|
suppressSubagentWake: source.suppressSubagentWake !== false,
|
|
768
766
|
hostNotify: source.hostNotify === true,
|
|
769
767
|
hostNotifyFallback: source.hostNotifyFallback === true,
|
|
768
|
+
folderRunningEnabled: source.folderRunningEnabled !== false,
|
|
770
769
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
|
|
771
770
|
soundMapping,
|
|
772
771
|
imTargets: normalizeImTargets(source.imTargets),
|
|
@@ -783,6 +782,7 @@ export function publicConfig(settings) {
|
|
|
783
782
|
suppressSubagentWake: resolved.suppressSubagentWake,
|
|
784
783
|
hostNotify: resolved.hostNotify,
|
|
785
784
|
hostNotifyFallback: resolved.hostNotifyFallback,
|
|
785
|
+
folderRunningEnabled: resolved.folderRunningEnabled,
|
|
786
786
|
enabled: resolved.enabled,
|
|
787
787
|
soundMapping: resolved.soundMapping,
|
|
788
788
|
imTargets: resolved.imTargets,
|
package/src/index.js
CHANGED
|
@@ -27,8 +27,8 @@ import {
|
|
|
27
27
|
isSubagentWakeTurn,
|
|
28
28
|
isValidImBotId,
|
|
29
29
|
hostNotifyWanted,
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
isBrowserPoll,
|
|
31
|
+
isBrowserPresent,
|
|
32
32
|
mapEventToCategory,
|
|
33
33
|
mimeOf,
|
|
34
34
|
normalizeImTargets,
|
|
@@ -62,7 +62,7 @@ const SOUNDS_DIR = join(homedir(), '.dsh', 'dsh-turn-notify', 'sounds')
|
|
|
62
62
|
const REQUEST_BODY_MAX_BYTES = 64 * 1024
|
|
63
63
|
// 长轮询挂起上限:低于客户端请求超时,保证客户端总在服务端放弃后才超时
|
|
64
64
|
const LONG_POLL_WAIT_MS = 25 * 1000
|
|
65
|
-
//
|
|
65
|
+
// 浏览器在场认可窗口:在途长轮询出账后的宽限时长,浏览器优先去重的时间边界;
|
|
66
66
|
// 窗口内事件仍视为浏览器呈现,超窗即宿主补位(宁可重复不可漏)
|
|
67
67
|
export const CLIENT_PRESENCE_WINDOW_MS = 2 * 1000
|
|
68
68
|
|
|
@@ -92,8 +92,9 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
92
92
|
minTurnDurationMs: z.number().default(MIN_TURN_DURATION_MS).description('最短回合时长(毫秒),回合结束类通知短于此不送达;AI 提问与审批请求即时送达'),
|
|
93
93
|
rootsOnly: z.boolean().default(true).description('子代理会话不通知'),
|
|
94
94
|
suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
|
|
95
|
-
hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast)
|
|
96
|
-
hostNotifyFallback: z.boolean().default(false).description('
|
|
95
|
+
hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast);浏览器优先,浏览器在场(2 秒内有在途长轮询)时由浏览器呈现宿主不重复弹,离场时宿主补位'),
|
|
96
|
+
hostNotifyFallback: z.boolean().default(false).description('宿主通知回退:浏览器在场时由浏览器呈现,离场(2 秒内无在途长轮询)时宿主补位;与总开关判定一致,任一开启即生效'),
|
|
97
|
+
folderRunningEnabled: z.boolean().default(true).description('工作区文件夹运行标记:侧边栏分组模式下,有运行中会话的文件夹组头显示运行点并给图标着色;关闭后标记整体不渲染,变更刷新页面生效'),
|
|
97
98
|
enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
|
|
98
99
|
soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
|
|
99
100
|
imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
|
|
@@ -207,10 +208,10 @@ export function apply(ctx) {
|
|
|
207
208
|
const titledSessions = new Set()
|
|
208
209
|
// 回合尾部统计:assistant/message 的 tokens 累加与回答前缀,回合结束随通知消费即清
|
|
209
210
|
const assistantTails = new Map()
|
|
210
|
-
//
|
|
211
|
+
// 浏览器在场信号:在途长轮询计数与最近活动时刻,
|
|
211
212
|
// 在场 = 有在途请求或认可窗口内有活动,宿主通知的浏览器优先去重据此判定
|
|
212
|
-
let
|
|
213
|
-
let
|
|
213
|
+
let inFlightBrowserPolls = 0
|
|
214
|
+
let lastBrowserSeenAt = 0
|
|
214
215
|
let seq = 0
|
|
215
216
|
// 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
|
|
216
217
|
let soundWriteQueue = Promise.resolve()
|
|
@@ -273,16 +274,16 @@ export function apply(ctx) {
|
|
|
273
274
|
if (routes === null || routes.indexOf('host') >= 0) deliverHostNotify(unit, settings)
|
|
274
275
|
}
|
|
275
276
|
|
|
276
|
-
//
|
|
277
|
+
// 宿主桌面通知:浏览器优先,浏览器在场由浏览器呈现,宿主让位(正常路径
|
|
277
278
|
// 零重复);离场(无在途长轮询且超认可窗口)由宿主补位;fire-and-forget,
|
|
278
279
|
// spawn 失败不影响其余通道
|
|
279
280
|
function deliverHostNotify(unit, settings) {
|
|
280
281
|
const wanted = hostNotifyWanted({
|
|
281
282
|
hostNotify: settings.hostNotify,
|
|
282
283
|
hostNotifyFallback: settings.hostNotifyFallback,
|
|
283
|
-
|
|
284
|
-
inFlightPolls:
|
|
285
|
-
lastSeenAt:
|
|
284
|
+
browserPresent: isBrowserPresent({
|
|
285
|
+
inFlightPolls: inFlightBrowserPolls,
|
|
286
|
+
lastSeenAt: lastBrowserSeenAt,
|
|
286
287
|
now: Date.now(),
|
|
287
288
|
windowMs: CLIENT_PRESENCE_WINDOW_MS,
|
|
288
289
|
}),
|
|
@@ -440,27 +441,26 @@ export function apply(ctx) {
|
|
|
440
441
|
path: '/api/turn-notify/projection',
|
|
441
442
|
handler: route('GET', {}, async (req, res) => {
|
|
442
443
|
const query = new URL(req.url, 'http://localhost').searchParams
|
|
443
|
-
//
|
|
444
|
+
// 浏览器长轮询即在场信号,宿主通知的浏览器优先去重据此判定;
|
|
444
445
|
// 记账形态收紧防伪造:裸 GET(img 探活/监控探测)与跨源 fetch 不入账。
|
|
445
446
|
// 记账期间计入在途,断连(浏览器关闭/中止)经 res close 事件即时出账,
|
|
446
447
|
// 离场盲区收敛到认可窗口
|
|
447
|
-
let
|
|
448
|
-
if (
|
|
449
|
-
remoteAddress: req.socket && req.socket.remoteAddress,
|
|
448
|
+
let releaseBrowserPoll = null
|
|
449
|
+
if (isBrowserPoll({
|
|
450
450
|
hasCursor: query.has('cursor'),
|
|
451
451
|
secFetchMode: req.headers ? req.headers['sec-fetch-mode'] : undefined,
|
|
452
452
|
origin: req.headers ? req.headers.origin : undefined,
|
|
453
453
|
host: req.headers ? req.headers.host : undefined,
|
|
454
454
|
})) {
|
|
455
|
-
|
|
455
|
+
inFlightBrowserPolls += 1
|
|
456
456
|
let settled = false
|
|
457
|
-
|
|
457
|
+
releaseBrowserPoll = () => {
|
|
458
458
|
if (settled) return
|
|
459
459
|
settled = true
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
inFlightBrowserPolls -= 1
|
|
461
|
+
lastBrowserSeenAt = Date.now()
|
|
462
462
|
}
|
|
463
|
-
res.once('close',
|
|
463
|
+
res.once('close', releaseBrowserPoll)
|
|
464
464
|
}
|
|
465
465
|
try {
|
|
466
466
|
const rawCursor = Number(query.get('cursor'))
|
|
@@ -471,7 +471,7 @@ export function apply(ctx) {
|
|
|
471
471
|
const settings = readSettings(ctx)
|
|
472
472
|
sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
|
|
473
473
|
} finally {
|
|
474
|
-
if (
|
|
474
|
+
if (releaseBrowserPoll !== null) releaseBrowserPoll()
|
|
475
475
|
}
|
|
476
476
|
}),
|
|
477
477
|
})
|