
最近一个月我桌面上最频繁的动作不是写代码而是切窗口写完一句提示词就切出去看 Codex 额度训练一跑起来就切出去敲一次 nvidia-smi下午还要反复确认下一个会议的 ddl 还剩几分钟arXiv 今天有没有上新论文也总惦记着。一天下来真正连续工作的时间被切得稀碎大部分心思都花在“我刚才是不是该看一眼”的自我拉扯上。后来我想明白这些信息并不需要我“去查询”它们只需要“被看见”。于是我把它们做成了四个钉在屏幕右边缘的常驻模块Codex 额度、GPU 占用、会议 ddl、arXiv 论文。这篇文章就把这个面板从设计、取数、踩坑到最终形态完整记录下来给同样在多个工具之间来回切换的朋友一个可直接参考的方案。1. 为什么非“屏幕边缘”不可而不是又一个浏览器标签页1.1 注意力机制决定了“环境区”和“工作区”是两回事先说一个基础但经常被忽略的事实人的注意力并不是一个单一通道。我们在做正事时注意力集中在屏幕中央的高密度区域这叫聚焦注意力而屏幕边缘、眼角余光能扫到的地方负责的是环境感知。你不需要“仔细看”就能知道“有个东西大概变红了”“这里数字好像跳了”这就是边缘视野的作用。把额度、GPU 数字放到浏览器标签页里本质上是把“环境信息”变成了“焦点信息”。你要么切窗去看要么干脆不看。但切窗是有代价的每一次切换都伴随着上下文的中断回来之后还要花一段时间重新进入状态。我试过把所有面板塞进浏览器的新标签页也试过在终端里开一个独立的 watch 窗口最后都失败了——因为它们都要求我主动“去一趟”而不是信息自己“待在那”。屏幕边缘的常驻窄条之所以有效是因为它把信息放在了一个不需要抢主线注意力的位置它挂在那你能感知到变化但不会被打断。这是它和浏览器标签页、终端窗口最本质的区别。所以结论很明确边缘 常驻 置顶 平时半透明这是这个项目的基调。1.2 四条信息的更新频率完全不同天然适合按模块设计项目正式开工前我先把这四条信息的变化特性拉了一张表数据变化频率合理的刷新间隔人的查看动机Codex 额度每次请求后变化15-30 分钟怕超额、预算焦虑GPU 占用秒级2-5 秒关注训练/推理是否卡住会议 ddl分钟级5-15 分钟怕迟到、安排间隙arXiv 论文天级6-12 小时不想漏掉新结果这张表直接否定了“做一个每秒统一刷新的大面板”的做法。如果你的 GPU 模块每 30 分钟才更新一次那它根本没法反映训练状态反过来如果 arXiv 模块每 2 秒去拉一次接口纯属浪费资源还可能触发对方限流。正确做法是让每个数据源独立轮询、独立更新谁改了谁上报UI 只负责把最新值画出来。这个原则贯穿了整个项目的架构设计。2. 整体方案一个常驻窄边悬浮面板而不是四个零散窗口2.1 技术栈选型Electron、Tauri、原生小窗还是纯脚本选型这件事我纠结了大概一个下午核心问题只有一个这个面板需要什么级别的能力它需要的能力其实很少画几根进度条、显示几行文字、定时取数外加一个置顶窗口。长期成本却很高维护、打包、跨平台适配。我当时在三种方案里摆了一盘方案优点缺点适合场景Electron生态成熟、前端技术栈顺手内存占用偏高一个空窗口动辄上百兆快速实现、不介意资源Tauri内存占用低、二进制体积小需要一点 Rust 壳知识、WebView 行为有平台差异长期常驻、资源敏感原生脚本 系统小组件零额外依赖、最轻样式能力弱、跨平台要重写只挂一两个指标我最终选了 Tauri。原因不是它比 Electron 强多少而是这个面板是要“每天从开机挂到关机”的常驻程序内存占用省下一百多兆对我这种经常同时开多个开发环境的人来说体感差别非常明显。如果你是 Electron 老手用 Electron 完全没问题后面的取数逻辑和架构设计是通用的。2.2 数据流设计一分钟 tick 加独立轮询拒绝一个源拖死全屏这是整个项目最核心的工程决策。面板的渲染只是一个 280 像素宽的窄条但背后有四条完全独立的数据链路它们的网络状况、接口响应速度、失败概率都不一样。我不能接受某个源超时八秒导致整个面板卡住。我采用的结构是一个主循环每秒 tick 一次遍历所有已注册的数据源检查是否到了各自的刷新时间如果到了就异步发起一次 fetch拿到结果后写入一个中心 storeUI 层订阅 store 的变化只在值真正改变时才更新 DOM。代码骨架大致长这样type Source { name: string; interval: number; // 毫秒 fetch: () Promiseunknown; }; const store new Mapstring, unknown(); const lastRun new Mapstring, number(); async function tick() { for (const s of sources) { if (Date.now() - (lastRun.get(s.name) ?? 0) s.interval) { lastRun.set(s.name, Date.now()); s.fetch() .then((v) store.set(s.name, v)) .catch((e) store.set(s.name, { error: e.message })); } } render(store); setTimeout(tick, 1000); }每个 fetch 内部都必须自带超时我统一设成 8 秒超过就当失败处理。失败时不是清空数据而是保留上一次的值并在 UI 上打一个“stale”标记。这样网络抖一下、日历服务临时不可用面板都只是显示旧数据而不是变成一个错误弹窗。你会发现这种设计还有个额外的好处系统休眠后醒来tick 一跑所有到期的源会同时刷新一次恰好让面板马上恢复新鲜状态。3. Codex 额度模块把看不见的“预算”变成一条进度条3.1 数据从哪来读本地登录态再走账号用量接口Codex 额度是个很容易让人误解的东西它不是你这台机器上的资源而是账号级的。也就是说你在公司电脑上消耗的额度家里这台机器的面板上也会看到变化。搞清楚这一点之后取数思路就明确了先拿到这个账号的身份凭据再去请求用量接口。Codex CLI 登录之后会把凭据存在~/.codex/auth.json文件里里面有一个 key 字段。第一步就是从这里面把 key 读出来然后带上它去请求账号用量接口。我写的取数函数大概长这样import json import requests from pathlib import Path USAGE_URL https://api.example.com/v1/usage # 以你账号实际接口为准 def codex_quota(): cfg json.loads(Path.home().joinpath(.codex/auth.json).read_text()) token cfg.get(OPENAI_API_KEY) or cfg.get(token) resp requests.get( USAGE_URL, headers{Authorization: fBearer {token}}, timeout8, ) payload resp.json() used _pick(payload, [aggregated_request_count.count, total_usage, data.total]) limit _pick(payload, [limit.max_request_count, limit, quota]) return used, limit注意_pick这个容错解析是必须的。不同账号类型的用量接口返回字段并不完全一致我第一次接入时按网上教程写的解析结果接口返回结构对不上多了个嵌套层级。我的建议是第一次跑的时候把返回 JSON 原样打印出来对着实际结构把 mapping 调对别照抄任何人的字段名。如果用量接口在你的账号上不可用比如权限不够、返回 403也不必卡死。我给模块留了一个手动兜底模式在配置文件里填一个估算的manual_quota { used 1200, limit 1500 }面板照样显示进度条只是数据来源变成“人工输入”。对个人开发者来说这个兜底模式的可用性并不比接口差太多。3.2 展示与刷新节奏进度条要红得及时但别每秒都去刷额度的展示我做了两件事一根总进度条加上一行小字“本月已用 1203 / 1500 · 剩余约 45 次”。当使用率超过 90% 时进度条整体变红这一条足以触发我的预算焦虑比任何提醒都有效。刷新间隔我设成 1800 秒也就是半小时一次。额度的变化只发生在你每次发起请求之后半小时刷新已经完全够用。很多人会把这种模块做成 5 秒刷一次纯粹是浪费既不会让数据更准确还会白白消耗请求次数。反而是“面板刚启动时立即拉一次”这个细节值得做保证你一开机看到的就是最新状态。这个模块踩坑的点不在技术在预期管理面板显示的是整个账号的用量你在另一台机器上跑了任务这里数字一样会跳。一开始我以为是自己代码写错了后来才发现是账号级统计在起作用。这个特性不是 bug想清楚就好。4. GPU 占用模块接替 nvidia-smi 的班4.1 取数命令解析和 NVML 我都试过最后留在 NVMLGPU 占用是这四个模块里刷新最频繁的。最朴素的做法是定时跑一次 nvidia-sminvidia-smi --query-gpuutilization.gpu,memory.used,memory.total,temperature.gpu,power.draw --formatcsv,noheader,nounits这个命令本身开销不大2 秒跑一次也没问题输出解析也简单适合快速验证。但真正长期挂在面板上我换成了 pynvml也就是 NVIDIA 官方的 NVML Python 绑定。原因是它能避免每次起一个子进程的开销而且拿到的字段更结构化import pynvml pynvml.nvmlInit() handle pynvml.nvmlDeviceGetHandleByIndex(0) util pynvml.nvmlDeviceGetUtilizationRates(handle) mem pynvml.nvmlDeviceGetMemoryInfo(handle) temp pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMP_GPU) power pynvml.nvmlDeviceGetPowerUsage(handle)我最终的渲染格式是每张卡一行小条GPU0 ██████░░░░ 58% · 13.4/24G · 66°C · 112W。利用率画成 10 格字符条温度和显存用数字直接跟在后面。多卡机器就循环nvmlDeviceGetCount每个索引一行。4.2 一个看起来没毛病但很坑的点利用率不是瞬时值NVML 返回的 GPU 利用率是大约 1 秒采样窗口内的平均值而不是某个瞬间的脉冲值。这意味着如果训练脚本有周期性停顿两秒一次的刷新完全能把趋势看明白但如果你把刷新间隔缩到 500 毫秒反而会看到数字剧烈跳动产生“卡了”的错觉。我当时就把间隔从 2 秒改成过 0.5 秒结果面板上的数字像心电图一点参考意义都没有又改回来了。另外两个小坑一是部分型号的卡不暴露温度字段读出来是空值渲染时要做 fallback显示“—”别让整个模块崩溃二是在 WSL 或容器里跑 NVML大概率看不到宿主机 GPU得让取数脚本在宿主机上执行再通过网络把结果传回来。如果以后我换成远程 GPU 机器这个模块的 fetch 函数会自动演变成一段 SSH 命令架构上不用改只换数据来源。5. 会议 ddl 模块把日历变成“还剩 47 分钟”5.1 iCal 私有订阅地址是最省事的接入方式按会议 ddl 最自然的做法是接日历。很多人第一反应是去接日历服务商的开放 API但那个流程要处理 OAuth、令牌刷新、权限申请对一个只想显示“下一场会议还有多久”的面板来说太重了。我的选择是直接用日历服务导出的“私有 iCal 订阅地址”。绝大多数日历服务都有一个“secret address in iCal format”本质是一个带私有 token 的 .ics 文件 URL。拿它当数据源只需要 GET 一次然后解析标准 iCalendar 格式完全没有 OAuth 的负担。面板每 300 秒拉一次足够覆盖所有日程变化。解析我用的是 Python 的 icalendar 库代码不复杂import requests from icalendar import Calendar from datetime import datetime cal Calendar.from_ical(requests.get(CAL_URL, timeout8).text) now datetime.now().astimezone() upcoming [] for ev in cal.walk(VEVENT): start ev.get(DTSTART).dt if not isinstance(start, datetime): continue # 全天事件跳过 if start.tzinfo is None: start start.replace(tzinfonow.tzinfo) # 浮动时间按本地时区处理 summary str(ev.get(SUMMARY, 无标题)) upcoming.append((start, summary)) upcoming sorted(upcoming)显示端我做的是一个“下一场”卡片事件名 开始时间 倒计时。倒计时离 15 分钟以内时整块变色加深一眼就知道该动身了。如果未来 24 小时内还有第二、第三场也列在下方但颜色逐级降低避免信息过载。5.2 时区、全天事件、循环会议三个必经之坑第一个坑是浮动时间。有些日历导出的 .ics 里DTSTART 没有时区信息解析出来是一个 naive datetime。如果不做处理直接拿去和本地时间比较夏令时切换的几周内会差整整一小时。处理方式就是上面代码里那句tzinfo为空时按本地时区补齐。第二个坑是全天事件。生日、假期这类事件在 .ics 里是 DATE 类型不是 DATETIME。它们没有“今天几点”的概念直接参与倒计时计算会得到负数或者无效值。我的办法是无脑跳过反正面板关注的是“接下来几点要开会”不是“今天是什么日子”。第三个坑是循环会议。每周例会、每日站会在 .ics 里表现为一个 RRULE 规则直接把 VEVENT 拿过来是看不到下一次实例的。千万别自己写递归展开逻辑日历服务对规则的解释千奇百怪手写必翻车。直接用现成的 recurring-ical-events 库让它把 RRULE 展开成接下来 N 天的具体实例再取最近的一条。这一步能省掉你至少一晚上调试时间。6. arXiv 论文模块早上八点自动把新结果贴在屏边6.1 用 arXiv API 而不是爬网页arXiv 论文这个模块我一开始想在每天固定时间去抓网页摘要后来发现它提供了标准的官方 API原子地解决取数问题。请求 URL 可以直接带查询条件我用的是按分类加关键词的双重过滤import feedparser # API 返回 Atom XMLfeedparser 直接解析 ARXIV_URL ( http://export.arxiv.org/api/query? search_querycat:cs.CLANDall:transformer sortBysubmittedDatesortOrderdescending max_results15 ) feed feedparser.parse(ARXIV_URL)每个 entry 的标题就是论文标题id字段是 arXiv 的绝对链接里面的那串数字就是论文 ID天然适合做去重。我的做法是维护一个seen.json文件每次拉到新结果后把不在 seen 里的挑出来更新面板再把 ID 写回文件。这样重启面板也不会重复提醒。面板上只显示最近三条标题前标一个绿色小圆点表示“新”附上论文 ID点击直接调起浏览器打开详情页。摘要我刻意不贴——面板空间就那么宽标题加 ID 已经足够判断一篇论文是否值得点开把摘要塞进去会把它变成一个阅读器完全跑偏。6.2 关键词打分与刷新节奏纯按分类拉回来的论文会混进大量不相关的。我加了一层很轻量的“关键词打分”本地维护一个自己关心的话题词表比如 code agent、visual grounding、RLHF、context window 这些每篇论文的标题命中一个词就加 1 分排序时分数高的排前面。这个逻辑不严谨但非常有效它让面板从“今天有什么新论文”变成了“今天有什么和我相关的论文”。刷新节奏我设在 6 小时一次每天上午八点前后让它自动跑一遍。注意 arXiv 的 submittedDate 是 UTC 时间所以面板文案我从不写“今天”只写“最近 24 小时”避免跨时区的日期错觉。另外官方 API 明确要求请求间隔至少 3 秒我这种 6 小时才拉一次的用法远低于限制完全不用担心。只要别把四个模块都调到同一秒并发去拉就不会有任何问题——这就是第一章节强调独立轮询带来的好处。7. 把所有钉子钉在一起的工程细节与踩坑记录7.1 置顶、透明、穿透、吸附桌面小窗四件套把四个独立模块绑定到同一个窄条面板之后剩下的问题都是桌面窗口层面的。这个窄条需要具备四个特征永远置顶、无系统装饰、半透明背景、鼠标移上去才变成可交互状态。Tauri 和 Electron 都支持这些只是字段名略有差异大致对应alwaysOnTop: true、decorations: false、transparent: true、focusable: false这几个开关。吸附逻辑说起来很简单窗口启动时拿到主显示器的工作区范围减去任务栏和程序坞然后把自己的坐标算到最右侧。重要的是要支持手动偏移——我第一天就把面板全贴到最右边结果和滚动条重叠难受得很后来加了一个offset配置项允许把面板往左挪 10 到 20 像素这才顺眼。这里有一个必须提前知道的坑透明窗口并不是所有显卡驱动下都表现良好。我遇到过面板区域随机闪烁黑块的情况排查了很久驱动最后发现是透明合成层和某次系统更新冲突。兜底方案很朴素关掉透明改用“无边框 不透明 深色底 圆角”视觉上差别不大稳定性提升明显。如果你遇到类似问题优先考虑关透明别在驱动上钻牛角尖。7.2 资源占用与渲染优化这个面板跑起来的资源占用是很多人最关心的问题。Tauri 方案下整个窗口常驻内存大概几十兆四个数据源里只有 GPU 模块是 2 秒一跳其余都是分钟级或小时级CPU 空闲时的占用率基本可以忽略。如果是 Electron内存会高一些但也不至于影响日常使用。渲染优化我只做了一件事更新 DOM 前先对比一下新值和旧值没变化就不碰界面。别看这事简单它避免了“每秒重画一次整个面板”的浪费也让面板不会闪烁。另一个小经验是让每个模块在右上角显示自己的“更新时间”比如“2 分钟前”“3 小时前”。出现数据源异常时你一眼就能判断哪个模块已经 stale而不是对着一个看似正常的数字猜半天。7.3 收尾的两个小经验第一个是快捷键临时隐藏。置顶面板最大的副作用是它会压在一切窗口上面包括全屏视频和演示文稿。我绑了一个全局快捷键CmdShiftH一键把整个面板藏起来再按一次恢复。这个功能建议你一开始就做不要等到被全屏演示挡住屏幕时才想起来。第二个是多显示器。配置里加了一个monitor字段默认主屏手动改成副屏之后面板会贴到副屏右边缘。我的实际使用场景是主屏写代码副屏挂面板正好把信息放在主屏注意力余光能扫到、又不会抢占工作区的角落。这个布局纯粹是个人习惯但多显示器支持这个能力肯定要做因为现在没多少人单屏干活了。8. 更轻的替代不想写桌面应用时也有两三条路如果你评估下来觉得自己并不需要一个常驻面板或者不想为它引入一套桌面应用框架也有更轻的路线可以试。我先说结论桌面常驻面板适合“每天要看很多次”的高频信息如果你的需求是一天看一次轻量方案性价比反而更高。第一个替代是终端方案。如果你平时就泡在 tmux 里完全可以把 GPU 占用和日历倒计时塞进 tmux 的 status barGPU 用一个执行 nvidia-smi 的 shell 命令片段日历用calendar命令或者一个简单脚本输出下一场会议时间。它永远挂在终端底部不占额外窗口缺点是只能显示文本没法画漂亮的进度条而且只在终端里可见。第二个替代是系统自带的小组件机制。Linux 桌面上的 Conky 类工具可以显示系统指标、订阅 RSS几天就能配出一个边缘信息条macOS 和 Windows 的现代系统也都提供了桌面小组件能力把 GPU 数据写成一个小组件并不难。代价是每个系统要各写一遍但我看很多朋友其实只需要在一个系统上长期使用那这反而是最快路线。第三个替代是浏览器侧边栏。把所有取数逻辑做成一个 HTML 页面让四个模块在里面独立刷新然后丢进浏览器的侧边栏面板里。好处是零安装、跨平台缺点是浏览器本身要常驻而且侧边栏的可视宽度通常不如桌面窄条稳定。我自己的第一个原型就是这么跑的验证了数据链路之后才决定做成桌面窗口。权衡的标准就一句话你看它的频率高不高。每天看十次以上值得做桌面面板偶尔看一次浏览器侧边栏或者终端 status bar 完全够用。别为了一个低频需求承担常驻程序的所有维护成本。跑了两周之后我最大的感受其实不是“方便”而是心里那根弦松了。以前每隔一阵子就冒出来的“要不要去看一眼 GPU”“额度是不是快没了”的念头现在有了一个固定的落点信息就在那不会跑不需要我主动想起来。如果你也想做类似的事我建议你先只挂上 GPU 和会议倒计时这两块跑一周确认自己真的习惯边缘常驻这种形态再决定要不要把 Codex 额度和 arXiv 论文也加上去。后者功能上没问题但多一个信息源就多一份注意力成本——不要让面板变成新的焦虑来源。