这活儿我早该干了。上周我把一个数据清洗的 Agent 任务丢进 DSH 跑,预估要 20 分钟。干等着嫌浪费时间,切去写别的需求,又总忍不住隔几分钟切回 DSH Web 扫一眼会话列表,看它是不是卡在某个工具调用上。最后任务跑完了,我是 15 分钟之后才注意到的,白白浪费了半条命。事后我就在想:Agent 都进化到能自己规划、自己调工具了,怎么"任务跑完通知我一声"这种最基础的能力反而没有?于是就有了这篇——给 DSH Web 接一个任务完成提醒。核心思路不复杂:任务状态实时推送 + 浏览器桌面通知,配合浏览器标签页标题和声音兜底。这篇文章会完整走一遍方案选型、后端推送通道、前端 Notification API 接入,以及怎么做成 DSH 插件顺手扔到 dshmarket 里,适合所有用 DSH 命令行和 Web 控制台跑 Agent 任务的开发者。
1. 先把需求说清楚:这个提醒功能到底在解决什么
1.1 真正的痛点不是"慢",而是"不可感知"
先说结论:Agent 任务跑得快不快是另一回事,真正让人难受的是任务状态完全不可感知。DSH Web 的会话列表是"拉取式"的,你要主动刷新页面、主动点进某个会话,才能看到 Agent 执行到哪一步。这种模式放在交互会话里没问题,毕竟人在现场;但一旦你把 Agent 当作后台任务跑,问题就出来了——你根本不知道它什么时候结束。
更麻烦的是很多 Agent 任务不是单纯快慢的问题,而是会终结于两种完全不同的状态:正常完成,或者某个中间环节挂掉。如果是正常完成,你希望立刻去拿产物;如果是失败,你希望立刻止损。这两种状态如果没有主动通知,你都得靠人工轮询去发现,而轮询本身就是一种隐性成本。
我在实际使用中把这种成本分成三档:任务分钟级完成,你盯着会话列表也许还能忍;任务十分钟以上,你一定会切出去做别的事;任务超过半小时,回来看到结果时大概率已经浪费了一段时间。也就是说,任务耗时越长,"不可感知"带来的浪费越大。这个提醒功能真正要解决的问题,是把"不知道什么时候结束"变成"结束的时候一定会知道我"。
1.2 提醒链路的目标定义
动手之前先定目标,不然容易做成一个花架子。我给自己列了几条硬性要求:
- 任务结束时必须主动告诉用户,而不是依赖用户来问。
- 通知要能区分"成功完成"和"异常失败",因为后续动作完全不同。
- 用户切走标签页、甚至切到别的应用时也要有效,得有桌面通知兜底。
- 不能影响任务的执行链路,通知通道必须和任务主流程解耦。
- 要能一键开启或关闭,不能让通知变成扰民弹窗。
基于这几条,整个功能可以拆成三块来看:后端负责把任务状态"推"出来,前端负责把状态"收"进来,浏览器权限能力负责把状态"通知"到人。三个环节环环相扣,但每一块又相对独立,这也为后面做插件化埋了伏笔。
2. 方案选型:轮询、SSE、WebSocket 的三方对比
2.1 浏览器能用的通知机制,先厘清边界
在聊实时通道之前,先看浏览器本身提供了什么"通知到人"的能力。现代浏览器基本标配 Notification API,也就是桌面通知。它的工作方式是:网页申请通知权限,用户点允许之后,网页就能在任意时刻弹出一条系统级的通知气泡,哪怕浏览器不在前台也能看到。
但这里有个关键限制:Notification 本身只是"展示层",它不负责传输数据。要让桌面通知在任务完成的瞬间被触发,前提是网页能在任务完成的那一刻拿到"任务完成"这个信息。所以核心问题不是怎么弹通知,而是怎么高效地把状态从后端送到前端页面。这就是典型的实时通信问题。
我整理了一下,可选的路无非三条:前端轮询、SSE(Server-Sent Events)、WebSocket。三者的区别从名字就能看出大概——轮询是前端反复问"完了吗",SSE 是后端持续往前端推"我这边情况变了",WebSocket 则是前后端建立一条全双工通道随便聊。
2.2 三条技术路线的直观对比
直接上对比表,省得绕:
| 方案 | 实现成本 | 数据方向 | 断线重连 | 典型适用场景 | 我的评价 |
|---|---|---|---|---|---|
| 前端轮询 | 最低,setInterval 就行 | 单向请求 | 天然就有 | 状态变化不频繁、可容忍分钟级延迟 | 简单但不解决痛点,因为通知不够即时 |
| SSE | 低,后端一个长连接 | 后端到前端单向 | 浏览器自动重连 | 服务端状态持续推送、通知类场景 | 最贴合任务状态推送 |
| WebSocket | 中,需要维护协议 | 双向 | 需要自己实现 | 聊天、协同编辑等双向交互 | 功能过剩,杀鸡用牛刀 |
为什么轮询是最容易想到但我不建议的方案?因为在"任务完成提醒"这个场景下,轮询的反直觉之处在于:任务没完成的时候,你每次请求都在浪费;任务完成的瞬间,你反而可能因为轮询间隔而错过即时通知。假设你设置 5 秒轮询一次,最坏情况下你会在任务完成后整整 5 秒才感知到,而如果 Agent 在任务完成后做了某些清理工作,5 秒足以让现场错过很多信息。
WebSocket 则是反过来,双向能力在这里几乎用不上——任务执行途中的状态明细走的是什么通道?还是 DSH 原本会话列表那套逻辑。任务完成提醒需要的信息量极小,无非一个任务 ID、一个状态、一个结果摘要。为这点消息维护一条 WebSocket 长连接,还要操心心跳、重连、鉴权,属于明显过度设计。
2.3 为什么最终选了 SSE,以及它带来的额外收益
SSE 的定位正好契合这个场景:它是"服务端到客户端"的单向推送协议,基于普通 HTTP 长连接,浏览器原生支持 EventSource 对象,断线了还自动重连。这几条特性几乎就是为"任务状态推送"量身定做的。
这里有个值得展开的点:SSE 的自动重连机制,在"任务执行中网页刷新"这种常见场景下价值很大。我用我实际的实现为例,EventSource 一旦和服务端断连,浏览器会默认按几秒间隔自动重新建立连接。重连过程中,后端只要在任务状态通道里保留了最近一次状态快照,前端重连后第一时间就能拿到"任务是否已完成",不需要用户手动刷新页面。这个特性让提醒的可靠性高了一个级别。
另一个容易被忽略的收益是消息格式。SSE 的消息格式是文本协议,每一条消息可以带 event 字段和 data 字段,天然适合做"心跳消息、状态更新、任务完成"这几类事件的区分。聚合到一起,选 SSE 的决策就清晰了:它在复杂度、浏览器生态、断线重连这三个维度上全部命中了这个场景的需求。
3. 后端实现:给任务状态加一个实时推送通道
3.1 先约定一个极简任务状态模型
后端改造前,先约定任务状态的数据结构。DSH 本身肯定有一套任务状态记录,这里我建议不要在业务层做大改动,而是单独建立一个"状态推送"层的约定。因为提醒功能本质是一个旁路系统,插在任务生命周期外面,尽量不要和任务执行器耦合。
我用的状态模型很简单,就四个字段:
task_id: 全局唯一的任务标识status: 取值统一为pending、running、done、failedfinished_at: 状态变为终态时的时间戳summary: 一句话摘要,成功时是产物路径或结果概要,失败时是错误信息
为什么不在推送消息里塞过多字段?因为通知面板的空间有限,桌面通知简介里放太长内容只能是灾难。用户看到通知后想了解详情,自然会点进 DSH Web 去查完整会话。推送通道只承担"提醒"职责,不承担"详情展示"职责。
为了让任务运行中的中间状态也能被感知,running状态可以附带当前 Agent 正在执行的工具名(比如tool: read_file),这样用户即使不打开会话列表,也能通过标签页上灰掉的小字了解任务大概走到哪了。这一步不是必须,但实测很提升体验。
3.2 后端 SSE 端点的核心实现
后端我用的是 FastAPI 风格的异步实现,逻辑上任何支持流式响应的后端都可以平移。核心是维护一个task_id -> 订阅者队列的映射,当任务状态变更时,把消息写进对应队列,SSE 流再把它吐给前端。
from collections import defaultdict import asyncio, json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse app = FastAPI() # task_id -> set[asyncio.Queue] subscribers: dict[str, set[asyncio.Queue]] = defaultdict(set) @app.get("/api/v1/tasks/{task_id}/events") async def task_events(task_id: str): # 每个前端页面建立一个属于自己的队列 queue: asyncio.Queue = asyncio.Queue(maxsize=100) subscribers[task_id].add(queue) async def event_stream(): try: # 连接建立后先补发一条当前快照,避免重连后漏状态 snapshot = get_task_snapshot(task_id) if snapshot: yield f"event: snapshot\ndata: {json.dumps(snapshot)}\n\n" while True: try: message = await asyncio.wait_for(queue.get(), timeout=15) yield f"event: {message['event']}\ndata: {json.dumps(message['data'])}\n\n" except asyncio.TimeoutError: # 15秒没有新消息则发一行注释格式心跳,保持连接不被切断 yield ": keep-alive\n\n" finally: subscribers[task_id].discard(queue) if not subscribers[task_id]: del subscribers[task_id] return StreamingResponse(event_stream(), media_type="text/event-stream")发布状态变更的入口函数是另一个独立的函数,在任务完成或失败时被调用:
async def publish_task_event(task_id: str, event: str, data: dict): if task_id not in subscribers: return # 队里最多放100条,超出时直接丢弃最旧,防止慢消费者拖垮任务进程 for queue in list(subscribers[task_id]): if queue.full(): try: queue.get_nowait() except asyncio.QueueEmpty: pass await queue.put({"event": event, "data": data})这里有一个很容易踩的坑:asyncio.Queue的默认行为是如果队列满了,put会一直阻塞。如果前端迟迟不消费消息,后端任务发布点会被卡住,严重时甚至把任务执行器拖停。所以我在publish_task_event里做了一个"满了就丢最旧"的兜底,提醒功能可以丢消息,但绝不能把任务执行链路拖垮。
3.3 心跳、超时与异常兜底的细节
SSE 连接维持有几个细节非得实测过才明白。
第一,代理层空闲超时是最常见的断连因素。很多网关默认会切断长时间没有数据流量的 HTTP 连接,所以我在循环里加了 15 秒的": keep-alive"心跳注释行。SSE 协议里,以单个冒号开头的行是注释,浏览器会自动忽略它,但这一行足以让代理层认为连接是活跃的,从而避免被空闲超时掐断。心跳间隔建议设置在代理超时时间的一半以下。
第二,连接断开后,队列里的残留消息要趁着finally里清掉。我在finally里做了discard和空集合清理,防止订阅集合泄漏。如果不清理,断线用户的队列会一直挂在subscribers里,任务状态发布时就会往一个没人消费的队列里写,消息越积越多,最后把内存吃掉。
第三,任务完成事件的发布必须放在任务状态事务提交之后。如果你在 Agent 任务回调里先发布了"已完成",紧接着任务本身的收尾步骤失败,那用户收到的是假消息。我这边是在任务记录真正落库之后才调publish_task_event,宁可在通知准确性和即时性之间让位给前者。
4. 前端落地:从 EventSource 到桌面提醒
4.1 监听逻辑与状态解析
前端这块,DSH Web 的界面是 Vue 技术栈,我就按 Vue 组件的方式来写,但核心逻辑用原生 EventSource,方便各位平移到 React 或者原生 JS。先说我建立监听模块的几个设计原则:
- 每个任务页面只建立一个 EventSource 连接,一个页面同时看多个任务时,用统一的
TaskNotifier管理器管理多条连接。 - EventSource 的每条消息按
event字段分发,避免在onmessage里用一大串 if 判断。 - 页面卸载时务必调用
close(),不然标签页切走之后隐藏页面的连接还在白白占资源。
核心监听逻辑示意:
class TaskNotifier { constructor() { this.esMap = new Map(); } watch(taskId, handlers = {}) { if (this.esMap.has(taskId)) return this.esMap.get(taskId); const es = new EventSource(`/api/v1/tasks/${taskId}/events`); es.addEventListener('snapshot', (e) => { const state = JSON.parse(e.data); this.handleState(taskId, state, handlers); }); es.addEventListener('done', (e) => { const state = JSON.parse(e.data); handlers.onDone?.(state); }); es.addEventListener('failed', (e) => { const state = JSON.parse(e.data); handlers.onFailed?.(state); }); es.addEventListener('running', (e) => { const state = JSON.parse(e.data); handlers.onRunning?.(state); }); es.onerror = () => { // EventSource 断线会自动重连,这里主要做用户提示 handlers.onReconnecting?.(); }; this.esMap.set(taskId, es); return es; } unwatch(taskId) { const es = this.esMap.get(taskId); if (es) { es.close(); this.esMap.delete(taskId); } } }这里要注意一个快照处理逻辑。前面后端代码里说过,连接建立后会立刻补发一条snapshot消息。为什么需要它?因为如果用户在任务跑完后才打开页面,EventSource 连上时可能已经错过了done事件。有了快照机制,前端一打开就能立即知道任务当前状态,该弹通知弹通知,该恢复界面状态恢复界面状态。对"页面刷新导致遗漏提醒"这个场景是刚需。
4.2 Notification API 的权限处理与桌面提醒触发
拿到"任务完成"事件之后,真正弹桌面通知的是 Notification API。这块有两个必须处理的现实问题:权限申请时机和权限被拒的降级方案。
先看权限申请时机。很多开发者的直觉是页面一加载就调用Notification.requestPermission(),这其实是个很差的交互——用户还没明白这个网站会用通知干什么,就收到了权限弹窗,大概率会点拒绝。我的做法是:首次有任务被监听时弹一次权限申请,并在页面里加一句说明"任务完成时进行桌面提醒,可在设置中选择关闭"。
async function ensureNotifyPermission() { if (!('Notification' in window)) { // 当前浏览器不支持桌面通知 return 'unsupported'; } if (Notification.permission === 'granted') return 'granted'; if (Notification.permission === 'denied') return 'denied'; // 首次使用时主动请求权限 const result = await Notification.requestPermission(); return result; } function showTaskDoneNotification(task) { if (Notification.permission !== 'granted') return; const title = task.failed ? `DSH 任务失败:${task.task_id.slice(0, 8)}` : `DSH 任务完成:${task.task_id.slice(0, 8)}`; const body = task.failed ? `错误原因:${task.summary}` : `耗时 ${task.durationSeconds}s,产物:${task.summary}`; const notification = new Notification(title, { body }); // 点击通知时聚焦到 DSH Web 窗口 notification.onclick = () => { window.focus(); notification.close(); }; }这里有一个必须说明的边界情况:Notification构造函数在设置onclick时如果用onclick属性赋值,需要注意某些移动端浏览器不支持点击事件冒泡。不过 DSH Web 主要跑桌面端,这个影响不大。另一个要命的问题是:用户把浏览器通知权限设成了"拒绝",Notification构造函数直接就会抛异常。所以每次调用前判一下Notification.permission是必须的,不能只靠申请时的返回值判断。
4.3 标题栏与声音的兜底提醒
桌面通知是主力方案,但不能是唯一方案。我在实际使用中遇到过一个很尴尬的场景:公司电脑锁屏状态下,Chrome 的桌面通知能不能正常弹取决于系统的"专注助手"设置,有时候通知被折叠进通知中心,根本看不到。所以我加了两层兜底。
第一层是浏览器标签页标题联动。任务完成后,把document.title临时改成带标记的文案,比如[完成] DSH Web,5 秒后再恢复原标题。这样即使桌面通知被系统拦截,只要切回浏览器,扫一眼标签页也能立刻感知状态。实现非常简单:
function flashTabTitle(statusText) { const originalTitle = document.title; document.title = `[${statusText}] ${originalTitle}`; setTimeout(() => { document.title = originalTitle; }, 5000); }第二层是声音提示。DSH Web 在任务启动时给用户一个开关选项,打开后,任务完成时会播放一小段短促的提示音。声音的实现用了 Web Audio API 直接合成,不需要引入任何音频文件:
function playDoneChime() { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.frequency.setValueAtTime(880, ctx.currentTime); oscillator.frequency.setValueAtTime(1320, ctx.currentTime + 0.15); gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.start(); oscillator.stop(ctx.currentTime + 0.5); }两层兜底加上桌面通知,就构成了一个从强到弱的提醒梯度:桌面通知最醒目,标签页标题次之,声音作为最后的听觉提示。我在实际使用中,标签页标题这一层的重要性被严重低估——它几乎不会打扰任何人,但信息传达特别有效。
5. 以插件方式接入 DSH 生态
5.1 把提醒能力从一次性代码变成可分发插件
单独给 DSH Web 加个功能不难,难的是让这个能力可以复用、可以分发。DSH 有一个插件系统,插件可以通过dsh plugin子命令安装,也可以从 dshmarket 的远程仓库拉取。比如下面这个命令就是把 dshmarket 插件仓库挂到当前配置的 Web profile 下:
dsh plugin --profile web add dshmarket这个命令做的是把远程插件索引加入本地的 Web profile 配置。加上之后,后续再装具体插件时,DSH 就能从 dshmarket 仓库解析到插件的名称和版本。整个流程非常像包管理器。我要分享的经验是:任何看起来简单的能力,一旦你想在团队内推广,就值得花半小时做成插件。不然每个同事遇到同样需求后,都会各自复制一份代码,最后维护成本全算在你头上。
做成插件之后,发布的流程大概是:写好插件元信息文件和实现文件,验证通过后打到 dshmarket 仓库。团队内其他成员只需要一行dsh plugin --profile web install <plugin-name>就能装好,DSH Web 启动时会自动加载插件,在首选项里出现提醒开关。
5.2 插件目录结构与提醒模块的组织方式
插件内部的组织方式我这边遵循 DSH 的插件约定,目录大致如下:
task-notifier/ ├── plugin.toml # 插件元信息:名称、版本、入口 ├── notifier/ │ ├── __init__.py │ ├── events.py # 任务状态事件解析 │ ├── notify.py # Notification 权限与弹窗 │ ├── sound.py # 声音提示 │ └── ui.py # 设置项注入与菜单集成plugin.toml是插件的入口描述,里面声明了插件名称、版本、以及 DSH Web 加载时要注入的模块列表。重要的是里面有一个配置项声明,可以把"任务完成提醒"这个开关自动注册到 DSH Web 的用户设置区:
[plugin] name = "task-notifier" version = "0.1.0" entry = "notifier" [config] enable_desktop_notify = { type = "bool", default = true } enable_tab_title_flash = { type = "bool", default = true } enable_sound = { type = "bool", default = false }插件加载时,notifier/__init__.py里注册一个全局的after_task_finish钩子,任务完成时由 DSH 调度器调用。插件本身不需要侵入 DSH Web 原有的任务执行代码,只要注册钩子,主程序在合适的时机把任务对象传给钩子函数即可。这也是我认为这个提醒功能最优雅的落地方式:不改动核心代码,不引入强依赖,纯旁路能力,随时可卸。
6. 实战中踩过的坑与排查实录
6.1 Notification API 在 HTTP 环境下的权限限制
这是我在本地联调时踩的第一个坑。桌面通知的权限机制里有一条硬规则:非安全上下文(也就是 HTTP 环境)下,未必能弹出浏览器通知。Chrome 从某个版本开始,把 Notification API 限定在 secure context 中才能使用,http://localhost作为本地开发地址是例外,但如果你通过局域网 IP 访问 DSH Web 或者部署在纯 HTTP 的内网服务上,会发现Notification.permission永远是denied,申请权限的弹窗根本不出现。
排查方法很简单:在浏览器控制台执行window.isSecureContext,如果是false,基本可以断定是这个原因。解决办法自然是用 HTTPS 部署 DSH Web,或者用反向代理把 HTTP 升级成 HTTPS。如果你的 DSH Web 跑在内网环境且无法上证书,那就只能依赖标签页标题和声音这两层兜底,桌面通知直接降级为不可用。我在落地的时候顺手在设置页加了个状态提示,告诉用户当前环境是否支持桌面通知,免得他们一直开开关发现没反应。
6.2 EventSource 自动重连引发的重复提醒
前端实现完成后的第二个坑来自 EventSource 的"过度勤劳"。浏览器在连接断开时会自动重连,这本是好设计,但搭配后端快照机制后出现了一个奇怪的现象:用户明明已经看过了任务完成的桌面通知,但几秒后浏览器又弹了一次,因为 EventSource 重连成功后收到了后端补发的snapshot消息,而快照里的 status 是done。
这个问题必须在客户端做去重,而不是依赖后端状态。我的做法是在TaskNotifier里维护一个completedTasks集合,已经处理过done事件的任务 ID 直接忽略后续同类消息:
handleState(taskId, state, handlers) { if (state.status === 'done' && this.completedTasks.has(taskId)) { return; } this.completedTasks.add(taskId); handlers.onDone?.(state); }这个集合在标签页的生命周期内有效,刷新页面后清空,此时如果任务还在"已完成"状态,快照机制反而能正确地补上提醒。一进一出,去重和防漏刚好平衡。
6.3 后台标签页的节流对提醒逻辑的影响
浏览器对后台标签页有一个节流机制,尤其影响定时器、音频播放等操作。我在实现声音提示时发现一个有意思的现象:标签页完全没有被切走时,AudioContext是直接就绪状态;一旦标签页切到后台一段时间,AudioContext的状态会变成suspended,此时调用playDoneChime不会报错,但也没有声音。
解决办法是提前创建AudioContext,并把它挂到页面任意一次用户交互手势里。我写了个初始化函数,在用户点击"运行任务"按钮时调用一次,让 AudioContext 在用户手势的上下文里恢复为running状态:
function ensureAudioContext() { const ctx = new AudioContext(); if (ctx.state === 'suspended') { ctx.resume(); } return ctx; }另外一个容易被忽略的细节是:后台标签页的定时器会被节流到至少 1 秒一次,这会影响标签页标题的 5 秒恢复逻辑。实测下来,切到后台后setTimeout的 5 秒可能变成 10 秒甚至更久,标题闪烁时间被拉长。这个一般不影响体验,但如果你的产品对标题回复时机有要求,建议用visibilitychange事件在页面回到前台时立刻恢复原标题。
6.4 常见问题排查速查表
| 症状 | 可能原因 | 快速处理 |
|---|---|---|
| 桌面通知完全不弹 | 非安全上下文导致 Notification 权限被禁用 | 控制台查window.isSecureContext,改用 HTTPS 部署 |
| 权限弹窗没有出现 | 浏览器在过去已经拒绝过权限 | 手动去浏览器站点设置里重置通知权限 |
| 任务完成提醒重复弹 | EventSource 重连后快照消息再次触发 | 客户端加completedTasks去重集合 |
| 任务完成但没有任何提醒 | 订阅连接被代理空闲超时切断 | 检查后端心跳间隔,是否小于代理超时的一半 |
| 标签页标题不恢复 | 后台标签页定时器被节流 | 用visibilitychange事件恢复标题 |
| 声音提示无声 | AudioContext 被浏览器挂起 | 在用户手势里创建并恢复 AudioContext |
7. 收尾:把这个能力沉淀下来之后的一点体会
做完这个提醒功能之后,我最大的体会是:Agent 工具链里,任务执行器和人之间的"最后一公里"往往被严重忽略。DSH 已经把 Agent 的执行、调度、会话管理做得足够好,但如果你还是靠人肉刷新去感知任务状态,那整套系统在体验上就是断的。这次我做的不过是一条 SSE 推送加一个 Notification 弹窗,但使用体验上的提升是质的——我再也不用提心吊胆地反复切标签页了。
最后分享一个更高阶的用法:因为提醒事件已经变成了一个钩子,后续可以在这个 Hook 后面继续扩展。比如任务失败时自动把错误摘要同步到团队的即时通讯群,或者把多个任务完成事件聚合到一个面板里做批量处理。提醒这件事本身只是个开始,真正有价值的是它把"任务状态"事件化之后带来的自动化空间。哪怕你暂时不做插件分发,只为自己的 DSH Web 加上这一个小能力,我建议你也按事件化的思路来设计,因为接下来你大概率会想在这个事件上挂更多东西。