CodeBuddy
@tnnevol/dsh-codebuddy 为 DSH 接入腾讯 CodeBuddy 模型目录,通过浏览器 OAuth 登录,无需 API Key。当前版本见插件总览,适配 DSH 0.1.5-rc.2。
账户信息弹框
点账号卡片打开,两级 Tab:
[身份信息 n] [用量信息 n]
└ 套餐状态
├ [可使用 n] ├ [已用完 n] └ [已过期 n]顶层按「账号是什么」与「账号用了多少」划分,两者语义互斥;套餐的三个生命周期属于用量这一侧,提到顶层会让读者以为它们与「身份信息」同级。层级用 Tab 类型区分,顶层 line(像分区)、二级 button(像筛选),同用线型会看起来平级。
身份页是一张单列表,顺序按读者的追问链排:标识(UID / 昵称 / 备注名 / UIN / 企业名 / 企业 ID / 企业用户名 / 部门)→ 归属与服务(账号类型 / 客户端与版本 / 网络环境 / 服务端点 / 额度上限)→ 当前状态(每日签到)。合并成一张表而不是拆多张,是因为这些是同一个账号的一组事实,分段会读成割裂的片段。
账号卡片上的套餐行只列名称与到期日:卡片是概览,逐个套餐的用量数字会干扰「这个账号还有哪些套餐能用」这一判断,而同一账号的额度合计已在上方大字给出;用量与进度条留在详情弹框里。已过期行只做弱化着色,不给日期加删除线,删除线原本用于划掉已作废的额度数字,而现在是到期日,日期是事实,划掉会读成「这个日期不算数」。
几处按能力与数据可用性条件渲染,而不是显示否定信息或占位:
checkinOk === false(企业账号不支持签到)时,整行不出现;- 服务端点只在存在时列出(个人账号走默认端点,列出无增量信息);
- 额度上限仅在
creditOk且大于 0 时列出,查询失败时该值为 0,展示「上限 0」会误导。
layout="horizontal" 必须显式写:Semi Descriptions 的 defaultProps 是 vertical(key/value 上下排),漏写会让每项占两行、高度翻倍。
账号切换
三条路径最终都经过 Host:手动(面板/设置页)、主动(额度低于阈值的周期检查)、被动(请求被额度拒绝)。它们共用一个纯决策模块 src/host/switch-policy.ts:
取数(探测额度) → 决策(纯函数) → 执行(带 CAS 的 switchTo)
session switch-policy session把「取数 / 决策 / 执行」分开是为了让规则能被直接断言,决策不读文件、不发请求、不改状态,now 由调用方传入,因此每条判定都能用一组输入测出来,也能在日志里说清「为什么没切」。
关闭「自动切换」开关后,主动与被动两条换号路径都停止,它是总闸,与用户「关掉自动切换 = 不要自动换账号」的预期一致。开关关闭时轮询随即 stopAutoSwitchCycle() 停止,且周期启动是幂等的(重复开启不会叠加定时器),宿主重启后按持久化配置决定是否恢复。
全进程只有一个切换轮询(host 侧)。管理面板本身不轮询(只在切页/手动刷新/账号变化时重取),客户端唯一的定时器是输入框旁的用量指示器(60s),它调用的 usage 端点是只读的、不触发任何换号,panelStatus 同样如此。这个指示器只在选中 CodeBuddy 供应商的模型且「显示额度余量」开启时才挂出,隐藏状态下不建立定时器、也不发首次请求。这样设计是因为账号状态只在 host 侧持有,客户端轮询无法也不应触发切换。
主动切换靠轮询实现:只有周期性探测才能在「没有请求发生」时发现额度将尽,从而在下一个提问到来前把账号换好,这正是它相对纯被动换号的价值(用户不感知一次失败)。纯被动机制(如 DSH 自带的重连)只在失败后触发,替代不了它。周期为 1 分钟:额度是分钟级变化的东西,更密的轮询在多数窗口里探到的变化为零,而每次探测都是一次远端往返。
主动切换的判定顺序,任一条不满足即保持当前账号(decideProactiveTarget 会给出原因码):
- 当前账号存在,且至少有两个账号;
- 当前账号凭据有效,失效则强制切换(不看额度、不受冷却与活跃请求阻挡,因为请求必然失败);
- 当前额度已知,探测失败时不切换。把「未知」当作「不足」会让一次 meter 抖动就轮换所有账号;请求真失败时还有被动路径兜底;
- 当前额度低于阈值(判断用
>=,等于阈值不算「低于」); - 不在冷却期;
- 没有进行中的流式请求(不打断已产出的内容);
- 存在满足约束的候选:凭据有效、额度已知、自身剩余不低于下限、相对当前账号收益差大于最小值。排序按剩余额度降序,相同时按账号在文档中的稳定顺序兜底。
被动切换(decideReactiveTarget)刻意放宽阈值、冷却与收益差,服务端已经明确拒绝,等待无益,但必须排除本请求已尝试过的账号,triedIds 是请求级状态而非全局,否则后续请求会误以为账号已试过。
切换全部走 switchTo(id, expectedActiveId):带期望当前账号做 CAS,探测期间账号若被别的路径改动则放弃本次切换,而不是拿着过期状态覆盖(面板则重新读状态再决策)。
额度探测
额度探测统一走 src/host/usage-probe.ts(TTL 30s + 单飞)。引入之前有 5 处独立探测点(面板 panelStatus、积分页 creditExpiryAll、主动周期、被动切换、session 自身),同一账号常在一轮里被探 2–3 次;更要紧的是面板显示的额度与策略决策用的额度来自两次不同探测,meter 一抖动就会出现「面板说还剩 60%,策略却判不足」这种自相矛盾。
缓存键是「账号 id + 端点」(同一账号换端点算不同目标)。探测失败的结果也在 TTL 内复用,meter 抖动时不该被面板每次刷新都重打一遍。失败用 error 表达、额度耗尽用 remainingPct === 0 表达,两者可区分,展示层据此区分「探测失败」与「额度耗尽」两种空状态:卡片上前者显示「积分查询失败」(失败原因在悬浮提示里),后者是正常卡片显示 0。
面板透出 probedAt / probedFromCache / probeError 三个字段。面板没有自动刷新(只有输入框旁的用量指示器每 60s 拉一次),因此面板开着不动时数据可以陈旧很久;叠加 30s TTL 后,用户看到数字时无法判断它是 3 秒前还是 5 分钟前的。卡片据此在数据陈旧时显示相对时间(formatProbeAge),默认不显示,正常情况下数据本来就有一定年纪,提示只会是噪音;只在陈旧到值得点刷新时出现,并区分「缓存于 N 分钟前」(刷新了但拿到缓存)与「N 分钟前」(一直没刷新)。
缓存条目不变量:result 与 inFlight 从不同时存在。登记在途探测时丢弃 result,探测完成时丢弃 inFlight。这让并发调用不会把上一次的旧结果当成命中(曾用一个空结果占位,导致 5 个并发消费者里只有 1 个拿到真实数据),也让「在途优先」与「缓存优先」两种判断顺序在全部可达状态下等价。改动此处需保留该不变量,测试有对应用例锁住。
写入并发
凭据文档是单个 JSON(全部账号共处一份),写入点分散在 session 与 auth-service 两处(切换、改名、删除、登录、token 刷新)。两个并发写各读一次旧值再各自写回时,后写的那次会整体覆盖前一次,表现为「刚切过去的账号又变回去」「刚删掉的账号复活」「刚改的备注名丢了」。
因此锁不在某个类里,而是在 storage 层(mutateStorage):只有包住「读 → 改 → 写」整个事务,才能挡住跨模块竞态;放在 CodeBuddySession 里只能串行它自己的写入。事务回调收到的是锁内最新的文档,返回新文档即写入,返回 undefined 表示放弃(CAS 失败或目标不存在)。
与 DSH 自带重试的分工
DSH 的 @deepseek-ai/dsh-llm-retry 已随 dsh-base 挂载,工作在整个 agent 请求层面(agent/request-error),默认 maxRetries: 5,自带指数退避(500ms 起、上限 10s、±10% 抖动)与 Retry-After 支持,可重试码为 EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT。
因此 adapter 内层只做官方不做的那件事:
| 故障 | 由谁处理 | 为什么 |
|---|---|---|
| 瞬时故障(网络、5xx、超时) | 外层 | 原地重试即可,换账号无益 |
RATE_LIMIT | 外层 | 限流是服务端对该账号的节流,换账号不解决;官方会按 Retry-After 等待 |
QUOTA 额度耗尽 | 内层 | 官方默认不重试该码,换账号是唯一有效手段 |
这个划分是为了避免两层对同一错误各重试一遍,那会变成乘法关系:外层每次重试都会把内层整个重跑,最坏达「内层次数 × 6」次远端请求。
内层尝试上限由账号数决定(每个账号试一次)而非固定 5:账号少时不该对着空气重试,账号多时也不该被魔数截断。triedAccountIds 是请求级状态,保证同一次请求内不重复使用同一账号。
一旦已经向调用方产出过 chunk,后续错误直接抛出,不换号也不重试:重放会让用户看到重复内容、工具调用被重复执行,并可能造成重复计费。
源码结构
目录按构建产物分层,与同仓库的 dsh-codex-auth-plugin、dsh-fnos-plugin 一致:
src/
index.ts host 入口(tsdown entry,产出 lib/index.js)
host/ 仅宿主侧:adapter / auth-service / codebuddy / session / storage /
usage / travel / token-stats / serialize / sse / translate / …
contracts/ host 与 client 共享的协议常量
client/ 浏览器入口(lib/client.js)与面板
client/store/ 状态管理,按模块拆 store 单元:token-stats(按范围缓存)、
usage-prefs(偏好持久化)、account-epoch(账号代际通知)
client/locales/ 文案按语言拆分:en.ts(键集合的唯一定义)/ zh.ts / index.ts
(对外保持与旧单文件相同的导入形态)
components/ 两个挂载点共用的 UI
styles/ 样式按组件拆分:panel-shell / accounts / add-account-modal /
usage-status / token-panel,index.scss 只做入口聚合注释语言约定:全部注释使用中文(技术名词、标识符、协议值保留英文)。运行时字符串,logger 前缀、错误 message、对外导出的英文字段,不是注释,不受此约束。dayjs 只引 core(import dayjs from 'dayjs',7KB):其 plugins/ 与 locale/ 均为独立子路径,未按需引入的不会被打进产物。
这个划分不是按命名猜的,而是按入口可达性定的:从 src/index.ts 出发可达 14 个模块,从 src/client/index.tsx 出发可达 20 个,两者交集只有 contracts/constants.ts,共享面就这么大,其余一律属于 host。
contracts/ 的存在是为了消除一类真实风险:协议常量曾在两端各写一份。CODEBUDDY_AUTH_CHANNEL 一度在 host/auth-service.ts 与 client/constants.ts 各定义一次,靠注释「mirror of the host constant」维持同步,改一处就会静默对不上(客户端发到 A 频道,宿主在 B 频道听)。现在它只在 contracts/constants.ts 定义,两端都从这里引入。
tests/structure.spec.ts 守住三条:分层目录不被重新打散(src/ 根只留入口)、client 侧不引用 host(否则浏览器产物会拖进宿主模块)、协议常量全仓库只有一个定义。
安装
fn-deepseek-harness 会在安装和升级时自动安装 npm rc 标签对应的版本。其他 DSH 环境可以执行:
dsh plugin --profile web add @tnnevol/dsh-codebuddy@0.1.5-rc.2
dsh --profile web --dump-config安装后重启 Web profile。
fnOS 网关内置反代
CodeBuddy 的 RPC 频道是 /codebuddy(CODEBUDDY_AUTH_CHANNEL),由 DSH 挂成浏览器同级 HTTP 路由。在 fnOS 应用里,浏览器 bridge 只对内置前缀和用户规则补应用前缀,因此该频道已作为内置前缀收录,与 /api、/plugins、/open-in-app 同级:
- 随 FPK 内置安装后开箱可用,不需要在「设置 → fnos → 三方插件 API URL 反代配置」里手工登记;
- 前缀按路径段边界匹配,
/codebuddyx之类的兄弟路径仍留给 fnOS 宿主; - 用户规则只在此基础上追加,不能覆盖或移除内置前缀。
若在非 fnOS 的 DSH 环境里自行部署并需要网关转发该频道,才需要按上面的规则手工添加 /codebuddy。
登录
一切交互都在插件 Web 界面中完成。

打开「设置 → CodeBuddy」,点击「添加账号」。插件会在新标签页打开腾讯 CodeBuddy 授权页面,用户完成登录后插件自动轮询换取令牌并持久化。无需输入任何 API Key,也无需使用任何终端命令。
登录成功后,该账号会出现在「账号管理」折叠面板列表中并成为当前账号。展开任意账号面板可查看昵称、UID、企业等详细信息,并可执行「设为当前」或「删除账号」。
多账号管理(设置页)

「设置 → CodeBuddy」的「账号管理」列出所有已登录账号,每个账号是一个可展开面板:
添加账号:点击「添加账号」(右侧文字主按钮),可选填写备注名(≤30 字)与网络环境;打开浏览器登录后追加账号,当前账号保持不变;重复登录同一账号会刷新凭据(用户设过的备注名保留)不产生重复条目。

不抢占当前账号:弹框调用
startLogin时显式传activate: false。host 的activate默认为true,不传就会让新账号成为当前账号,用户只是想多存一个备用账号,正在用的账号却被静默换掉,后续请求全部改走新账号。切换当前账号有独立入口(卡片菜单的「设为当前账号」与自动切换策略),登录不顺带替用户做这个决定。规则集中在src/host/storage.ts的nextActiveId(),两种例外都由它兜住:① 一个账号都没有时无视activate,新账号必须成为当前账号,否则会留下「有账号却没有当前账号」的空悬状态;② 重复登录已存在的账号用activate || wasActive判断,刷新当前账号凭据不会把它自己挤下去(该保护的前提是复用分支沿用existing.id)。登录反馈闭环:弹框里的「打开登录」按钮从点击起持续 loading(先是握手在途,随后是等待浏览器授权),直到登录彻底落定。落定后用通知组件提示结果:成功提示「登录成功,账号已添加」并自动关闭弹框;超时或失败也给出通知,但保留弹框与已填字段,用户可直接再点一次重试,不必重新填备注名、客户端与环境。轮询归弹框所有(只有它知道这次登录由它发起、也只有它能决定关闭自己),宿主页面只负责开浏览器窗口与登录成功后刷新名册,不重复轮询同一个 state,两处同时轮询会对
pollLogin发双份请求并让提示出现两次。登录在途时整表单只读:点「打开登录」后,表单的全部字段(备注名、客户端、环境、企业服务地址、企业开关)一律禁用,直到登录落定。理由是字段已随握手发给了 host(客户端与环境决定登录端点),此刻再改只会让界面显示的与这次登录实际用的不一致;要改应先取消重来。解除禁用有三种情形:重新打开弹框、登录成功、授权轮询超时或失败,都归结为
waiting(handshaking || pendingState !== undefined)变回 false。关闭弹框即放弃本次等待:用「取消」、右上角 X 或 ESC 关闭弹框,会一并结束这次登录等待,停止客户端轮询、清掉弹框主按钮的 loading、并通知宿主把「登录中」标记落回(否则宿主的「添加账号」按钮会一直禁用)。遮罩点击被禁用(
maskClosable={false}),避免误触中断登录。重开弹框是干净的初始态。关框还会作废在途的握手(
generationRef代计数):点「打开登录」后立刻关框时,startLogin的 RPC 仍在途,它返回后原本会继续走submit()的后半段,把handshaking又置回 true(重开的弹框仍是禁用态、主按钮一直转),并setPendingState+onLoginStart替一次已取消的登录弹开浏览器登录页。因此close()推进代、submit在await后比对,不一致即整体丢弃(不置 state、不开窗、也不回报onFinished,宿主的「登录中」标记由onLoginStart置起,这次从未置起,回报会让它误以为发生过一次失败登录)。关框时
setHandshaking(false)必须放在if (pendingState !== undefined)之外:握手在途时pendingState还是 undefined(它要等握手返回才被 set),只在 if 内清理会让handshaking停在 true,waiting于是保持为真,重开弹框后表单依旧禁用,与「重新打开即解除」不符。一处边界值得知道:这里停的是客户端轮询。host 侧
pollAuthToken那条长轮询没有取消端点,会自行在LOGIN_TIMEOUT_MS(10 分钟)后到期并回收pending条目。因此关框后若用户仍在浏览器里完成了授权,账号依然会被 host 落库,只是本次不再由弹框提示与自动关框,刷新或重开面板即可看到该账号。真正即时掐断需要 host 新增取消端点。弹框里原有的「复制登录链接」按钮已去除。auth 链接由 host 向官方
/auth/state握手后才签发(内含服务端下发的一次性state),客户端无法在提交前算出它,因此做不到「按钮一直可用且内容随客户端/网络环境更新」;提交前只能放一个永远禁用的按钮,没有意义。跨设备授权改由浏览器自身的分享/复制地址能力承担。掉线账号「重新登录」流程的等待提示同样只保留文字说明。卡片等高:账号卡片网格里,拉取不到账户数据的卡片(
creditOk === false的「积分查询失败」、row.expired的「已离线」)此前只渲染一行文字,而正常卡片渲染「额度大字 26px + 进度条 + 两行资源包预留(42px)」约 92px 的正文,于是同一栅格行内底部参差。修法分两层:① 把栅格行高沿wrap → Card → .semi-card-body用height: 100%传下去(栅格默认会 stretch 子项,但内部 Card 只设了width: 100%,仍是内容高度);② 把.semi-card-body改为纵向 flex,正文用flex: 1 1 auto吸收剩余高度,两个失败分支改走.dsh-codebuddy-account-body-state状态块(min-height: 96px,与正常正文相当,用min-height而非固定height以便内容变多时自然增高)。新增规则一律限定在.dsh-codebuddy-account-card-wrap下,.dsh-codebuddy-panel-cards是账号页 / 骨架 / 积分统计三处共用的容器类,直接改动.dsh-codebuddy-panel-card会波及其它页面。面板头部:账号名 + 状态 Tag(当前/掉线)+ 编辑备注名图标(始终内联在 semi-collapse-header 中)+ 掉线时的重新登录按钮。手动切换位于展开后的账号信息区,编辑弹框预填当前备注名,清空即恢复昵称。
备注名默认就是昵称:登录时若用户没填备注名(或填了空白),buildAccountEntry 会把昵称落盘为 label,而不是留空。这样「备注名」在凭据文档、日志和任何直接读文档的地方都是自解释的,不必各自实现一遍 label ?? nickname 回落;用户之后通过重命名覆盖即可。昵称本身为空(服务端未返回)时省略该字段,让「没有名字」这个事实保持可见。
重登录时保留用户设过的备注名:判据是「本次登录有没有显式给 label」(options.label),不是「结果里有没有 label」, 因为回落之后后者恒为真,用它判断会把用户的备注名覆盖成昵称(实测:公司账号 被 m6440216j102 覆盖)。这与 client 字段的处理是同一个模式。
剩余额度:每个账号头部与详情行都展示 meter 实测的剩余额度;无可用余额的账号,「设为当前」与「选择账号」自动禁用并给出提示。
隐藏手动切换入口的两种情形:已选中的账号不再渲染「选择账号」按钮(它此前无条件渲染,而
disabled里含... && account.id !== activeId,对当前账号恒为 false,于是非切换状态下可点却无效果,自己切自己);开启自动切换时也对所有账号隐藏(账号由策略按剩余额度接管,手动指定会被下一次自动切换覆盖,留一个按不动的按钮只会让人以为设置没生效)。两种情形都由switching放行:切换在途时仍渲染「切换中…」与转圈,否则用户点了按钮它就直接消失,看不出请求是否发出。两处手动入口语义现已一致:设置区块的「选择账号」(
hideForAutoSwitch = autoSwitch && !switching)与管理面板卡片菜单的「设为当前」(!row.active && !autoSwitch)都用同一个$autoSwitch开关。此前设置区块只做成「禁用非当前账号」,同一个开关在两端反应不同,且禁用会留下一个 Tab 可达但按不动的元素,而不是「这个操作此刻不存在」。
需求 002、003 验收记录
- 需求 002:自动切换开启时隐藏所有手动切换入口:设置页和管理面板在
autoSwitch开启时都隐藏所有账号的「选择账号」/「设为当前」入口;当前账号隐藏和切换在途的 loading 状态仍按各自规则处理。 - 需求 003:添加账号登录在途时禁用整表单:点击「打开登录」后禁用备注名、客户端、环境、企业服务地址和企业开关;重新打开弹框、登录成功、授权轮询超时或失败后解除禁用。关闭弹框还会作废仍在途的握手,避免已取消的登录继续开窗或启动轮询。
- 验收状态:需求 002、003 已完成。CodeBuddy 插件检查通过 50 个测试文件、647 条测试;发布前根仓库检查 24/24 通过。
- 账号掉线:凭据过期的账号标记「已掉线」并只保留「重新登录」;掉线的是当前账号时顶部提示由其他账号接管。
- 删除账号:删除本地凭据;删除当前账号自动切换至剩余第一个账号;删除最后一个即退出登录。
- 凭据文件在读取旧版单账号格式时自动迁移为多账号格式,旧登录完整保留为第一个账号。
「添加账号入口位于「账号管理」区块标题右侧(包裹在 .dsh-codebuddy-accounts-head-lead 里,与标题同处区块头左端),右端是次级控件(自动切换 / 自动签到 / 自动旅行 / 刷新),两段由区块头的 justify-content: space-between 分列两端。主操作放左端而非右端:右端是开关与刷新这类次级控件,主操作混在其中会被削弱,贴标题则与「这一屏在管什么」直接相邻。原先页面顶部那张独立操作卡已移除。自动切换账号开关在右端动作区,与设置页共用同一 localStorage 键(互为镜像);开启时卡片菜单里的「设为当前账号」不再渲染,那时账号由客户端按剩余额度自动切换,手动指定会被下一次自动切换覆盖,留着只会让人以为设置没生效。账号页同时订阅 subscribeUsagePref:面板关闭只是 return null、组件并不卸载,不订阅的话在设置页改动开关后本页会一直显示旧状态(自动切换尤其明显,它还决定卡片菜单里入口是否出现)。区块头常驻渲染,不随「有账号」条件渲染,否则账号数为 0 时(恰恰最需要添加账号)入口会消失;列表为空时只把卡片网格换成空状态。动作区现含四个控件,窄屏一行放不下,因此设了 flex-wrap: wrap 并在 ≤720px 竖排时改为左对齐。
管理面板」文字按钮与对话区圆环旁的齿轮都能打开全页面管理后台(shell.overlay,hash 路由隔离)。
用量与偏好
用量余量展示在对话输入区右侧,紧凑圆环样式:
悬停显示「已用额度 / 总量」和重置时间;点击展开浮层按计量窗口列出剩余比例。
图标只在当前选中模型属于 CodeBuddy 供应商时出现,与 Codex 插件在同一位置的用量图标按各自供应商显隐,同一轮对话里不会两个并存;切换模型即时跟随,不需要刷新页面。选中别家供应商时图标不显示,也不会在后台继续轮询用量。
偏好:自动切换(额度不足或被限流时切换账号)、切换阈值(剩余百分比滑块,0 关闭主动切换)、自动签到(每天自动为全部账号签到领取重置额度)、显示额度余量。
偏好的权威方是 Host,采纳必须原子:这几个偏好持久化在浏览器 localStorage,同时 Host 也各存一份(
autoPrefs端点用于读取)。挂载时方向统一为「读 Host → 写本地」,Host 无配置时(hasStoredPrefs === false,老用户首次升级)才把本地值一次性迁移上去。两个挂载入口(设置页、管理面板的useAutoPrefs)共用src/client/store/usage-prefs.ts的adoptHostPrefs(),不得各写一份,它们曾各写一份并漂移:设置页采纳 4 项(三个开关 + 阈值),面板 hook 只采纳 3 个开关,同一份 Host 配置在两端得到不同的本地副本。采纳还必须整组原子(
adoptHostPrefs内部用whileAdoptingPrefs包住):这些是共享的持久化 atom,且被subscribeUsagePref监听(职责是「本地改动 → 推给 Host」),而set会同步触发该监听器,监听器读的是「当前全部偏好」。逐个裸set时,第一个set触发回调而其余尚未采纳,于是它们的本地旧值被推回 Host,把刚从 Host 读到的值覆盖掉,把「Host 为准」反转成「本地为准」。因此监听器在采纳期间不回推。
跨视图状态同步
Host 改了「别处也会展示的账号数据」后必须广播,否则另一个已挂载的视图会停留在旧值。本插件唯一的跨视图同步通道是 notifyModels()(adapter replace → harness 的 llm/adapters-updated → 浏览器端 accountEpoch +1 → 面板与用量指示器重取),因此改名、切换、删除、登录都要调它,而不是「模型目录变了才调」,RPC 的响应只回到发起调用的那个组件。设置页与管理面板都是常驻不卸载的(面板关闭只是 return null),彼此收不到对方的响应。
renameLabel 此前漏了这次广播:设置页改名后自己用响应刷新、立即正确,但面板会一直显示旧备注名,直到手动刷新或碰巧发生别的广播(面板的 rosterTick 是它自己的局部 state,设置页无法触发)。
管理面板
「管理面板」为全页面后台,左上返回按钮关闭,左侧菜单在「账号管理 / Token 统计」间路由(hash 隔离,非动态组件切换)。账号积分总览已并入账号管理页(原「积分统计」菜单项已移除):两处数据同源于 panelStatus,同屏展示后读者不必在菜单间来回切换;旧的 #/codebuddy/credits 链接由未知子页回落规则落到入口页(账号管理),地址栏同时被改写为规范子页,书签不会白屏。DSH 前端自身没有 hash 路由,面板归属完全由插件的前缀匹配决定:#/codebuddy 及其任意子路径都属于面板,裸路由与无法识别的子页在加载时被规范化为具体子页(replaceState,不新增历史记录),因此 /#/codebuddy 刷新后仍停在面板而不是落回会话页。Token 页面采用面向开发者的观测画布:深色总览、趋势图、活动热力图和分布排行;数据通过 DSH 的 logical session/query/projection 能力读取,不直接依赖 JSONL 文件布局。
- 客户端标识:账号记录登录时所用的客户端(
cli/workbuddy)与其固定版本号(CLI 2.148.0、WorkBuddy 5.5.6),卡片上以标签展示。版本是产品发布版本、不随机也不随会话变化,服务端据此归因客户端,随机化会让归因失真。客户端同时决定端点:WorkBuddy 走https://www.workbuddy.cn,不再按环境解析(环境表里没有它,若按环境解析会把请求打到 CodeBuddy 的地址、凭据不被承认)。添加账号时先选客户端;环境选择器只在 CLI 下出现,因为 WorkBuddy 与环境无关,留一个改了没作用的控件会误导用户。缺省客户端为cli,历史条目没有该字段时按 CLI 处理。
标识大小写统一为产品名形态(CLI / WorkBuddy):CODEBUDDY_CLIENT_PLATFORMS 一个取值同时供登录 ?platform= 参数与请求头 X-IDE-Type/X-IDE-Name 使用,因此在这里统一,不再混用小写 workbuddy。改大小写是已核实安全的:服务端不校验(实测 workbuddy/WorkBuddy/WORKBUDDY 都返回 200 并原样回填 authUrl),且登录页前端对比前显式做了 get("platform")?.toLowerCase() === "workbuddy",没有精确匹配。
normalizeClientId 大小写不敏感(trim().toLowerCase() 后比较):标识在不同场合出现过三种写法、存储里也可能残留旧值。若这里大小写敏感,WorkBuddy 会被当成「未知」而回退到 cli,端点、版本、请求标识全错,等于把 WorkBuddy 账号静默降级成 CLI。
登录失败的可见性:
runLogin的失败原因会写入本次握手的 pending 条目(不是实例字段,并发登录会串台),pollLogin据此把「已失败」与「仍在等待授权」区分开并回传error;客户端轮询遇到error立即停止并显示原因。此前失败与等待都表现为done:false,前端只能一直轮询到 10 分钟超时,用户既看不到原因也不知道该重试,workbuddy 登录「没有反应」正是这个链路。token 载荷的命名容忍:服务端在不同客户端/网关下可能用 camelCase 或 snake_case 返回同一组字段(
access_token/refresh_token/expires_in/refresh_expires_in)。normalizeAuthToken对每个字段同时容忍两种写法(参考实现 workbuddy-switch 的 oauth 解析亦如此):只认一种会解析出undefined,进而发出Authorization: Bearer undefined并收到 401。缺accessToken时返回undefined按失败处理,而不是带着残缺对象继续走;domain缺失归一化为空串,避免字符串"undefined"进入X-Domain。AuthToken的时长/刷新字段因此标为可选,取用处一律用?? 0/?? 原值兜底,直接用undefined参与乘法会得到NaN,而NaN比较恒为false,会让「已过期」判断静默失效。版本号归属三条产品线,不要混用:
CODEBUDDY_CLI_VERSION对应@tencent-ai/codebuddy-code(CLI,当前 2.148.0),用在X-IDE-Version/User-Agent/ 登录页version参数;CODEBUDDY_IDE_VERSION对应 CodeBuddyIDE(VS Code 扩展,4.9.8),只用在 meter/travel 请求的User-Agent;CODEBUDDY_CLIENT_VERSIONS.workbuddy对应 WorkBuddy 客户端(5.5.6)。三者版本序列互不相关。官方 CLI 发的是自己的 package.json version(源码getCurrentPackageJson()取值),所以 CLI 版本必须跟随上游正式发布更新,核对命令npm view @tencent-ai/codebuddy-code dist-tags.latest,只取正式版、不要 dev/next 预发布号。已实测服务端不把X-IDE-Version当作鉴权门槛(新旧版本都能通过鉴权),它用于归因,因此滞后不会报错、但会让归因失真。登录协议:两个客户端共用一套握手(
/v2/plugin/auth/state与/auth/token,待登录码同为 11217),只是platform参数不同(CLI/workbuddy);服务端把该参数原样回填进authUrl,登录页因此指向对应服务。已实测:requestAuthState('https://www.workbuddy.cn', 'workbuddy')生成https://www.workbuddy.cn/login?platform=workbuddy&state=…&version=5.5.6。账号管理:顶部提供 OAuth 添加账号操作,账号区显示数量、刷新入口(「自动签到」「自动旅行」开关位于刷新按钮左侧)和响应式卡片网格;卡片包含头像、名称/环境、签到状态、旅行状态、剩余额度、资源包进度。卡片右上「…」菜单提供改备注 / 签到(自动签到关闭且未签到时)/ 设为当前(无可用余额时禁用)/ 删除;企业账号不显示签到项。签到走 meter 平面 checkin-activity-status / daily-checkin,与 workbuddy-switch 同一协议;签到状态接口为 POST-only(GET 会返回 HTTP 404),插件统一以 POST 查询。
资源包台账:点击账号卡片打开弹框,按「可使用 / 已用完 / 已过期」三组列出该账号的全部资源包(剩余/总量、用量进度、到期时间)。meter 平面只返回生效中的包,因此客户端会把每次探测到的资源包记入本地台账(
dsh-codebuddy:resource-history),不再返回的包归入「已过期」,删除账号时一并清理。自动签到:「自动签到」开关(默认开)位于账号管理页的刷新按钮左侧。开启后宿主启动即对全部账号执行一轮自动签到(已签到的跳过),此后每 30 分钟补一次;凭据过期的账号记录为 expired,企业账号跳过,不会重复提交。设置页的偏好区同样提供该开关。
派猫猫旅行:账号卡片的旅行 chip 显示当前状态(未旅行 / 旅行中并倒计时 / 今日已旅行 / 暂无猫猫)。「暂无猫猫」只以派发失败文案
no active buddy为依据:status.buddy_id表示的是当前正在旅行的猫猫 id,未派发时服务端一律返回 0,派发成功后才变成真实 id(实测 0 → 7317310)。曾误用它判断「是否拥有猫猫」,导致从未派发过的账号被永久拦在派发之外,越没派过越被拦,卡片因此一直显示「暂无猫猫」。「自动旅行」开关(默认开,位于「自动签到」右侧)开启后,宿主启动即按状态机推进一轮,此后派发周期每 30 分钟补派、领取周期每 15 分钟只处理已到点的奖励。两个周期职责分离:派发周期查状态并按状态机推进,领取周期只对arrived的账号调 claim,不做任何派发,因此跑得再勤也不会重复派发或反复试探地点列表。状态机以服务端data.state为准:idle --depart--> traveling --到点--> arrived --claim--> idle;idle且daily_limit_reached是官网的「累了,明天再来吧」,当日不再派发。接口为成长中心/activity/growth/buddy/travel/{config,status,depart,claim},与 meter 平面不同,额外要求x-client-platform: web与origin/referer语境头。成长中心仅对个人账号开放(企业账号返回 403),企业账号自动跳过;没有 Buddy 的账号派发会被拒,记为可重试原因而非当日完成。设置页的偏好区同样提供该开关。积分总览(位于账号管理页顶部):原「积分统计」页的指标卡,提供剩余额度、积分包数量、可用账号、已掉线四项总览,带「积分」区块标题。它不再自己拉取数据,而是由账号页把同一份
PanelAccountRow[]以 props 传入,两处同源,既省一次 RPC,也避免数据短暂不一致。按账号列出资源包进度与到期时间(长期有效/重置时间)的明细仍在各账号卡片与其「资源包」弹框中。Token 统计:只统计 DSH 会话中
codebuddy供应商的调用,提供总览、输入/输出/缓存指标、缓存命中率、按日 Token 与调用趋势、Token 活动、按工作区/模型可切换的分布与排行,和会话排名;支持最近 7/30/90 天。统计通过sessionQuery.observeSession()读取会话事件,并使用 DSH token-meter 投影保持会话用量语义一致,不上传数据。页面顶部只显示一行「当前数据更新于 yyyy-MM-dd HH:mm:ss」(generatedAt经 dayjs 固定 pattern 格式化),原先这里重复了页面标题与副标题;不用toLocaleString()是因为其分隔符与顺序随运行环境 locale 变化,而该时间戳每次刷新都变,格式不稳定不利扫视。dayjs 是插件独有依赖,已在tsdown.config.ts的alwaysBundle中登记:DSH 浏览器模块表没有它,漏登记会残留裸require('dayjs')并在运行时直接报模块缺失。- 时间范围:默认「近 7 天」;选项按面板职责分配,总览给「总计」(回答「一共用了多少」,需要全量)、趋势给「本月」(回答「随时间怎么变」,需要有意义的当前窗口),两者不互换:把总计放到趋势上逐日图会退化成一根巨柱。选择器用
ButtonGroup(facade 的DshButtonGroup)呈现,而不是 SplitButtonGroup:前者把相邻按钮的圆角相接成一条连续控件,符合「互斥单选一组」的语义。外观完全沿用 Semi 原生样式,插件侧不写任何 CSS 覆盖,激活项theme="solid" type="primary"、其余theme="borderless",底色/圆角/hover/focus 全部交回 Semi 与主题层。
- 时间范围:默认「近 7 天」;选项按面板职责分配,总览给「总计」(回答「一共用了多少」,需要全量)、趋势给「本月」(回答「随时间怎么变」,需要有意义的当前窗口),两者不互换:把总计放到趋势上逐日图会退化成一根巨柱。选择器用
组上不传 theme / type:ButtonGroup 合并子 props 的顺序是 {disabled,size,type} → itm.props → rest,而 theme 不在其解构出的键里,会落进 rest 并排在子 props 之后,组上的值因此覆盖每个子按钮的值、激活态永远显不出来(size 被解构出去,可安全传递)。选中态另用 aria-pressed 表达,因为纯视觉的 theme 切换对读屏不可见。
对比度由主题层保证,插件不介入:Semi 自身的实心按钮写死 color: rgba(var(--semi-white), 1),而 DSH 深色主题下 --dsw-alias-button-primary-fill 解析为浅色(→ brand-primary → bluish-50),白字对比度仅 1.08:1。所幸 packages/dsh-semi-ui 的 theme.scss 已为 .semi-button-primary.semi-button-solid 分浅色/深色指定硬编码的高对比配对(浅色:bluish-1000 底 + bluish-00 字;深色反之),因此这里既不需要覆盖文字色,也不需要隐藏 ButtonGroup 自动插入的分隔线 <span class="semi-button-group-line-*">,那是组件正常产物,原型样式下渲染正常。
另外,组容器上不能设 gap:分段控件靠相邻圆角相接表达「一组」,有间隙就断了。曾有一条遗留布局规则带 gap: 8px(早于 ButtonGroup 改造),是「按钮之间有空隙」的实际来源,已删除并在测试中锁住。「本月」解析为 days = 今天几号,正好落在本月 1 号(服务端起点是 startOfLocalDay(now - (days-1)*DAY_MS)),无需服务端支持「月」这种单位;「总计」走 allTime 而非大 days,days 有 365 上限,超过一年的历史会被静默截断,而总计的语义是全部。缓存以范围键为键而非 days:month 在 30 号时与 30d 天数相同但请求不同,用 days 会互相污染。
- 图例 item 最小高度 20px:ECharts 图例的高度由
itemHeight决定,且色块是 roundRect、按 (itemWidth, itemHeight) 直接铺开、不保持宽高比,实测只把itemHeight调到 20 会把 10×10 的色块拉成 10×20 的竖条,而symbolKeepAspect: true对 roundRect 无效(path 完全不变)。因此必须让itemWidth === itemHeight(20×20)。另外图例变宽后更早折行:实测英文长标签(Cache write)约 340px 起折、中文约 260px 起折,折行后图例高 66px 会压住固定的grid.top: 32;用 ECharts 的media在maxWidth: 420时把grid.top抬到 68,单行保持紧凑、折行自动让位。 - 周期选择器落在每个面板内部(总览 / 趋势 / 工作区分布 / 模型分布 / 会话排行各一个):这些面板回答不同问题,读者常需要让它们停在不同的时间窗口上对比,全局选择器会强迫所有面板同时跳变。Token 活动热力图固定为最近一年(服务端
ACTIVITY_RANGE_DAYS与days无关),因此不提供周期选择器。 - 用量分布与模型排行的维度切换:两个面板都能按工作区或按模型排行,切换用 ButtonGroup 放在排行卡片内部(列表内容区顶部,
DimensionToggle+dsh-codebuddy-token-card-toolbar),它切换的是这份列表的统计口径,与列表是同一个整体;放面板头部会像在控制整个面板(含周期选择器)。两面板维度相互独立。默认档沿用历史视角(分布=按工作区、排行=按模型),老读者看到的内容不变。这两个面板的静态副标题已随之删除,副标题原先就是写死的维度说明(「按工作区」「按模型」),改成控件后再用小字重复只会占高度;数据型副标题(总览的「N 个活跃会话」、趋势的「总计 X Token」)保留,它们随数据变化、有信息量。host 无需改动:TokenStats早已同时聚合workspaces与models,此前只是两个面板各用其一。
「模型用量排行」面板已移除:维度可切换后它与分布面板能力完全重合(同数据源、同两维度),保留两个只会同屏出现镜像数据;分布面板独占一行,「按模型」视角保留在维度切换的第二档。
所有带日期档位的面板(总览 / 趋势 / 分布 / 会话排名)头部都有日期范围选择器(PanelRangeControls 统一注入)。标题行分左右两组:左侧 token-panel-lead 是标题 + 日期范围选择器(选择器紧随标题);右侧 token-panel-actions 是维度切换、档位按钮组与刷新(space-between 会把多个控件撑成几块,改为两侧各成一组)。同步语义是单向的:切固定档时把该档的日期区间回填进选择器(纯显示,不发第二次查询,range 变化本身已触发一次);在选择器里改区间则先写窗口天数、再进 custom 档且不回写固定档(按钮组全灭)。
custom档的窗口 = 起点相对今天的天数(终点视为今天,服务端按days计算无需改动)。曾有的回归:只进custom档而漏写窗口天数,resolveRange('custom')恒为 1(今天),选任何区间数据都不变,必须先setCustomRangeDays(days)再onRangeChange('custom'),顺序由测试锁定(注意断言只能认代码行,注释里的同名字样会撞indexOf)。- 选择器非空:初始为今天(
todayRange()),清自定义通过点固定档完成、回到该档的区间,不留白。 - 只能选到今天(
disabledDate把今天之后禁选):统计窗口的终点固定是今天,未来的数据不存在。 - 菜单与内容区的分隔只靠背景色分层:Semi 竖排 Navigation 主规则自带
border-right: 1px solid var(--semi-color-border),已显式border-right: none去掉;sider 自定义的 border 一并移除(原先两条叠加)。
时间档位收敛为「今天 / 近 7 天 / 近 30 天」(默认今天),「总计 / 本月 / 近 90 天」移除。
日期范围选择器的配色只改 semi 变量:Semi 的 DatePicker 全部用 --semi-color-primary* 上色(选中日 primary、范围内日期 primary-light-default、悬停 primary-light-active),因此把这几个变量 scoped 到工具栏与弹层(.semi-popover .semi-datepicker)即可,不写任何 .semi-datepicker-* 的属性覆盖。有一个已知的类型缺陷要留档:DatePickerProps 的接口继承链在 @douyinfe/semi-foundation 上,而它的 package.json 没有 main/module/types/exports 任何入口字段,pnpm 结构下 TS 无法解析(skipLibCheck 静默吞掉),于是除自有字段外的 props(type/density 等)全部「不存在」。已尝试把 foundation 加进 catalog 与相关包依赖,均无效。解法是插件本地声明了实际用到的字段(DatePickerRangeProps),运行时不受影响;上游修好入口字段后删除该接口即可。
- 按范围缓存:面板多起来后各自裸调 RPC 会线性放大开销(服务端每次都重放全部会话,实测 200 会话约 50ms)。客户端
TokenStatsStore按days缓存并复用在途请求,同范围共享一份数据、切回旧范围零成本命中;范围不同才真正多取一次,这是功能本身要求的,因为服务端对 workspaces/models/sessions 的累积带范围过滤,无法从大范围响应推导小范围结果。 - 会话排名只显示标题,不显示会话 id:
user/message事件的正文在data.content(不是data.message,那是assistant/message的形状),DSH 注入的 system-reminder / 运行时上下文也以同一类型出现,取标题时必须跳过。取不到真实用户输入时标题为空串,由客户端用本地化占位呈现,绝不把 uuid 顶上来。 - 热力图铺满内容区:一年恒为 53 周(365 天补位后总是 371 格),列数固定。原先把列宽写死 12px,整块宽度被钉死在 844px,卡片更宽时右侧留白。现由
activity-grid.ts按可用宽度算出格子边长,写进--dcb-cell-size,月份行 / 热力图 / 星期列共用同一个值(星期列宽固定,「一/三/五」的行对齐无法由纯 CSS 从列宽反推)。边长有上下限:低于 9px 会看不清(交外层横向滚动),高于 22px 会显得笨重(此时留白比继续放大好看)。可用宽 ≥ 711px 时正好铺满。 - 分段条按占比降序 + 给小项保留可见下限:占比最大的分段排最左(真实数据里缓存读常占 95% 以上,排在中间会让视觉重心偏移;降序后主项紧贴阅读起点)。排序同时作用于条形与图例。每段先占
--dcb-segment-min(8px)的可见宽度,剩余空间才按数值比例分配,大项仍占绝大多数,小项始终可辨。SEGMENT_MIN_WIDTH(TS,供测试验算)与--dcb-segment-min(CSS,真正生效)必须一致,测试会核对两者取值。实现上用 flex-grow 语义,flex-basis: 0是必需的,否则内容宽度会参与分配,最小宽度被满足后各段比例就不再等于数值比例。 - 趋势图柱体最小高度:
barMinHeight: 30。真实数据里输出仅 0.15%,按比例算出的高度会被四舍五入成 0px,该分段从图上消失(图例有、柱体没有)。堆叠模式下 ECharts 对每个分段独立生效(源码按stackStartValue计算),因此每个堆叠系列都要设置。 - 指标配色单一事实来源:输入/输出/缓存读三个指标在总览分段条、趋势图、分布图里必须同色,否则同一份数据在各面板间颜色跳变。三色定义在
.dsh-codebuddy-panel-tokens的--dcb-series-*,所有面板统一引用;不使用黑/灰阶作为数据色(灰是「无数据/次要文本」的语义,用作系列色会让该系列看起来被禁用)。缓存写原先占用品红,该色值现由活动热力图复用(--dcb-mint: var(--dcb-magenta)),因此不能随系列一起删除,已改名为中性的--dcb-magenta。 - 统计口径含缓存读、不含缓存写:缓存读是真实发生的用量(实测占总量 98.7%),保留为独立指标并参与总量与命中率;缓存写在本环境下 7309 条 CodeBuddy 用量事件中出现 0 次(服务端不上报该字段),计入只会多出一个恒为 0 的项,因此从统计中移除。连带影响:
CodeBuddyTokenBucket不再有write字段;只有缓存写、没有输入/输出/缓存读的事件会被丢弃(否则记录数增加而总量不增,让「平均每次调用」偏小);translate.ts的mapUsage不变,它产出 harness 约定的TokenUsage,DSH 自身的轨迹视图依赖cacheReadTokens/cacheWriteTokens,收窄的只是统计口径。
模型目录
CodeBuddy 的模型目录来自其非 OpenAI 兼容的 /v3/config 端点,包含每个模型的上下文容量、输出上限、工具调用、推理和图片输入能力。企业账号额外合并控制台自定义模型。切换账号后会广播模型目录更新,模型选择器和消息框额度即时同步,不需要刷新页面。
模型目录只读展示在 DSH 的模型选择器中,无需在插件面板单独维护。
图片输入
CodeBuddy 支持图片输入的模型(supportsImages)在插件中以原生 image_url 数据 URI 发送图片内容,无需 DSH 的 OCR/读图工具兜底;模型不支持图片时,DSH 才会把图片降级为文本交给读图工具。图片字节通过 DSH 的 durable attachment 服务(ctx.attachments)读取,不进会话记录。
- 会话内联图片(粘贴/拖拽上传):DSH 以
ImageBlock交给模型 → 插件原生上传,无需read_image。 read_image工具结果图片:后续轮次中插件会把工具结果里嵌入的图片一并原生上传给 CodeBuddy,模型可直接看到图片内容而无需重复读图。
状态管理与持久化
浏览器侧的偏好与本地台账用 nanostores(+ @nanostores/persistent、@nanostores/react)承载,插件源码里不再有直接的 localStorage 读写。选它的三点理由:体积小且零依赖;@nanostores/persistent 内置跨标签同步(同时监听 storage 与 pageshow,后者覆盖「浏览器从 bfcache 恢复页面」,手写实现只监听 storage 会漏掉);私密模式下库自动退回内存存储,不必像原先那样每处都包 try/catch。另有 useTestStorageEngine() 可注入假 storage,让持久化逻辑能在 Node 环境里直接测。
@nanostores/react 的 useStore 就是 useSyncExternalStore 的薄封装,与本仓库既有写法同构,因此设置页、后台面板、输入框指示器都把「手写订阅 effect + setState 镜像」换成了 useStore($store)。
两个迁移时踩到的坑,都已写成用例守住:
- 不能改用
persistentBoolean。它按'yes'/''编解码且缺省false,而本插件的布尔偏好是'1'/'0'且缺省 true,直接替换会把用户已有设置静默反转。改用persistentAtom+ 自定义 codec,存储格式与迁移前完全一致,老数据无需迁移。 - 写入前必须自己归一化。
persistentAtom.set只把编码后的值写进 storage,atom 自身保留原始值,于是set(7.6)会让内存读到7.6而 storage 里是"8",刷新后才一致。阈值因此走setThreshold()先取整再写。批量订阅同理用listen而非subscribe,后者注册时会立即回调一次,对「变化后同步 host」的场景等于 5 个 store 触发 5 次多余 RPC。
nanostores 不在 DSH 的模块表里,已在 tsdown.config.ts 的 alwaysBundle 中登记,确保内联进 client 产物(否则会残留 require('nanostores') 而浏览器端无法解析)。
图标约定
左侧菜单与界面装饰使用彩色图标,来自 @douyinfe/semi-icons-lab,该包的 SVG 内硬编码多色 fill,不随前景色变化。
判断依据是实测而非包名:@douyinfe/semi-icons 里全部图标(包括 IconAI* 系列)都走 currentColor,是单色图标;名字带 AI 并不代表彩色。选图标时用「SVG 内是否有多个硬编码 #RRGGBB」来区分,不能靠名字推断。
由此带来两个好处与一个注意点:
- 硬编码
fill不会被.semi-navigation-item-icon-info的color覆盖,因此导航选中/悬停时彩色图标颜色保持不变,不会出现「选中变单色」。 - 新增 Lab 图标须同时登记到
packages/dsh-semi-ui的components.ts与index.ts(后者是显式列表,漏加则导出为空);alwaysBundle无需改动,实测semi-icons-lab会随 facade 内联,产物中无裸require。 - 彩色图标自带配色,不要再用 CSS 给它上色,否则多色设计会被覆盖成单色。
界面约定
- 悬停提示统一用 Tooltip 组件,不使用 DOM
title属性:原生title的样式与延迟不受主题控制,且在禁用按钮上不可靠。需要说明禁用原因的按钮用DshTooltip包裹;不处于该状态时直接渲染按钮,不挂空 Tooltip。 - 文本截断统一用
DshTypography.Text的ellipsis,不使用 CSStext-overflow:ellipsis开启showTooltip后,Semi 在真正溢出时才挂 Tooltip,短文本不会弹出多余气泡,同时省掉手写的nowrap/overflow/text-overflow三件套。
<DshTypography.Text ellipsis={{ showTooltip: true }}>{name}</DshTypography.Text>样式类只保留颜色、字号、字重与在 flex/grid 中的收缩能力(min-width: 0)。
面板布局
布局全部由 Semi Layout 组件表达,没有自建 flex 容器。两层 Layout 的嵌套正好对应目标结构:
<Layout> 含 Sider → Semi 自动加 has-sider → row(左右)
<Layout.Sider> 菜单
<Layout className="…-main"> 不含 Sider → 默认 column(上下)
<Layout.Header className="…-toolbar"> 固定,不参与滚动
<Layout.Content className="…-views"> 唯一滚动容器Semi 的 .semi-layout 默认 flex-direction: column,只有含 Sider 时才加 .semi-layout-has-sider { flex-direction: row },因此「左侧菜单 + 右侧上下」无需手写任何方向。Layout 系列还会渲染语义化标签:Header→<header>、Content→<main>、Sider→<aside>。
滚动行为:滚动容器是 Content,Header 在它之外,所以标题固定、只有主体滚动;菜单在 Sider 里也是独立容器,不随主体滚动。两个容易踩的点:
min-height: 0:flex 子项默认min-height: auto,不设它就不会收缩到容器高度以下,overflow随之失效(内容把容器撑高、滚动条落到整页上,标题依旧被带走)。- 内层 Layout 必须
overflow: hidden:若把滚动放在它身上,header 会与内容同处一个滚动上下文而被一起卷走。
横向对齐:Header 取 width: 100%(不设宽度上限,底部分隔线因此横跨整个面板),其内部内容靠 padding-inline: max(clamp(16px, 2vw, 32px), calc((100% - 1480px) / 2)) 对齐到与页面内容相同的 1480px 列。width: 100% 与 padding-inline 并存不溢出,因为 Semi 的 layout.css 已为 .semi-layout-header 设 box-sizing: border-box(padding 计在 100% 之内),这条前提已由用例守住。不用「两者共用 width: min(100%, 1480px)」的写法,原因有二:① header 若不 通栏,底部那条分隔线在宽屏下会比面板窄一截、两端悬空(主区 1720px 时两侧各空 120px);② 宽度上限与自带横向 padding 组合时,padding 落在宽度之内,而 .dsh-codebuddy-panel-view 的 padding 在宽度之外,于是主区 >1544px 时标题比卡片多缩进 32px。通栏 + 同一公式计算后,两者左边界在宽/中/窄屏都一致。
固定 header 的分隔用「主题感知描边 + 一层浅阴影」:border-bottom: 1px solid var(--dsw-alias-border-l2) + box-shadow: var(--dsw-shadow-lv1),并配 position: relative; z-index: 1 让分隔压在上滑的内容之上(overflow 不产生层叠上下文,故非定位的滚动容器不会盖过它)。只用阴影不够,--dsw-shadow-lv* 是固定的 5% 纯黑(#0000000d)、不随主题变化,在深色底 #151517 上对比度仅 1.009:1 等于看不见;而 --dsw-alias-border-* 是主题感知的(浅色 #000000xx / 深色 #ffffffxx),分隔必须由它承担。另外 DSH 的 --dsw-elevation-* 全部只是 0 0 0 .5px 的描边环,并非模糊阴影。
面板 keep-alive
三个管理页首次进入后一直保持挂载,只把非当前页隐藏(hidden 属性),切回时不重新拉取。此前用条件渲染 page === 'x' ? <XPage/> : null,切走即卸载,页面内的 useState(Token 各面板已选范围)与 useMemo 里的 TokenStatsStore、已拉到的数据、echarts 实例全部销毁,切回只能重新请求并重建图表。
- 用
hidden而非只写 CSS 显示控制:hidden会把子树移出可访问性树且不可聚焦,隐藏页里的按钮不会被 Tab 选中。 - 按
visited惰性挂载:首次进入某页才真正渲染,避免一进面板就并发拉三页数据。 - 账号数据(
panelStatus)由账号页与积分页共用,两者都必须带rosterTick依赖;积分页此前 deps 为空,那是靠「每次进入都重新挂载」掩盖的缺口,keep-alive 后必须补齐。 - 图表需在从隐藏切回可见时主动
resize:容器从 0 宽度恢复时ResizeObserver不保证回调,因此额外用MutationObserver观察承载页面的hidden变化。
面板刷新
刷新一律是局部更新:保留已渲染内容,只叠一层半透明遮罩 + 转圈,而不是把整页换成 DshSpin。整页替换会让整个子树卸载重建,页面闪一下、滚动位置丢失,与「刷新」的语义相反。首次加载(尚无任何数据)才整页占位。
- 账号管理 / Token 统计:遮罩覆盖页面容器。
- Token 统计:每个面板有独立的刷新按钮与遮罩,只刷新该面板所属的时间范围。
刷新要满足三条不变式,缺一条就会退化成「全局刷新」:
- 刷新期间保留旧数据(后端 store 不清缓存)。清掉会让取数变
undefined,面板据此判定「还没数据」而回到初次加载占位,整页闪成转圈,这正是最初的现象。 - 加载指示按面板隔离,不按范围共享。五个面板默认都停在 30 天,若 loading 只用 range 做键,刷新总览会让另外四个同范围的面板一起转圈。
- 切换范围时用等高空壳占位,且面板内容只用本范围的数据,旧范围的数字顶着新标签显示会让人误读。
整页占位只在「从未取到过任何数据」时出现,且用 Skeleton 骨架而不是整屏转圈:转圈会先出现一大片空白再「啪」地换成内容,视觉上像卡了一下;骨架按各页真实结构铺出同形状的占位(账号页是操作卡 + 卡片网格,Token 页是总览卡 + 图表 + 列表),内容到位时就地替换,版面不跳动。骨架必须包在 <DshSkeleton active> 里,Semi 的微光动画由祖先类 .semi-skeleton-active 选择器驱动,单独用 Skeleton.Title 只会得到静态灰块。另外 --semi-color-fill-0 在 .semi-skeleton 上被重新映射为边框色:默认值是一个悬停底色,放在卡片上太淡、读不出「占位」的语义。刷新失败会弹提示:因为刻意保留旧数据,失败时界面看起来「什么都没发生」,静默失败比报错更糟。
后台周期
宿主有三个后台周期,都是进程级单例:dsh web 每个进程只 apply 一次插件,因此开多个浏览器窗口不会让定时器翻倍(窗口自身的定时器只有输入区用量指示器的 60 秒轮询,属预期)。
| 周期 | 间隔 | 首轮 | 职责 |
|---|---|---|---|
| 自动切换 | 1 分钟 | 不立即执行 | 主动阈值探针,低于阈值切到更健康的账号 |
| 自动签到 | 30 分钟 | 启动立即执行 | 逐账号查状态,未签到的提交 |
| 旅行派发 | 30 分钟 | 启动立即执行 | 查状态并按状态机推进(含到达即领取) |
| 旅行领取 | 15 分钟 | 不立即执行 | 只对 arrived 账号 claim,不派发 |
三条约束对所有周期成立:
- 防重入:一轮要串行访问 N 个账号的远端接口,只要耗时超过间隔,下一轮就会叠加上来,同一账号被并发派发或重复领取。每个周期用
RunGuard(对照 workbuddy-switch 的RunFlagGuard)做「检查并置位」,拿不到标志就返回skipped并直接退出。派发与领取各有独立标志,互不阻塞。 - 按账号去重:两个旅行周期的守卫互相独立,而 30 与 15 分钟的最小公倍数是 30 分钟,定时器每半小时对齐一次,那一刻两个周期都可能读到同一个
arrived账号。因此领取走claimArrived()按账号去重:已有领取在途时另一方记为claiming而非error,避免把健康的一轮算成「全失败」而误推退避。旅行派发的首轮与领取周期也刻意不同时启动。 - 失败退避会自愈:连续全失败到阈值后进入退避(
BackoffGate),冷却期满自动放行重试,任一轮成功即清零。不能写成「到阈值就永久 standby」,那种写法在重算计数之前就返回,计数再无下降机会,周期从此只能靠重启宿主恢复,而远端故障恰恰是会自动恢复的。 - 定时器归属:所有
setInterval都由ctx.effect在插件卸载时清理。偏好是异步读取的,回调可能在卸载之后落地,因此start*Cycle额外检查disposed标志,避免绕过清理留下孤儿定时器。
账号批量探测(面板刷新、签到、积分)以受限并发(每批 4 个账号)而非串行执行:串行时每个账号的 2–3 个请求首尾相接,账号一多面板就要等上数百毫秒;结果按账号存储顺序回填,卡片顺序不随响应快慢抖动。
运行时验证清单
上表的周期与前面几节的状态机制(探测缓存、写入串行化、代际守卫、重试分层、开关总闸) 都由单元测试与源码断言覆盖,但单元测试不覆盖运行时装配,插件是否真的挂上、周期 是否真的按间隔跑、缓存是否真的命中,只有实际跑一次才知道。以下是一次真实运行的观察点, 按「改动面大小」排序(前面几轮的改动面很大,建议完整走一遍)。
前置:改动 host 侧后必须重启宿主(Node 的 ESM 缓存按进程生效);重启客户端只刷新 浏览器,不会重新加载宿主模块。
| # | 验证点 | 怎么看 | 通过标准 |
|---|---|---|---|
| 1 | 额度探测命中缓存 | 打开管理面板 → 切到别的页 → 再切回账号页(30 秒内) | 宿主日志里没有新的 meter 请求;卡片上的额度数字不变,且不出现「N 分钟前」提示(数据够新) |
| 2 | 新鲜度提示只在陈旧时出现 | 面板停在同一页 ≥ 2 分钟不操作 | 额度旁出现灰色「2 分钟前」;点刷新后提示消失 |
| 3 | 客户端身份正确落盘 | 用 WorkBuddy 登录一个账号,然后切一次当前账号,检查 ~/.dsh/codebuddy-auth.json | 该账号的 client 仍为 "workbuddy"、clientVersion 仍在(这两个字段曾被静默擦除) |
| 4 | 自动切换周期按新间隔运行 | 账号页停着看「最近探测时间」或宿主日志 | 周期约 1 分钟一次(此前是 30 秒),且相邻两轮常因 30 秒 TTL 直接命中缓存 |
| 5 | 关闭开关后周期真停 | 关掉「自动切换」→ 观察 ≥ 3 分钟 | 不再出现自动切换相关的探测;重新打开后恢复 |
| 6 | 切换后界面即时同步 | 在设置页切换当前账号,回到管理面板(面板保持挂载) | 「当前账号」徽标已跟随变化,无需手动刷新,由 llm/adapters-updated → 账号代际驱动 |
| 7 | 并发操作不互相覆盖 | 同时做「重命名账号」与「切换账号」 | 两个改动都保留(备注名改了、当前账号也换了),不出现其中之一被回退 |
若第 1、2 项不通过,先确认宿主确实重启过;若第 3 项不通过,说明 normalizeEntry 的字段 保留逻辑被改坏(见「源码结构」一节关于白名单重建的说明)。