dsh-service-status
把 DeepSeek 的服务/模型状态搬进 DSH:输入框工具栏左侧和侧边栏底部各一枚状态圆点,点开是
status.deepseek.com 的现场情况(整体状态、被点名的服务与模型 API、最近事件原文链接);
两枚圆点各自可以关掉,订阅地址可以在设置页里改;外带给模型一个 model_status 工具,
让它在判定「这次调用失败到底怪谁」之前能先问一句。
围绕 harness 0.2.0-rc.2 / DSH Desktop 0.12.0(2026-10 那一版)写成。插件版本 0.3.0。
零运行时依赖:host 半只有 ESM,浏览器半只 require('react') —— 弹层既不用 react-dom 的 portal
(它是一进 top layer 就会和锚点脱钩的那类定位),也不依赖任何图标包。
English — DeepSeek service status (status.deepseek.com) inside the harness UI: a status dot beside the composer and at the sidebar foot (each switchable), a detail popover over configurable Atom/RSS feeds, a settings page, and a
model_statustool so the model can ask "is this failure ours or theirs?" before blaming a tool call.
安装
dsh plugin add chiqianyushao/dsh-service-status
或者用 GitHub Release 上预构建的 tarball(同样不需要构建,适合不想走 git 依赖的环境):
dsh plugin add https://github.com/chiqianyushao/dsh-service-status/releases/latest/download/dsh-service-status.tgz
DSH Desktop:侧栏 插件 → 添加插件 → 粘贴 https://github.com/chiqianyushao/dsh-service-status
→ 安装 → 重启 DeepSeek Harness。桌面端的 profile 由 Electron 独占,所以不要用
dsh plugin --profile desktop … 装。
仓库里已提交 lib/,因此从 GitHub 安装时不需要构建、也不需要授权构建脚本。
改名说明:本插件原名
dsh-model-status,与 npm 上已有的同名包冲突,公开发布时改为dsh-service-status。配置文件位置随之变为<harness home>/service-status/config.json;旧的<harness home>/model-status/目录可以删掉 —— 设置会回到默认值,重新保存一次即可。
一、用起来是什么样
| 你在哪 | 看到什么 | 点一下 |
|---|---|---|
| 输入框工具栏左侧(模型选择器同一行) | 圆点 + 服务正常 / 服务降级 / 服务异常 / 状态未知 |
展开详情面板 |
| 侧边栏底部(设置按钮旁) | 同上;侧栏收起成 56px 轨道时只留圆点 | 同上 |
| 设置 → 服务状态 | 两个「显示位置」开关 + 订阅地址列表(可增删、可测试) | —— |
| 模型上下文里 | model_status 工具:状态、未解决事件、相关分量、事件链接 |
—— |
面板内容:标题旁的状态胶囊、更新于 N 分钟前、读取失败时的黄色提示(仍显示上一次结果)、
被最近事件点名过的服务/模型(最严重的在前)、最近 5 条事件(状态词 + 相对时间 + 原文链接),
以及通往状态页的入口。
状态只有四个:
| 圆点 | 词 | 什么时候 |
|---|---|---|
| 绿 | 服务正常 | 最近窗口里没有未解决事件 |
| 黄 | 服务降级 | 最新未解决事件处于 monitoring / 计划维护 |
| 红 | 服务异常 | 最新未解决事件处于 investigating / identified |
| 灰 | 状态未知 | 一次都没读到(不是「挂了」) |
「降级」「异常」的圆点会带一圈同色光晕,所以扫一眼侧边栏就知道现在不是绿的,而不用读字。
二、设置:显示在哪、读哪些地址
设置页在 设置 → 服务状态(settings.section 席位),做三件事:
- 显示位置:两个开关,分别控制输入框工具栏与侧边栏底部。关掉的那一处完全不占布局 ——
列表槽位会把组件返回的
null直接丢掉,所以不会留下一个空白的间距。 - 订阅地址:一行一个 URL,可以加、删、改;每一行带「测试」按钮,点它走的是生产同一份读取代码 (同样的超时、同样的解析),能当场告诉你「连接正常 · N 条事件」还是失败原因。
- 保存 / 恢复默认:保存后立即生效,两枚圆点当下就会出现或消失;面板也会立刻用新地址重读一次。
配置文件位置:<harness home>/service-status/config.json($DSH_HOME 为 Desktop 的 harness 目录),
内容是 { version, seats: { composer, footer }, feeds: [...] }。写入是原子的(临时文件 + rename),
所以断电或崩溃不会留下半个配置把插件弄哑。文件坏了或读不出来时回到默认值并记一条日志 —— 插件继续工作,
只是忘掉你的自定义。
地址的规则:只接受 http/https;最多 8 个;重复地址会被去掉;全部无效时回退到默认的两个地址
(一个什么都不读的插件只会一直说「未知」,和坏掉没有区别)。
三、两个订阅地址为什么只能由 host 去读
https://status.deepseek.com/feed.atom 与 feed.rss 是同一批事件的两种格式,都不带 CORS 头
(实测响应里没有 access-control-allow-origin)。DSH 的页面跑在 http://127.0.0.1:<port>,
直接 fetch 会被浏览器拦下,所以读 feed 这件事只能放在 host 半,浏览器半读 host 归一化后的
一份快照:
Atom ─┐
├─ host: 解析 → 归一 → 60s 缓存 ─→ GET /dsh-service-status/status ─→ 两枚圆点 + model_status 工具
RSS ─┘ (按 config.feeds 顺序读,第一个成功的就用)
设置页 ─→ GET /dsh-service-status/config (读配置 + 默认值 + 展示用 home)
─→ POST /dsh-service-status/config (写配置,校验失败返回 400)
─→ POST /dsh-service-status/check (用生产读取器试一个地址)
为什么这些路由不在 /api 下。 /api 这个前缀由 @deepseek-ai/dsh-client-connection 拥有:
它是 gateway 的载体、自带鉴权、并且会回答它不认识的那些请求。把精确路由塞进别人的前缀里虽然
按匹配顺序能生效(精确表先于前缀表),但那是在别人的命名空间里放第二个所有者。
/dsh-service-status/* 是没人认领的路径,只由本插件回答;同时仍然加了 loopback 栅栏
(socket 地址 + Host 头 + 拒绝带转发头的请求)—— 状态路由只是读公开数据,但配置路由会写文件,
栅栏在那里是承重的。
为什么一次失败不等于「挂了」。 读失败时保留上一份快照并标 stale,面板写清原因;
只有一次都没成功过才显示 状态未知。「我连不上状态页」和「DeepSeek 挂了」是两件事实,
用同一个红色说出去就是在编数据。
四、聚合规则
feed 里每条事件带一个 Status: 词和一组 Affected components:。整体状态取自
最新一条未解决事件,而不是最新一条事件——状态页顶上挂着的已恢复事件不代表现在是降级的:
| feed 里的状态词 | 聚合 |
|---|---|
resolved / completed / closed |
服务正常 |
monitoring / scheduled / maintenance |
服务降级 |
investigating / identified / incident / major_outage / partial_outage / degraded_performance |
服务异常 |
| 认不出来的词 | 状态未知(不猜) |
分量表按事件从新到旧折叠,每个分量保留最新一次点名它的那条事件;同一分量被多条事件点名时
先到的赢(scripts/verify.mjs 有一条断言专门盯这个:改回去让旧的覆盖新的,套件会红)。
只列当前事件窗口里被点过名的分量——一年没人提过的分量不叫「正常」,叫「没数据」,把它标绿同样
是在编数据。
五、视觉:借壳的度量,但底是不透明的
弹层的几何、投影和配色直接取 harness 自己的值,所以它在浅色/深色下都像原生卡片:
| 用到的 token | 值 | 来自 |
|---|---|---|
--dsw-radius-sm / md / lg |
8 / 12 / 16px | 全局控件圆角 |
--dsw-alias-bg-layer-2 |
主题的抬升层(不透明) | 弹层底色 |
--dsw-alias-border-l2 |
#0000001a / #ffffff1f |
弹层描边 |
--dsw-elevation-prominent |
stroke + 两层阴影 | 卡片投影 |
--dsw-alias-interactive-bg-hover/active |
#2631480f / #2631481a(深色 #ffffff14 / #ffffff24) |
悬停与展开态 |
--dsw-alias-state-success/warn/error/idle-primary |
—— | 四种圆点 |
--dsw-alias-switch-thumb、--dsw-alias-button-primary-fill |
—— | 设置页开关与主按钮 |
为什么底色不透明(这是一次线上回修)。 最初照 MenuSurface.module.css 用了壳的菜单材质
--dsw-menu-surface-fill(#f8f9fa94 / #43454a73,45–55% 不透明度)叠
--dsw-menu-backdrop-filter。问题在真实聊天流里才暴露:那一层半透明让底下的工具卡片与文字透上来,
事件行和它背后的内容叠在一起、两边都读不清 —— 截图里的双影就是这么来的;而当 backdrop 模糊没有生效时,
45% 的颜料盖在段落上就只是糊。菜单可以这么薄(它小而往往压在纯色上),一个 340px 宽、500px 高的面板
不行。所以底改成主题的抬升层(不透明),材质感交给圆角 + 描边 + 投影。
为什么最后改用 top layer(第二次回修)。 不透明之后又收到一张截图:空会话页的大字 hero
(探索未至之境)压在了面板上,遮住一条事件行。查过壳的样式表与内联样式,壳里没有任何 z-index
超过 1100(菜单 1100、设置 1001、命令面板 100),而面板已经要了 1300 —— 也就是说「比数字」
这条路本身不成立:那个 hero 落在了一个更高的层叠上下文里,靠继续加大 z-index 只会一路内卷。
所以改用浏览器给的唯一确定性手段:popover="manual" + showPopover(),把打开的面板提升到
top layer,它按定义位于所有层叠上下文之上(dialog/弹出层之外无物可压)。同时重置了 popover
的 UA 默认值(inset:0; margin:auto),否则盒子会被拉伸;::backdrop 也置为透明且不拾取点击。
没有 showPopover() 的引擎退回 z-index:1500,两条路都有断言守着。
第三次回修:先猜错,再用真 Chromium 定案。 提升到 top layer 之后面板跑到右上角去了。我第一次
的判断是「坐标不可靠,那就别用坐标」—— 把面板放回 .dms-root 里当兄弟节点,靠 CSS
position: absolute; bottom: calc(100% + 8px) 贴住圆点。结果面板居中飘在屏幕中间。
于是写了个最小复现页,用本机 Chrome 的 headless 模式量真值(同一份 CSS、同一套 DOM、真
showPopover()):
| 组合 | 量的结果 |
|---|---|
top layer + position: absolute + CSS 贴圆点 |
offsetParent: null,left:0;bottom:calc(100% + 8px) 解析成初始包含块的坐标 → 面板飞出屏幕顶部 |
top layer + CSS anchor positioning(anchor-name/position-anchor) |
锚点没解析到,面板落在 top: 0 |
top layer + position: fixed + 圆点 getBoundingClientRect() 的视口坐标 |
圆点 x=22, top=466 → 面板 x=22, bottom=458,正好左对齐、底边高 8px |
结论:top layer 里没有锚点链,绝对定位会退回初始包含块,所以只能把圆点量成视口坐标再
position: fixed。之前的漂移不是「用了坐标」,而是同时 portal 到了 body —— portal 会换掉
fixed 的包含块。现在面板留在 .dms-root 里、不 portal、不带 react-dom 依赖,坐标在打开那一刻
量一次,两条结论都有断言与变异守着。
面板内部是「滚动区 + 钉住的页脚」:事件再多也不会把「打开状态页」挤到视野外,面板底边永远是一 条干净的线而不是切在半个字上,滚动区用细滚动条提示还有内容。
尺寸跟壳对齐:圆点按钮 28px 高、8px 圆角(Button 的 sm 规格);胶囊用 Tag 的
1px 8px / 11px 字号;弹层 6px 内边距 + 28px 行高;图标 14px 内联 SVG(currentColor,
不依赖任何图标包)。动效只有两处:弹层 140ms 的淡入上浮、刷新时图标旋转,两者都在
prefers-reduced-motion 下关闭。每个 token 都带字面量兜底,老版本壳里也不会变成透明。
样式是一张 dms- 前缀的样式表,每个 document 注入一次(按 data-dsh-service-status 去重)。
「面板必须不透明」「必须进 top layer」「必须重置 UA 内边距」「必须是 fixed 而不是 absolute」
「坐标必须来自圆点的 rect(左对齐、底边高 8px、宽度收进视口)」这五条被写成了断言(见验证一节),
所以下次把底改薄、把 popover 去掉、或者把它改回按祖先定位,套件会直接变红。
六、布局
src/config.ts 纯逻辑:home 解析 / 校验 / 归一 / ConfigStore(原子写、坏文件回退)
src/status.ts 纯逻辑:实体解码 / Atom+RSS 解析 / 聚合 / 缓存 / readFeed + probeFeed
src/index.ts host 半:三个路由 + loopback 栅栏 + model_status 工具 + 两个可选注入
src/client/index.ts 浏览器半:共享 store、两枚圆点、详情面板、设置页、样式表
cordis.patch.yml bundle patch:插入一行,按包名解析,无 config
scripts/build.mjs 零依赖构建:stripTypeScriptTypes + 包成 module-loader 工厂
scripts/verify.mjs 328 项校验:真 feed 夹具 + 配置落盘 + 路由/工具/栅栏 + 假 DOM 驱动真 bundle
scripts/fixtures/ 线上 Atom 与 RSS 各截 6 条
node scripts/build.mjs # -> lib/index.js, lib/status.js, lib/config.js, lib/client.js
node scripts/verify.mjs # 328 checks
pnpm test # 两个都跑
typescript 只用 Node 自带的 stripTypeScriptTypes 擦除;构建末尾会真的 import() 一遍
lib/index.js,所以「类型擦除后留下的悬空 import」在构建期就会炸,而不是等到激活。
两个 host 面都是可选注入
export const inject = [] // 不要求任何服务
ctx.inject(['webServer'], …) // 没有 webserver 就没有三个路由
ctx.inject(['tools'], …) // 没有 tools 就没有工具
理由和本仓库另一个插件一样:inject 里声明了某个服务,缺它的组合里整个插件都不会激活。
分别 ctx.inject 之后,只有一半能力的组合仍然拿到它能拿到的那一半。
七、验证到什么程度
已验证(node scripts/verify.mjs,328 项全绿)
- 解析:真 Atom / 真 RSS 两种格式各自 6 条,两边标题、id、分量完全一致;实体解码(十进制、十六进制、
命名实体、未知实体保留、代理对不生成不可用字符);HTML 先解码再去标签的顺序(反过来 23 项会红);
CDATA、
rel="alternate"优先、无日期条目丢弃、RFC 822 归一成 ISO。 - 聚合:全 resolved 的 feed 是「正常」;更新的已恢复事件不会盖掉更旧的未解决事件;
monitoring→ 降级;认不出的词 → 未知;分量按严重度再按时间排序、重复点名保留最新。 - 缓存:TTL 内不发请求、超时后发、
refresh绕过、并发共享一次读、Atom 500 时落 RSS、 两边都挂时 staleness 与error正确、慢 feed 超时、空条目视为失败;setFeeds换地址会清缓存且下一次读新地址。 - 配置:
$DSH_HOME优先级与空白忽略;URL 校验(只收 http/https,拒 ftp/file/javascript/无 scheme/空/非字符串); 去重、上限 8、空列表回退默认并说明原因;缺文件→默认、坏 JSON→默认并记日志、部分坏→修好其余; 保存是原子的(落盘后无.tmp残留)、被拒绝的保存一个字节都不写、部分保存保留未提及的字段。 - 路由:status 200 +
no-store+ 载荷带 config;HEAD 无 body;POST 405 +Allow; config GET 返回 live/默认/符号化 home/文件路径/上限;config POST 落盘并切换 monitor 的地址; 非法配置 400 且不落盘;坏 JSON 400;PUT 405;非 loopback 的写 403; check 200(真读一次)/非法地址 400/不可达 422/GET 405;?refresh=1真重读;monitor 抛错 → 500 并记日志。 - 工具:定义形状(名字、描述、参数 JSON Schema、输出 schema、
render)、execute的结构化返回值、 未解决事件枚举、refresh生效、render输出摘要而非JSON.stringify。 - host 装配:两个
ctx.inject各自的 effect 标签、三条精确路由的 kind/path/handler、工具名; 缺inject的 context 不抛错而是说清原因。 - 浏览器半:bundle 通过
__ModuleLoader__以包名注册、只注入slots;三个席位的 slot 名/id/order 逐字断言; 样式只注入一次且含菜单材质、elevation、圆角刻度、开关 thumb、reduced-motion; 真lib/client.js在假 DOM + 真 hook runtime 里被驱动:两枚席位共享一次读 → 点开面板读到状态/事件/分量/链接 → 点刷新(断言?refresh=1且面板不关)→ 点面板内部不关 → 点外部关 →Escape关, 监听器随之释放 → 设置页:两个开关、两个地址输入、关掉一处并保存(断言 POST 体、已保存)→ 保存后那枚圆点当场消失、另一枚还在 → 两处都关掉 → 再打开回来 → 加行/删行/改行 → 「测试」命中 check 路由 → 不可达地址显示失败 → 非法地址保存显示失败 → 恢复默认回填默认地址 → 卸载后清掉定时器与监听器。
变异测试(改坏实现必须变红,20 个全部让套件变红)
| 变异 | 套件反应 |
|---|---|
| 配置保存吞掉校验失败 | ✗ 7 项 |
| 配置不再去重 | ✗ 2 项 |
| 空地址列表静默保留默认 | ✗ 1 项 |
| 保存配置后不同步给 monitor | ✗ 1 项 |
| 某条路由跳过栅栏 | ✗ 9 项 |
| 浏览器半忽略席位开关 | ✗ 2 项 |
| 设置页开关不改草稿 | ✗ 5 项 |
| 保存时不带地址列表 | ✗ 2 项 |
| 把面板底改回半透明材质 | ✗ 1 项 |
| 去掉面板的 z-index | ✗ 1 项 |
| 面板不进 top layer(去掉 popover) | ✗ 1 项 |
| 打开时不调 showPopover() | ✗ 1 项 |
| 不重置 popover 的 UA 内边距 | ✗ 1 项 |
面板改回 position:absolute(top layer 里没有锚点链) |
✗ 1 项 |
| 去掉底边 8px 间距 | ✗ 1 项 |
| 把测量坐标从面板上摘掉 | ✗ 3 项 |
| 宽度不再收进视口 | ✗ 1 项 |
| 面板按祖先定位而非视口坐标 | ✗ 1 项 |
| 去掉单飞 / 失败不标 stale / 解析顺序反了 / 分量去重失效 | ✗ 各 1–23 项(上一轮 10 个变异的延续) |
线上验证过(不是声称)
- 第一版(只有状态路由与工具):装进
webprofile 后patchReload: live激活 host 半,GET /dsh-service-status/status返回 200 且overall: operational;另开 agent 会话调用model_status拿回完整摘要。 - 0.2.0 的 host 半:重启后在运行中的 Desktop 里
GET /dsh-service-status/config返回 200, status 载荷带config;设置页被真实使用过 —— 现存配置是seats: { composer: true, footer: false }, 也就是侧边栏那枚在界面上被关掉了,状态接口如实回同样的值。 - 0.2.0 的 host 半另在真实进程外用同一份
lib/对真 feed 跑了一遍:status 200(12 条事件)、 config GET 200(home: $DSH_HOME、2 个地址)、config POST 200(写盘并把新地址交给 monitor)、 改席位后 status 载荷跟着变、check 对真 RSS 返回35 条事件、非法地址 400、非 loopback 写入 403。 - 0.2.4 的定位结论是用本机 Chrome headless 量的真值:把 lib/client.js 里的 CSS 数组原样取出来 (不是手抄),配上和客户端一样的坐标算法,在真 Chromium 里 showPopover() 后量矩形 —— 圆点 x=22, top=466,面板 x=22, bottom=458,即左对齐且底边高 8px,ok: true。 同一次实验也量到了反例:top layer 里 position: absolute 的 offsetParent 是 ull, 换成 CSS anchor positioning 则落到 op: 0 —— 所以「用 CSS 贴住圆点」这条路被真实验否掉了。
- 0.2.1–0.2.3 的三次修复都由真机截图驱动,每一轮都改对了一件、又暴露出下一件: 先是面板半透明、底下工具卡片透上来且压住面板底边;接着是不透明之后空会话 hero 仍压在面板上 (壳里最大 z-index 只有 1100,面板要 1300 也没用);最后是 top layer 生效了、但按测量坐标定位的 面板跑到了右上角。三次的修法分别是「不透明底」「进 top layer」「删掉测量坐标改用 CSS 锚定」, 每一件都有断言 + 变异守着。
没验证(如实说)
- 真实浏览器里的像素级外观。这次的透明与层叠问题正是真机截图才发现的 —— 假 DOM 能证明 CSS 里写了不透明底和层叠次序(现在有断言),但量不了实际观感。
showPopover()缺失时的退回。没有 top layer 的引擎走z-index:1500,此时面板仍可能被 某些特殊层压住;当前 Desktop(Chromium 系)一定有,这条只有单测覆盖。- 后台标签页恢复刷新。
visibilitychange只在文档重新可见且超过 60s 时补读一次,没有真实后台场景验证。 - feed 的措辞变化。
Status:/Affected components:是从摘要里正则提的;状态页换文案时分量表会空, 整体状态则退化成「未知」而不是瞎猜。
八、安装(DSH Desktop / profile web)
Desktop 的应用市场装不了本地包,必须走终端 CLI,且 DSH_HOME 要指向 Desktop 的 harness:
$env:DSH_HOME = "$env:APPDATA\dsh-desktop\harness"
dsh plugin --profile web add link:E:\dsh-plugiun\packages\dsh-service-status
装完之后 profile 的 dependencies 出现 dsh-service-status: link:E:\dsh-plugiun\packages\dsh-service-status,
dsh.profile.bundles 出现 dsh-service-status,node_modules/dsh-service-status 是指向本目录的 Junction。
首次安装会随 patchReload: live 直接激活 host 半;之后改 host 代码必须重启 Desktop。
浏览器半每次刷新页面重新拉取。配置文件在第一次保存设置时创建。
退路:把 profiles/web/package.json 换回安装前的备份、删掉那个 Junction 与 <harness home>/service-status/ 即可。
授权
MIT
No comments yet. Be the first to write one.