最近一个月,我桌面上最频繁的动作不是写代码,而是切窗口:写完一句提示词就切出去看 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,拿到结果后写入一个中心 store;UI 层订阅 store 的变化,只在值真正改变时才更新 DOM。代码骨架大致长这样:type Source = { name: string; interval: number; // 毫秒 fetch: () => Promise<unknown>; }; const store = new Map<string, unknown>(); const lastRun = new Map<string, 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": f"Bearer {token}"}, timeout=8, ) 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-smi:nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw --format=csv,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, timeout=8).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(tzinfo=now.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 XML,feedparser 直接解析 ARXIV_URL = ( "http://export.arxiv.org/api/query?" "search_query=cat:cs.CL+AND+all:transformer&" "sortBy=submittedDate&sortOrder=descending&" "max_results=15" ) 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 收尾的两个小经验第一个是快捷键临时隐藏。置顶面板最大的副作用是它会压在一切窗口上面,包括全屏视频和演示文稿。我绑了一个全局快捷键(Cmd+Shift+H),一键把整个面板藏起来,再按一次恢复。这个功能建议你一开始就做,不要等到被全屏演示挡住屏幕时才想起来。第二个是多显示器。配置里加了一个monitor字段,默认主屏,手动改成副屏之后,面板会贴到副屏右边缘。我的实际使用场景是:主屏写代码,副屏挂面板,正好把信息放在主屏注意力余光能扫到、又不会抢占工作区的角落。这个布局纯粹是个人习惯,但多显示器支持这个能力肯定要做,因为现在没多少人单屏干活了。8. 更轻的替代:不想写桌面应用时,也有两三条路如果你评估下来觉得自己并不需要一个常驻面板,或者不想为它引入一套桌面应用框架,也有更轻的路线可以试。我先说结论:桌面常驻面板适合“每天要看很多次”的高频信息;如果你的需求是一天看一次,轻量方案性价比反而更高。第一个替代是终端方案。如果你平时就泡在 tmux 里,完全可以把 GPU 占用和日历倒计时塞进 tmux 的 status bar:GPU 用一个执行 nvidia-smi 的 shell 命令片段,日历用calendar命令或者一个简单脚本输出下一场会议时间。它永远挂在终端底部,不占额外窗口,缺点是只能显示文本,没法画漂亮的进度条,而且只在终端里可见。第二个替代是系统自带的小组件机制。Linux 桌面上的 Conky 类工具可以显示系统指标、订阅 RSS,几天就能配出一个边缘信息条;macOS 和 Windows 的现代系统也都提供了桌面小组件能力,把 GPU 数据写成一个小组件并不难。代价是每个系统要各写一遍,但我看很多朋友其实只需要在一个系统上长期使用,那这反而是最快路线。第三个替代是浏览器侧边栏。把所有取数逻辑做成一个 HTML 页面,让四个模块在里面独立刷新,然后丢进浏览器的侧边栏面板里。好处是零安装、跨平台,缺点是浏览器本身要常驻,而且侧边栏的可视宽度通常不如桌面窄条稳定。我自己的第一个原型就是这么跑的,验证了数据链路之后才决定做成桌面窗口。权衡的标准就一句话:你看它的频率高不高。每天看十次以上,值得做桌面面板;偶尔看一次,浏览器侧边栏或者终端 status bar 完全够用。别为了一个低频需求承担常驻程序的所有维护成本。跑了两周之后,我最大的感受其实不是“方便”,而是心里那根弦松了。以前每隔一阵子就冒出来的“要不要去看一眼 GPU”“额度是不是快没了”的念头,现在有了一个固定的落点:信息就在那,不会跑,不需要我主动想起来。如果你也想做类似的事,我建议你先只挂上 GPU 和会议倒计时这两块跑一周,确认自己真的习惯边缘常驻这种形态,再决定要不要把 Codex 额度和 arXiv 论文也加上去。后者功能上没问题,但多一个信息源,就多一份注意力成本——不要让面板变成新的焦虑来源。
最近一个月,我桌面上最频繁的动作不是写代码,而是切窗口:写完一句提示词就切出去看 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,拿到结果后写入一个中心 store;UI 层订阅 store 的变化,只在值真正改变时才更新 DOM。代码骨架大致长这样:type Source = { name: string; interval: number; // 毫秒 fetch: () => Promise<unknown>; }; const store = new Map<string, unknown>(); const lastRun = new Map<string, 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": f"Bearer {token}"}, timeout=8, ) 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-smi:nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw --format=csv,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, timeout=8).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(tzinfo=now.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 XML,feedparser 直接解析 ARXIV_URL = ( "http://export.arxiv.org/api/query?" "search_query=cat:cs.CL+AND+all:transformer&" "sortBy=submittedDate&sortOrder=descending&" "max_results=15" ) 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 收尾的两个小经验第一个是快捷键临时隐藏。置顶面板最大的副作用是它会压在一切窗口上面,包括全屏视频和演示文稿。我绑了一个全局快捷键(Cmd+Shift+H),一键把整个面板藏起来,再按一次恢复。这个功能建议你一开始就做,不要等到被全屏演示挡住屏幕时才想起来。第二个是多显示器。配置里加了一个monitor字段,默认主屏,手动改成副屏之后,面板会贴到副屏右边缘。我的实际使用场景是:主屏写代码,副屏挂面板,正好把信息放在主屏注意力余光能扫到、又不会抢占工作区的角落。这个布局纯粹是个人习惯,但多显示器支持这个能力肯定要做,因为现在没多少人单屏干活了。8. 更轻的替代:不想写桌面应用时,也有两三条路如果你评估下来觉得自己并不需要一个常驻面板,或者不想为它引入一套桌面应用框架,也有更轻的路线可以试。我先说结论:桌面常驻面板适合“每天要看很多次”的高频信息;如果你的需求是一天看一次,轻量方案性价比反而更高。第一个替代是终端方案。如果你平时就泡在 tmux 里,完全可以把 GPU 占用和日历倒计时塞进 tmux 的 status bar:GPU 用一个执行 nvidia-smi 的 shell 命令片段,日历用calendar命令或者一个简单脚本输出下一场会议时间。它永远挂在终端底部,不占额外窗口,缺点是只能显示文本,没法画漂亮的进度条,而且只在终端里可见。第二个替代是系统自带的小组件机制。Linux 桌面上的 Conky 类工具可以显示系统指标、订阅 RSS,几天就能配出一个边缘信息条;macOS 和 Windows 的现代系统也都提供了桌面小组件能力,把 GPU 数据写成一个小组件并不难。代价是每个系统要各写一遍,但我看很多朋友其实只需要在一个系统上长期使用,那这反而是最快路线。第三个替代是浏览器侧边栏。把所有取数逻辑做成一个 HTML 页面,让四个模块在里面独立刷新,然后丢进浏览器的侧边栏面板里。好处是零安装、跨平台,缺点是浏览器本身要常驻,而且侧边栏的可视宽度通常不如桌面窄条稳定。我自己的第一个原型就是这么跑的,验证了数据链路之后才决定做成桌面窗口。权衡的标准就一句话:你看它的频率高不高。每天看十次以上,值得做桌面面板;偶尔看一次,浏览器侧边栏或者终端 status bar 完全够用。别为了一个低频需求承担常驻程序的所有维护成本。跑了两周之后,我最大的感受其实不是“方便”,而是心里那根弦松了。以前每隔一阵子就冒出来的“要不要去看一眼 GPU”“额度是不是快没了”的念头,现在有了一个固定的落点:信息就在那,不会跑,不需要我主动想起来。如果你也想做类似的事,我建议你先只挂上 GPU 和会议倒计时这两块跑一周,确认自己真的习惯边缘常驻这种形态,再决定要不要把 Codex 额度和 arXiv 论文也加上去。后者功能上没问题,但多一个信息源,就多一份注意力成本——不要让面板变成新的焦虑来源。