dsh-notify-yimit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +97 -0
- package/README.md +95 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +790 -0
- package/lib/index.js +716 -0
- package/lib/toast-host.ps1 +393 -0
- package/lib/typert.host.js +158 -0
- package/package.json +51 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify 宿主插件。
|
|
3
|
+
*
|
|
4
|
+
* 单一 Loader 行(见 cordis.patch.yml)挂载本模块,职责:
|
|
5
|
+
* 1. 持久化插件配置($DSH_HOME/storages/dsh-notify/config.json):总开关、通知方式
|
|
6
|
+
* (system 系统通知 / custom 自定义样式 / off 关闭,默认 off)、自定义通知的最大
|
|
7
|
+
* 同时显示数、显示时长、背景色与文字色;
|
|
8
|
+
* 2. 监听主机事件流,为每个会话维护轻量状态机并生成"通知事件":
|
|
9
|
+
* - session/event:turn/start、assistant/chunk、tool/call(含 ask_user_question
|
|
10
|
+
* 的提问内容)、turn/end(completed/error)、session/title(对话标题);
|
|
11
|
+
* - agent/status:运行位边沿(运行中/空闲的兜底信号);
|
|
12
|
+
* - approval/request:等待审批时的具体审批内容;
|
|
13
|
+
* 3. 维护未确认事件队列(客户端轮询 getState + ackEvents 消费,运行中事件按会话
|
|
14
|
+
* 节流合并,完成/出错事件总是新增);
|
|
15
|
+
* 4. 提供 notify 服务(手写 typertRemote 绑定,配合 ./typert 清单走 Typert 网关),
|
|
16
|
+
* 客户端经 remote.notify.* 读取配置/事件并回执。
|
|
17
|
+
*
|
|
18
|
+
* 零构建、零 React 依赖;仅用 ctx API 与 Node 内建能力(配置路径经
|
|
19
|
+
* @deepseek-ai/dsh-home-paths 解析,契约校验用 zod,与 dsh-cost-meter 同款姿势)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { z } from 'zod'
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
28
|
+
|
|
29
|
+
export const name = 'dsh-notify'
|
|
30
|
+
|
|
31
|
+
// ── 配置 ───────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** 通知类型清单(与事件 kind 一一对应)。 */
|
|
34
|
+
const KIND_KEYS = Object.freeze(['completed', 'error', 'running', 'approval', 'question'])
|
|
35
|
+
|
|
36
|
+
/** 默认配置(首次启动;之后持久化副本优先)。 */
|
|
37
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
38
|
+
enabled: false, // 插件总开关
|
|
39
|
+
mode: 'off', // 通知方式:system(Windows 系统通知)| custom(自定义样式)| off(关闭)
|
|
40
|
+
maxVisible: 3, // 自定义通知最大同时显示数量
|
|
41
|
+
durationMs: 8000, // 自定义通知显示时长(毫秒)
|
|
42
|
+
browser: '', // 跳转会话使用的浏览器可执行文件路径;空 = 系统默认浏览器
|
|
43
|
+
bgColor: '#20272f', // 自定义通知全局默认背景色(旧配置兼容/顶层默认)
|
|
44
|
+
textColor: '#e6edf3', // 自定义通知全局默认文字色(旧配置兼容/顶层默认)
|
|
45
|
+
// 每种通知类型的背景/文字色;未显式配置时:旧配置(顶层 bgColor/textColor)迁移使用顶层值,
|
|
46
|
+
// 全新默认则使用下方的类型区分色。
|
|
47
|
+
colors: {
|
|
48
|
+
completed: { bg: '#1f5138', fg: '#e6f4ec' }, // 绿:完成
|
|
49
|
+
error: { bg: '#5c1f24', fg: '#fbe9ea' }, // 红:出错
|
|
50
|
+
running: { bg: '#203a5c', fg: '#e8f0fb' }, // 蓝:运行中
|
|
51
|
+
approval: { bg: '#4a3a12', fg: '#f7efd8' }, // 黄:待审批
|
|
52
|
+
question: { bg: '#3d2a55', fg: '#f0e8fa' }, // 紫:待回答
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
|
|
57
|
+
const isHex = (v) => typeof v === 'string' && HEX_RE.test(v)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 配置合并校验:未知键忽略,非法值回退默认(设置页只发合法 patch)。
|
|
61
|
+
* colors 采用"候选即结果"策略:输入中的 colors[kind].bg/fg 合法即采用;
|
|
62
|
+
* 缺失时回退每类型默认色(旧配置的顶层色迁移在 openConfig 加载时一次性完成)。
|
|
63
|
+
*/
|
|
64
|
+
function normalizeConfig(input) {
|
|
65
|
+
const src = input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {}
|
|
66
|
+
const out = { ...DEFAULT_CONFIG }
|
|
67
|
+
if (typeof src.enabled === 'boolean') out.enabled = src.enabled
|
|
68
|
+
if (src.mode === 'system' || src.mode === 'custom' || src.mode === 'off') out.mode = src.mode
|
|
69
|
+
if (typeof src.maxVisible === 'number' && Number.isFinite(src.maxVisible)) out.maxVisible = Math.max(1, Math.min(20, Math.floor(src.maxVisible)))
|
|
70
|
+
if (typeof src.durationMs === 'number' && Number.isFinite(src.durationMs)) out.durationMs = Math.max(1000, Math.min(300000, Math.floor(src.durationMs)))
|
|
71
|
+
if (typeof src.browser === 'string') out.browser = src.browser
|
|
72
|
+
if (isHex(src.bgColor)) out.bgColor = src.bgColor
|
|
73
|
+
if (isHex(src.textColor)) out.textColor = src.textColor
|
|
74
|
+
const srcColors = src.colors !== null && typeof src.colors === 'object' && !Array.isArray(src.colors) ? src.colors : {}
|
|
75
|
+
out.colors = {}
|
|
76
|
+
for (const kind of KIND_KEYS) {
|
|
77
|
+
const entry = srcColors[kind]
|
|
78
|
+
const def = DEFAULT_CONFIG.colors[kind]
|
|
79
|
+
out.colors[kind] = {
|
|
80
|
+
bg: isHex(entry?.bg) ? entry.bg : def.bg,
|
|
81
|
+
fg: isHex(entry?.fg) ? entry.fg : def.fg,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 旧配置(仅有顶层 bgColor/textColor、无 colors)一次性迁移:colors 全用顶层色。 */
|
|
88
|
+
function migrateLegacyColors(input) {
|
|
89
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) return input
|
|
90
|
+
if (input.colors !== null && typeof input.colors === 'object') return input
|
|
91
|
+
if (!isHex(input.bgColor) && !isHex(input.textColor)) return input
|
|
92
|
+
const colors = {}
|
|
93
|
+
for (const kind of KIND_KEYS) colors[kind] = { bg: input.bgColor, fg: input.textColor }
|
|
94
|
+
return { ...input, colors }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 深合并配置补丁:colors 按类型浅合并(只 patch 变更的 bg/fg,保留其余类型与字段)。 */
|
|
98
|
+
function mergeConfigPatch(base, patch) {
|
|
99
|
+
const out = { ...base, ...patch }
|
|
100
|
+
if (patch !== null && typeof patch === 'object' && patch.colors !== null && typeof patch.colors === 'object' && !Array.isArray(patch.colors)) {
|
|
101
|
+
const baseColors = base.colors !== null && typeof base.colors === 'object' ? base.colors : {}
|
|
102
|
+
const next = {}
|
|
103
|
+
for (const kind of KIND_KEYS) next[kind] = { ...(baseColors[kind] ?? {}), ...(patch.colors[kind] ?? {}) }
|
|
104
|
+
out.colors = next
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 配置存储:$DSH_HOME/storages/dsh-notify/config.json(临时文件 + 原子重命名,写防抖)。 */
|
|
110
|
+
function openConfig() {
|
|
111
|
+
const home = resolveDshHome()
|
|
112
|
+
const dir = path.join(home, 'storages', 'dsh-notify')
|
|
113
|
+
const file = path.join(dir, 'config.json')
|
|
114
|
+
let loaded = null
|
|
115
|
+
try {
|
|
116
|
+
loaded = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
117
|
+
} catch {
|
|
118
|
+
loaded = null
|
|
119
|
+
}
|
|
120
|
+
let config = normalizeConfig(migrateLegacyColors(loaded))
|
|
121
|
+
let timer = null
|
|
122
|
+
const persist = () => {
|
|
123
|
+
if (timer !== null) return
|
|
124
|
+
timer = setTimeout(() => {
|
|
125
|
+
timer = null
|
|
126
|
+
try {
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
128
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
129
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8')
|
|
130
|
+
fs.renameSync(tmp, file)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// 配置写盘失败不应影响插件主流程,仅记录。
|
|
133
|
+
console.warn(`[dsh-notify] 配置写盘失败: ${String(error)}`)
|
|
134
|
+
}
|
|
135
|
+
}, 200)
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
get: () => config,
|
|
139
|
+
set(patch) {
|
|
140
|
+
config = normalizeConfig(mergeConfigPatch(config, patch))
|
|
141
|
+
persist()
|
|
142
|
+
return config
|
|
143
|
+
},
|
|
144
|
+
close() {
|
|
145
|
+
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
148
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
149
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8')
|
|
150
|
+
fs.renameSync(tmp, file)
|
|
151
|
+
} catch {
|
|
152
|
+
// 同上:关闭时尽力落盘。
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 通知事件队列 ───────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const MAX_QUEUE = 500 // 未确认事件上限(防堆积)
|
|
161
|
+
const RUNNING_THROTTLE_MS = 400 // 运行中事件按会话节流合并(更快响应,实时性优先)
|
|
162
|
+
const KIND_DEBOUNCE_MS = 2000 // 非运行中事件同会话同类型防抖窗口
|
|
163
|
+
const TOAST_GAP = 12 // 浮窗间距(像素);堆叠按各窗口回传的真实高度累加,自适应
|
|
164
|
+
const TEXT_MAX = 300 // 事件文本上限
|
|
165
|
+
|
|
166
|
+
function clip(text) {
|
|
167
|
+
const s = String(text ?? '')
|
|
168
|
+
return s.length > TEXT_MAX ? `${s.slice(0, TEXT_MAX)}…` : s
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 会话标题:优先会话日志里的 session/title,其次 cwd 目录名,最后会话 id。 */
|
|
172
|
+
function titleOf(session) {
|
|
173
|
+
const events = session?.events
|
|
174
|
+
if (Array.isArray(events)) {
|
|
175
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
176
|
+
const event = events[i]
|
|
177
|
+
if (event?.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.length > 0) {
|
|
178
|
+
return event.data.title
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const cwd = session?.header?.cwd
|
|
183
|
+
if (typeof cwd === 'string' && cwd.length > 0) {
|
|
184
|
+
const base = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop()
|
|
185
|
+
if (typeof base === 'string' && base.length > 0) return base
|
|
186
|
+
}
|
|
187
|
+
return session?.id ?? ''
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── 浏览器探测(跳转会话时选择用哪个浏览器打开) ─────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** 常见 Windows 浏览器安装候选路径(按 64/32 位与用户级安装展开)。 */
|
|
193
|
+
function browserCandidates() {
|
|
194
|
+
const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
|
|
195
|
+
const pfx86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
196
|
+
const local = process.env.LOCALAPPDATA ?? ''
|
|
197
|
+
return [
|
|
198
|
+
{ id: 'edge', name: 'Microsoft Edge', paths: [`${pfx86}\\Microsoft\\Edge\\Application\\msedge.exe`, `${pf}\\Microsoft\\Edge\\Application\\msedge.exe`] },
|
|
199
|
+
{ id: 'chrome', name: 'Google Chrome', paths: [`${pf}\\Google\\Chrome\\Application\\chrome.exe`, `${pfx86}\\Google\\Chrome\\Application\\chrome.exe`, local ? `${local}\\Google\\Chrome\\Application\\chrome.exe` : ''] },
|
|
200
|
+
{ id: 'firefox', name: 'Mozilla Firefox', paths: [`${pf}\\Mozilla Firefox\\firefox.exe`, `${pfx86}\\Mozilla Firefox\\firefox.exe`] },
|
|
201
|
+
{ id: 'brave', name: 'Brave', paths: [`${pf}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`, local ? `${local}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe` : ''] },
|
|
202
|
+
{ id: 'opera', name: 'Opera', paths: [local ? `${local}\\Programs\\Opera\\opera.exe` : '', `${pf}\\Opera\\launcher.exe`] },
|
|
203
|
+
{ id: 'vivaldi', name: 'Vivaldi', paths: [local ? `${local}\\Vivaldi\\Application\\vivaldi.exe` : '', `${pf}\\Vivaldi\\Application\\vivaldi.exe`] },
|
|
204
|
+
{ id: '360se', name: '360 安全浏览器', paths: [`${pfx86}\\360\\360se6\\Application\\360se.exe`] },
|
|
205
|
+
{ id: '360chrome', name: '360 极速浏览器', paths: [`${pfx86}\\360\\360Chrome\\Chrome\\Application\\360chrome.exe`] },
|
|
206
|
+
{ id: 'qqbrowser', name: 'QQ 浏览器', paths: [`${pfx86}\\Tencent\\QQBrowser\\QQBrowser.exe`, `${pf}\\Tencent\\QQBrowser\\QQBrowser.exe`] },
|
|
207
|
+
{ id: 'sogou', name: '搜狗浏览器', paths: [`${pfx86}\\SogouExplorer\\SogouExplorer.exe`] },
|
|
208
|
+
{ id: 'liebao', name: '猎豹浏览器', paths: [`${pfx86}\\liebao\\liebao.exe`] },
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 探测本机已安装浏览器:返回 [{ id, name, path }](按候选顺序,路径存在即收录)。 */
|
|
213
|
+
function detectBrowsers() {
|
|
214
|
+
const found = []
|
|
215
|
+
for (const candidate of browserCandidates()) {
|
|
216
|
+
const path = (candidate.paths ?? []).find((p) => typeof p === 'string' && p.length > 0 && fs.existsSync(p))
|
|
217
|
+
if (path !== undefined) found.push({ id: candidate.id, name: candidate.name, path })
|
|
218
|
+
}
|
|
219
|
+
return found
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 插件主体 ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export function apply(ctx) {
|
|
225
|
+
const store = openConfig()
|
|
226
|
+
ctx.effect(() => () => store.close(), 'dsh-notify: config close')
|
|
227
|
+
|
|
228
|
+
/** 每会话状态:标题、状态位、最近一次运行中事件(用于节流合并)、等待类型、防抖时间戳。 */
|
|
229
|
+
const states = new Map()
|
|
230
|
+
const stateOf = (sessionId) => {
|
|
231
|
+
let st = states.get(sessionId)
|
|
232
|
+
if (st === undefined) {
|
|
233
|
+
st = { title: '', status: 'idle', lastRunningId: null, lastRunningAt: 0, lastRunningText: null, waitingKind: null, lastFired: {} }
|
|
234
|
+
states.set(sessionId, st)
|
|
235
|
+
}
|
|
236
|
+
return st
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 未确认事件队列(升序 id)。 */
|
|
240
|
+
const queue = []
|
|
241
|
+
let nextId = 1
|
|
242
|
+
|
|
243
|
+
// ── 桌面自定义通知浮窗(常驻 PowerShell 宿主 + WPF,不依赖浏览器) ──────────
|
|
244
|
+
// custom 模式下通知直接弹桌面上浮窗(显示即消费,不入队);system 模式走浏览器
|
|
245
|
+
// Web Notification(事件入队由客户端消费)。
|
|
246
|
+
// 架构:插件加载时启动一个常驻 powershell 宿主进程(toast-host.ps1),所有浮窗都在
|
|
247
|
+
// 该进程内创建(WPF 只加载一次)。宿主经 stdin 收一行一个 JSON 命令、经 stdout 回传
|
|
248
|
+
// pos/exit 报告——通知创建从 ~1s 冷启动降到 ~10ms,并消灭 ctl/pos 文件轮询。
|
|
249
|
+
|
|
250
|
+
const TOAST_HOST_SCRIPT = fileURLToPath(new URL('./toast-host.ps1', import.meta.url))
|
|
251
|
+
const activeToasts = new Map() // 键 → { key, instance, meta: {left,top,height,hwnd,workBottom} | null, offsetY }
|
|
252
|
+
const toastOrder = [] // 浮窗键序(弹出顺序 = 从底到顶堆叠)
|
|
253
|
+
const pendingClose = new Set() // 已发 close 命令、等待淡出退出的浮窗键
|
|
254
|
+
/** 屏幕底部基准线(工作区底边):由浮窗回传的准确 workBottom 更新,供自适应堆叠。 */
|
|
255
|
+
let globalWorkBottom = null
|
|
256
|
+
/** 浮窗实例序号:同 key 的新旧窗口用 instance 区分(旧窗 exit 回传不会误删新窗)。 */
|
|
257
|
+
let toastSeq = 1
|
|
258
|
+
|
|
259
|
+
// ── 常驻宿主进程 ─────────────────────────────────────────────────────────
|
|
260
|
+
let host = null
|
|
261
|
+
let hostAlive = false
|
|
262
|
+
function spawnHost() {
|
|
263
|
+
try {
|
|
264
|
+
// 测试钩子:单测通过 globalThis.__DSH_NOTIFY_TEST_SPAWN__ 注入假的 spawn,避免真实进程。
|
|
265
|
+
const spawnFn = typeof globalThis.__DSH_NOTIFY_TEST_SPAWN__ === 'function'
|
|
266
|
+
? globalThis.__DSH_NOTIFY_TEST_SPAWN__
|
|
267
|
+
: spawn
|
|
268
|
+
host = spawnFn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', TOAST_HOST_SCRIPT], {
|
|
269
|
+
windowsHide: true,
|
|
270
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
271
|
+
})
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.warn(`[dsh-notify] 浮窗宿主启动失败: ${String(error)}`)
|
|
274
|
+
host = null
|
|
275
|
+
hostAlive = false
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
hostAlive = true
|
|
279
|
+
// 宿主死亡/管道破裂时静默,避免未捕获的 error 事件击穿插件。
|
|
280
|
+
host.stdin.on('error', () => {})
|
|
281
|
+
host.stdout.on('error', () => {})
|
|
282
|
+
host.on('error', () => { hostAlive = false })
|
|
283
|
+
host.stdout.setEncoding('utf8')
|
|
284
|
+
let buf = ''
|
|
285
|
+
host.stdout.on('data', (d) => {
|
|
286
|
+
buf += String(d)
|
|
287
|
+
let nl
|
|
288
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
289
|
+
const line = buf.slice(0, nl).trim()
|
|
290
|
+
buf = buf.slice(nl + 1)
|
|
291
|
+
if (line.length === 0) continue
|
|
292
|
+
let msg
|
|
293
|
+
try { msg = JSON.parse(line) } catch { console.warn(`[dsh-notify] 浮窗回传解析失败: ${line}`); continue }
|
|
294
|
+
if (msg === null || typeof msg !== 'object') continue
|
|
295
|
+
if (msg.type === 'pos' && typeof msg.key === 'string') {
|
|
296
|
+
const rec = activeToasts.get(msg.key)
|
|
297
|
+
if (rec !== undefined && rec.instance === msg.instance) {
|
|
298
|
+
setToastMeta(rec, { left: msg.left, top: msg.top, height: msg.height, hwnd: msg.hwnd, workBottom: msg.workBottom })
|
|
299
|
+
reflowToasts()
|
|
300
|
+
}
|
|
301
|
+
} else if (msg.type === 'exit' && typeof msg.key === 'string') {
|
|
302
|
+
const rec = activeToasts.get(msg.key)
|
|
303
|
+
if (rec !== undefined && rec.instance === msg.instance) {
|
|
304
|
+
pendingClose.delete(msg.key)
|
|
305
|
+
activeToasts.delete(msg.key)
|
|
306
|
+
const idx = toastOrder.indexOf(msg.key)
|
|
307
|
+
if (idx !== -1) toastOrder.splice(idx, 1)
|
|
308
|
+
reflowToasts() // 窗口真正退出后才重排,上方通知收到 move 命令曲线下落
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
host.on('exit', () => {
|
|
314
|
+
hostAlive = false
|
|
315
|
+
// 宿主退出:全部浮窗随之消失,清理状态(下次 show 时按需重新拉起)。
|
|
316
|
+
activeToasts.clear()
|
|
317
|
+
toastOrder.length = 0
|
|
318
|
+
pendingClose.clear()
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 下发一条命令到常驻宿主(stdin,一行 JSON)。 */
|
|
323
|
+
function sendCmd(payload) {
|
|
324
|
+
if (host === null || !hostAlive) return false
|
|
325
|
+
try {
|
|
326
|
+
host.stdin.write(`${JSON.stringify(payload)}\n`)
|
|
327
|
+
return true
|
|
328
|
+
} catch { return false }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 宿主不在时重新拉起(插件加载时已预热一次;意外退出后按需恢复)。 */
|
|
332
|
+
function ensureHost() {
|
|
333
|
+
if (host !== null && hostAlive) return true
|
|
334
|
+
spawnHost()
|
|
335
|
+
return host !== null && hostAlive
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 优雅关闭:发 close 命令让窗口淡出;窗口退出后由 exit 回传触发带动画的重排。
|
|
339
|
+
* 附带 1.5s 兜底(重发 close + 强制清理),确保堆叠序列绝不泄漏。 */
|
|
340
|
+
function dismissToast(key) {
|
|
341
|
+
if (pendingClose.has(key)) return
|
|
342
|
+
const rec = activeToasts.get(key)
|
|
343
|
+
if (rec === undefined) return
|
|
344
|
+
pendingClose.add(key)
|
|
345
|
+
sendCmd({ cmd: 'close', key })
|
|
346
|
+
setTimeout(() => {
|
|
347
|
+
if (activeToasts.get(key) === rec) {
|
|
348
|
+
sendCmd({ cmd: 'close', key })
|
|
349
|
+
pendingClose.delete(key)
|
|
350
|
+
activeToasts.delete(key)
|
|
351
|
+
const idx = toastOrder.indexOf(key)
|
|
352
|
+
if (idx !== -1) toastOrder.splice(idx, 1)
|
|
353
|
+
reflowToasts()
|
|
354
|
+
} else {
|
|
355
|
+
pendingClose.delete(key)
|
|
356
|
+
}
|
|
357
|
+
}, 1500).unref?.()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 重排浮窗:从底部按各窗口回传的真实高度逐个向上累加(12px 间距),
|
|
361
|
+
* 下发 move 命令让窗口自带曲线移动。 */
|
|
362
|
+
function reflowToasts() {
|
|
363
|
+
if (globalWorkBottom === null) return
|
|
364
|
+
let anchor = globalWorkBottom - 20 // 底部留边
|
|
365
|
+
for (let i = 0; i < toastOrder.length; i += 1) {
|
|
366
|
+
const rec = activeToasts.get(toastOrder[i])
|
|
367
|
+
if (rec === undefined) continue
|
|
368
|
+
// meta 未回传的窗口用默认高度 130 占位推进(不下发命令,避免基于估算高度的错误移动;
|
|
369
|
+
// 其初始位置由 offsetY 估算保证);meta 到达后 reflow 精修。
|
|
370
|
+
const height = rec.meta?.height ?? 130
|
|
371
|
+
const newTop = Math.round(anchor - height)
|
|
372
|
+
if (rec.meta !== null && newTop !== rec.meta.top) {
|
|
373
|
+
rec.meta.top = newTop
|
|
374
|
+
sendCmd({ cmd: 'move', key: rec.key, top: newTop }) // 窗口以曲线自行移动
|
|
375
|
+
}
|
|
376
|
+
anchor = newTop - TOAST_GAP
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 设置一个浮窗的 meta;用浮窗回传的准确 workBottom 更新全局基准线。 */
|
|
381
|
+
function setToastMeta(rec, meta) {
|
|
382
|
+
rec.meta = meta
|
|
383
|
+
if (Number.isFinite(meta.workBottom)) globalWorkBottom = meta.workBottom
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const baseUrlOf = () => {
|
|
387
|
+
const ws = ctx.get('webServer')
|
|
388
|
+
return `http://127.0.0.1:${ws !== null && ws !== undefined && typeof ws.port === 'number' ? ws.port : 3080}`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function showDesktopToast(cfg, ev) {
|
|
392
|
+
const maxVisible = Math.max(1, Math.min(20, Math.floor(Number(cfg.maxVisible) || 3)))
|
|
393
|
+
// 状态性浮窗键(运行中/待审批/待回答):同会话同类型只有一个,刷新内容实现实时更新。
|
|
394
|
+
const stickyKind = ev.kind === 'running' || ev.kind === 'approval' || ev.kind === 'question'
|
|
395
|
+
const key = stickyKind ? `${ev.kind}:${ev.sessionId}` : `${ev.kind}:${ev.sessionId}:${ev.at}`
|
|
396
|
+
// 状态性浮窗已在屏且未在淡出:原地更新文本(text 命令)——不 kill、不重弹,
|
|
397
|
+
// 消灭运行中通知的闪烁与二次延迟;正在淡出的旧窗除外(直接走重弹路径替换)。
|
|
398
|
+
let replaceIndex = -1
|
|
399
|
+
if (stickyKind) {
|
|
400
|
+
const prev = activeToasts.get(key)
|
|
401
|
+
if (prev !== undefined && !pendingClose.has(key)) {
|
|
402
|
+
sendCmd({ cmd: 'text', key, text: clip(ev.text) })
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
if (prev !== undefined) {
|
|
406
|
+
// 旧窗正在淡出:从堆叠序列移除;exit 回传时 instance 不匹配,不会误删新窗。
|
|
407
|
+
replaceIndex = toastOrder.indexOf(key)
|
|
408
|
+
if (replaceIndex !== -1) toastOrder.splice(replaceIndex, 1)
|
|
409
|
+
activeToasts.delete(key)
|
|
410
|
+
pendingClose.delete(key)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// 并发上限:超出时对最先弹出的窗口发 close 优雅淡出(不立即删除,由 exit 回传收尾;
|
|
414
|
+
// 计算活跃数时排除已在淡出中的窗口)。
|
|
415
|
+
let guard = 0
|
|
416
|
+
while (activeToasts.size - pendingClose.size >= maxVisible && guard++ < 64) {
|
|
417
|
+
let first = null
|
|
418
|
+
for (const k of activeToasts.keys()) {
|
|
419
|
+
if (pendingClose.has(k)) continue
|
|
420
|
+
if (stickyKind && !/^(running|approval|question):/.test(k)) { first = k; break }
|
|
421
|
+
if (first === null) first = k
|
|
422
|
+
}
|
|
423
|
+
if (first === null) break
|
|
424
|
+
dismissToast(first)
|
|
425
|
+
}
|
|
426
|
+
const colors = cfg.colors !== null && typeof cfg.colors === 'object' ? cfg.colors[ev.kind] : undefined
|
|
427
|
+
// 堆叠位置:全新通知插到底部(最新在底);替换场景插回原槽位(保持原生成时间顺序)。
|
|
428
|
+
// 弹窗偏移按 insertIndex 之前窗口的真实高度估算(meta 回传后由 reflowToasts 精确校正)。
|
|
429
|
+
if (replaceIndex === -1) {
|
|
430
|
+
toastOrder.unshift(key)
|
|
431
|
+
} else {
|
|
432
|
+
toastOrder.splice(replaceIndex, 0, key)
|
|
433
|
+
}
|
|
434
|
+
const insertIndex = replaceIndex !== -1 ? replaceIndex : 0
|
|
435
|
+
let offsetY = 0
|
|
436
|
+
for (let i = 0; i < insertIndex; i += 1) {
|
|
437
|
+
const r = activeToasts.get(toastOrder[i])
|
|
438
|
+
offsetY += (r?.meta?.height ?? 130) + TOAST_GAP
|
|
439
|
+
}
|
|
440
|
+
const payload = {
|
|
441
|
+
cmd: 'show',
|
|
442
|
+
key,
|
|
443
|
+
instance: toastSeq++,
|
|
444
|
+
title: String(ev.title || ev.sessionId),
|
|
445
|
+
text: clip(ev.text),
|
|
446
|
+
bg: colors?.bg ?? cfg.bgColor ?? '#20272f',
|
|
447
|
+
fg: colors?.fg ?? cfg.textColor ?? '#e6edf3',
|
|
448
|
+
durationSec: Math.max(1, Math.round((Number(cfg.durationMs) || 8000) / 1000)),
|
|
449
|
+
sticky: stickyKind,
|
|
450
|
+
offsetY,
|
|
451
|
+
sessionId: String(ev.sessionId),
|
|
452
|
+
baseUrl: baseUrlOf(),
|
|
453
|
+
winTitle: `dsh-notify-${key}`,
|
|
454
|
+
}
|
|
455
|
+
if (typeof cfg.browser === 'string' && cfg.browser.length > 0) payload.browserPath = cfg.browser
|
|
456
|
+
if (!ensureHost()) return
|
|
457
|
+
if (!sendCmd(payload)) return
|
|
458
|
+
activeToasts.set(key, { key, instance: payload.instance, meta: null, offsetY })
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 关闭一个状态性浮窗(状态结束时优雅淡出;不存在的键为 no-op)。 */
|
|
462
|
+
function closeToast(kind, sessionId) {
|
|
463
|
+
dismissToast(`${kind}:${sessionId}`)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 卸载/退出时关闭常驻宿主与全部浮窗。
|
|
467
|
+
ctx.effect(() => () => {
|
|
468
|
+
if (host !== null) {
|
|
469
|
+
try { sendCmd({ cmd: 'shutdown' }) } catch { /* 忽略 */ }
|
|
470
|
+
try { host.kill() } catch { /* 已退出 */ }
|
|
471
|
+
}
|
|
472
|
+
activeToasts.clear()
|
|
473
|
+
toastOrder.length = 0
|
|
474
|
+
pendingClose.clear()
|
|
475
|
+
}, 'dsh-notify: toast cleanup')
|
|
476
|
+
|
|
477
|
+
// 插件加载时预热一次宿主:把 ~1s 的 WPF 冷启动变成一次性成本,之后每个通知近实时。
|
|
478
|
+
spawnHost()
|
|
479
|
+
|
|
480
|
+
const push = (sessionId, kind, text) => {
|
|
481
|
+
const st = states.get(sessionId)
|
|
482
|
+
if (st === undefined) return
|
|
483
|
+
const at = Date.now()
|
|
484
|
+
const cfg = store.get()
|
|
485
|
+
const desktop = cfg.enabled === true && cfg.mode === 'custom'
|
|
486
|
+
if (kind === 'running') {
|
|
487
|
+
// 运行中事件节流合并:同会话 RUNNING_THROTTLE_MS 内已有运行中事件。
|
|
488
|
+
if (st.lastRunningAt !== 0 && at - st.lastRunningAt < RUNNING_THROTTLE_MS && st.lastRunningId !== null) {
|
|
489
|
+
if (desktop) {
|
|
490
|
+
// 桌面模式:仅当活动文本变化时替换浮窗(实时内容更新);相同文本忽略防刷屏。
|
|
491
|
+
if (st.lastRunningText !== clip(text)) {
|
|
492
|
+
st.lastRunningText = clip(text)
|
|
493
|
+
showDesktopToast(cfg, { sessionId, title: st.title, kind, text: clip(text), at })
|
|
494
|
+
}
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const entry = queue.find((item) => item.id === st.lastRunningId)
|
|
498
|
+
if (entry !== undefined) {
|
|
499
|
+
entry.text = clip(text)
|
|
500
|
+
entry.at = at
|
|
501
|
+
}
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
st.lastRunningAt = at
|
|
505
|
+
st.lastRunningText = clip(text)
|
|
506
|
+
if (desktop) {
|
|
507
|
+
// 桌面模式:替换同会话运行中浮窗,实现实时内容更新。
|
|
508
|
+
showDesktopToast(cfg, { sessionId, title: st.title, kind, text: clip(text), at })
|
|
509
|
+
st.lastRunningId = `desktop:${at}`
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const entry = { id: String(nextId++), sessionId, title: st.title, kind, text: clip(text), activity: clip(text), at }
|
|
513
|
+
st.lastRunningId = entry.id
|
|
514
|
+
queue.push(entry)
|
|
515
|
+
trim()
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
// 完成 / 出错 / 审批 / 问答:同会话同类型防抖(KIND_DEBOUNCE_MS 内不重复)。
|
|
519
|
+
// 审批/问答是状态性通知(sticky,在屏即原地更新文本):不做防抖,防止 2s 内多条
|
|
520
|
+
// 审批/提问事件被静默吞掉——等待状态必须让用户看得到(替换即更新)。
|
|
521
|
+
if (st.lastFired === undefined) st.lastFired = {}
|
|
522
|
+
const isSticky = kind === 'approval' || kind === 'question'
|
|
523
|
+
if (!isSticky && at - (st.lastFired[kind] ?? 0) < KIND_DEBOUNCE_MS) return
|
|
524
|
+
st.lastFired[kind] = at
|
|
525
|
+
if (desktop) {
|
|
526
|
+
showDesktopToast(cfg, { sessionId, title: st.title, kind, text: clip(text), at })
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
const stale = queue.findIndex((item) => item.sessionId === sessionId && item.kind === kind)
|
|
530
|
+
if (stale !== -1) queue.splice(stale, 1)
|
|
531
|
+
const entry = { id: String(nextId++), sessionId, title: st.title, kind, text: clip(text), at }
|
|
532
|
+
queue.push(entry)
|
|
533
|
+
trim()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function trim() {
|
|
537
|
+
while (queue.length > MAX_QUEUE) queue.shift()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── 会话事件流:状态机与通知事件 ─────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
ctx.on('session/event', (session, event) => {
|
|
543
|
+
if (session === undefined || session === null || event === undefined || event === null) return
|
|
544
|
+
const id = session.id
|
|
545
|
+
const st = stateOf(id)
|
|
546
|
+
// 首次接触该会话时回退标题(会话日志中可能尚无 session/title 事件)。
|
|
547
|
+
if (st.title.length === 0) st.title = titleOf(session)
|
|
548
|
+
switch (event.type) {
|
|
549
|
+
case 'session/title': {
|
|
550
|
+
if (typeof event.data?.title === 'string' && event.data.title.length > 0) {
|
|
551
|
+
st.title = event.data.title
|
|
552
|
+
// 已入队事件补标题(通常未入队,防御)。
|
|
553
|
+
for (const item of queue) if (item.sessionId === id && item.title.length === 0) item.title = st.title
|
|
554
|
+
}
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
case 'turn/start':
|
|
558
|
+
st.status = 'running'
|
|
559
|
+
st.activity = '开始处理'
|
|
560
|
+
push(id, 'running', st.activity)
|
|
561
|
+
return
|
|
562
|
+
case 'step/start':
|
|
563
|
+
st.status = 'running'
|
|
564
|
+
st.activity = '正在思考…'
|
|
565
|
+
push(id, 'running', st.activity)
|
|
566
|
+
return
|
|
567
|
+
case 'assistant/chunk': {
|
|
568
|
+
const chunk = event.data?.chunk
|
|
569
|
+
if (chunk === null || typeof chunk !== 'object') return
|
|
570
|
+
if (chunk.type === 'reasoning' && typeof chunk.text === 'string' && chunk.text.length > 0) {
|
|
571
|
+
st.activity = '正在思考…'
|
|
572
|
+
push(id, 'running', st.activity)
|
|
573
|
+
} else if (chunk.type === 'text' && typeof chunk.text === 'string' && chunk.text.length > 0) {
|
|
574
|
+
st.activity = '正在生成回复…'
|
|
575
|
+
push(id, 'running', st.activity)
|
|
576
|
+
}
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
case 'tool/call': {
|
|
580
|
+
const data = event.data
|
|
581
|
+
if (data === null || typeof data !== 'object') return
|
|
582
|
+
const toolName = typeof data.name === 'string' ? data.name : ''
|
|
583
|
+
st.activity = `正在执行 ${toolName}`
|
|
584
|
+
push(id, 'running', st.activity)
|
|
585
|
+
// ask_user_question 工具:提问内容即"等待回答"通知文本(状态性浮窗,回答前不消失)。
|
|
586
|
+
if (toolName === 'ask_user_question' && typeof data.arguments === 'string') {
|
|
587
|
+
try {
|
|
588
|
+
const args = JSON.parse(data.arguments)
|
|
589
|
+
const questions = Array.isArray(args?.questions) ? args.questions : []
|
|
590
|
+
const first = questions.find((q) => q !== null && typeof q === 'object' && typeof q.question === 'string')
|
|
591
|
+
if (first !== undefined) {
|
|
592
|
+
st.status = 'waiting'
|
|
593
|
+
st.waitingKind = 'question'
|
|
594
|
+
push(id, 'question', first.question)
|
|
595
|
+
}
|
|
596
|
+
} catch {
|
|
597
|
+
// arguments 非 JSON:跳过问答解析。
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
case 'tool/result':
|
|
603
|
+
// 工具结果返回:问答/审批结束,关闭对应状态性浮窗并恢复运行中。
|
|
604
|
+
if (st.waitingKind === 'question') {
|
|
605
|
+
closeToast('question', id)
|
|
606
|
+
st.waitingKind = null
|
|
607
|
+
st.status = 'running'
|
|
608
|
+
st.activity = '继续处理…'
|
|
609
|
+
push(id, 'running', st.activity)
|
|
610
|
+
} else if (st.status === 'waiting') {
|
|
611
|
+
st.status = 'running'
|
|
612
|
+
st.activity = '继续处理…'
|
|
613
|
+
push(id, 'running', st.activity)
|
|
614
|
+
}
|
|
615
|
+
return
|
|
616
|
+
case 'approval/decided':
|
|
617
|
+
// 审批已决定:关闭待审批状态性浮窗。
|
|
618
|
+
closeToast('approval', id)
|
|
619
|
+
if (st.waitingKind === 'approval') st.waitingKind = null
|
|
620
|
+
return
|
|
621
|
+
case 'turn/end': {
|
|
622
|
+
const reason = event.data?.reason
|
|
623
|
+
const kind = reason !== null && typeof reason === 'object' ? reason.kind : undefined
|
|
624
|
+
st.status = 'idle'
|
|
625
|
+
st.activity = ''
|
|
626
|
+
st.lastRunningAt = 0
|
|
627
|
+
st.lastRunningId = null
|
|
628
|
+
st.lastRunningText = null
|
|
629
|
+
st.waitingKind = null
|
|
630
|
+
// 任务结束:关闭运行中状态性浮窗。
|
|
631
|
+
closeToast('running', id)
|
|
632
|
+
if (kind === 'completed') {
|
|
633
|
+
push(id, 'completed', '任务已完成')
|
|
634
|
+
} else if (kind === 'error') {
|
|
635
|
+
const message = reason !== null && typeof reason === 'object' && reason.error !== null && typeof reason.error === 'object'
|
|
636
|
+
? (typeof reason.error.message === 'string' ? reason.error.message : '')
|
|
637
|
+
: ''
|
|
638
|
+
push(id, 'error', message.length > 0 ? `任务出错:${message}` : '任务出错')
|
|
639
|
+
}
|
|
640
|
+
// aborted / interrupted / max-tokens:不打扰用户,不通知。
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
default:
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// ── 运行位兜底:turn/end 事件可能缺失时的运行中信号 ──────────────────────
|
|
649
|
+
|
|
650
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
651
|
+
if (agent === undefined || agent === null || typeof agent.id !== 'string') return
|
|
652
|
+
const st = stateOf(agent.id)
|
|
653
|
+
if (status === 'running' && st.status !== 'running') {
|
|
654
|
+
st.status = 'running'
|
|
655
|
+
st.activity = '正在处理…'
|
|
656
|
+
push(agent.id, 'running', st.activity)
|
|
657
|
+
} else if (status === 'idle' && st.status === 'running') {
|
|
658
|
+
st.status = 'idle'
|
|
659
|
+
st.lastRunningAt = 0
|
|
660
|
+
st.lastRunningId = null
|
|
661
|
+
}
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
// ── 等待审批:具体审批内容(approval/request 为 waterfall,观察后放行) ─────
|
|
665
|
+
|
|
666
|
+
ctx.on('approval/request', (req, next) => {
|
|
667
|
+
try {
|
|
668
|
+
const sessionId = req?.agent?.session?.id ?? req?.agent?.id
|
|
669
|
+
if (typeof sessionId === 'string' && req !== null && typeof req === 'object') {
|
|
670
|
+
const st = stateOf(sessionId)
|
|
671
|
+
st.status = 'waiting'
|
|
672
|
+
st.waitingKind = 'approval'
|
|
673
|
+
const toolName = typeof req.toolName === 'string' ? req.toolName : ''
|
|
674
|
+
const reason = typeof req.reason === 'string' && req.reason.length > 0 ? req.reason : ''
|
|
675
|
+
push(sessionId, 'approval', reason.length > 0 ? reason : (toolName.length > 0 ? `等待批准执行 ${toolName}` : '等待批准'))
|
|
676
|
+
}
|
|
677
|
+
} catch {
|
|
678
|
+
// 观察失败不影响审批链路。
|
|
679
|
+
}
|
|
680
|
+
return next()
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
// ── typert 服务 ──────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
const service = {
|
|
686
|
+
/** 读取当前配置与全部未确认事件(客户端轮询消费)。 */
|
|
687
|
+
getState() {
|
|
688
|
+
return { config: store.get(), events: queue.map((item) => ({ ...item })) }
|
|
689
|
+
},
|
|
690
|
+
/** 深合并一份配置补丁并持久化(返回值与 ./typert 清单的纯 config codec 一致)。 */
|
|
691
|
+
updateConfig(patch) {
|
|
692
|
+
return store.set(patch)
|
|
693
|
+
},
|
|
694
|
+
/** 回执已消费的事件 id(从队列移除,防止重复提醒)。 */
|
|
695
|
+
ackEvents(ids) {
|
|
696
|
+
if (Array.isArray(ids)) {
|
|
697
|
+
const acked = new Set(ids.map((x) => String(x)))
|
|
698
|
+
for (let i = queue.length - 1; i >= 0; i -= 1) if (acked.has(queue[i].id)) queue.splice(i, 1)
|
|
699
|
+
}
|
|
700
|
+
return { ok: true }
|
|
701
|
+
},
|
|
702
|
+
/** 探测本机已安装浏览器(设置页"跳转会话浏览器"选择器数据源)。 */
|
|
703
|
+
listBrowsers() {
|
|
704
|
+
return { browsers: detectBrowsers() }
|
|
705
|
+
},
|
|
706
|
+
}
|
|
707
|
+
Object.defineProperty(service, 'typertRemote', {
|
|
708
|
+
configurable: false,
|
|
709
|
+
enumerable: false,
|
|
710
|
+
writable: false,
|
|
711
|
+
value: { service, serviceKey: 'notify', namespace: 'notify' },
|
|
712
|
+
})
|
|
713
|
+
ctx.provide('notify', service)
|
|
714
|
+
|
|
715
|
+
console.log('[dsh-notify] 已加载(默认关闭;请在 设置 → 通知 中开启)')
|
|
716
|
+
}
|