Skip to content

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 DescriptionsdefaultPropsvertical(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 会给出原因码):

  1. 当前账号存在,且至少有两个账号;
  2. 当前账号凭据有效,失效则强制切换(不看额度、不受冷却与活跃请求阻挡,因为请求必然失败);
  3. 当前额度已知,探测失败时不切换。把「未知」当作「不足」会让一次 meter 抖动就轮换所有账号;请求真失败时还有被动路径兜底;
  4. 当前额度低于阈值(判断用 >=,等于阈值不算「低于」);
  5. 不在冷却期;
  6. 没有进行中的流式请求(不打断已产出的内容);
  7. 存在满足约束的候选:凭据有效、额度已知、自身剩余不低于下限、相对当前账号收益差大于最小值。排序按剩余额度降序,相同时按账号在文档中的稳定顺序兜底。

被动切换(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 分钟前」(一直没刷新)。

缓存条目不变量:resultinFlight 从不同时存在。登记在途探测时丢弃 result,探测完成时丢弃 inFlight。这让并发调用不会把上一次的旧结果当成命中(曾用一个空结果占位,导致 5 个并发消费者里只有 1 个拿到真实数据),也让「在途优先」与「缓存优先」两种判断顺序在全部可达状态下等价。改动此处需保留该不变量,测试有对应用例锁住。

写入并发

凭据文档是单个 JSON(全部账号共处一份),写入点分散在 sessionauth-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-plugindsh-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.tsclient/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 环境可以执行:

sh
dsh plugin --profile web add @tnnevol/dsh-codebuddy@0.1.5-rc.2
dsh --profile web --dump-config

安装后重启 Web profile。

fnOS 网关内置反代

CodeBuddy 的 RPC 频道是 /codebuddyCODEBUDDY_AUTH_CHANNEL),由 DSH 挂成浏览器同级 HTTP 路由。在 fnOS 应用里,浏览器 bridge 只对内置前缀和用户规则补应用前缀,因此该频道已作为内置前缀收录,与 /api/plugins/open-in-app 同级:

  • 随 FPK 内置安装后开箱可用,不需要在「设置 → fnos → 三方插件 API URL 反代配置」里手工登记;
  • 前缀按路径段边界匹配,/codebuddyx 之类的兄弟路径仍留给 fnOS 宿主;
  • 用户规则只在此基础上追加,不能覆盖或移除内置前缀。

若在非 fnOS 的 DSH 环境里自行部署并需要网关转发该频道,才需要按上面的规则手工添加 /codebuddy

登录

一切交互都在插件 Web 界面中完成。

CodeBuddy 设置页

打开「设置 → CodeBuddy」,点击「添加账号」。插件会在新标签页打开腾讯 CodeBuddy 授权页面,用户完成登录后插件自动轮询换取令牌并持久化。无需输入任何 API Key,也无需使用任何终端命令。

登录成功后,该账号会出现在「账号管理」折叠面板列表中并成为当前账号。展开任意账号面板可查看昵称、UID、企业等详细信息,并可执行「设为当前」或「删除账号」。

多账号管理(设置页)

CodeBuddy 账号管理面板

「设置 → CodeBuddy」的「账号管理」列出所有已登录账号,每个账号是一个可展开面板:

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

    添加 CodeBuddy 账号

    不抢占当前账号:弹框调用 startLogin 时显式传 activate: false。host 的 activate 默认为 true,不传就会让新账号成为当前账号,用户只是想多存一个备用账号,正在用的账号却被静默换掉,后续请求全部改走新账号。切换当前账号有独立入口(卡片菜单的「设为当前账号」与自动切换策略),登录不顺带替用户做这个决定。规则集中在 src/host/storage.tsnextActiveId(),两种例外都由它兜住:① 一个账号都没有时无视 activate,新账号必须成为当前账号,否则会留下「有账号却没有当前账号」的空悬状态;② 重复登录已存在的账号activate || wasActive 判断,刷新当前账号凭据不会把它自己挤下去(该保护的前提是复用分支沿用 existing.id)。

    登录反馈闭环:弹框里的「打开登录」按钮从点击起持续 loading(先是握手在途,随后是等待浏览器授权),直到登录彻底落定。落定后用通知组件提示结果:成功提示「登录成功,账号已添加」并自动关闭弹框;超时或失败也给出通知,但保留弹框与已填字段,用户可直接再点一次重试,不必重新填备注名、客户端与环境。轮询归弹框所有(只有它知道这次登录由它发起、也只有它能决定关闭自己),宿主页面只负责开浏览器窗口与登录成功后刷新名册,不重复轮询同一个 state,两处同时轮询会对 pollLogin 发双份请求并让提示出现两次。

    登录在途时整表单只读:点「打开登录」后,表单的全部字段(备注名、客户端、环境、企业服务地址、企业开关)一律禁用,直到登录落定。理由是字段已随握手发给了 host(客户端与环境决定登录端点),此刻再改只会让界面显示的与这次登录实际用的不一致;要改应先取消重来。解除禁用有三种情形:重新打开弹框登录成功授权轮询超时或失败,都归结为 waitinghandshaking || pendingState !== undefined)变回 false。

    关闭弹框即放弃本次等待:用「取消」、右上角 X 或 ESC 关闭弹框,会一并结束这次登录等待,停止客户端轮询、清掉弹框主按钮的 loading、并通知宿主把「登录中」标记落回(否则宿主的「添加账号」按钮会一直禁用)。遮罩点击被禁用(maskClosable={false}),避免误触中断登录。重开弹框是干净的初始态。

    关框还会作废在途的握手generationRef 代计数):点「打开登录」后立刻关框时,startLogin 的 RPC 仍在途,它返回后原本会继续走 submit() 的后半段,把 handshaking 又置回 true(重开的弹框仍是禁用态、主按钮一直转),并 setPendingState + onLoginStart 替一次已取消的登录弹开浏览器登录页。因此 close() 推进代、submitawait 后比对,不一致即整体丢弃(不置 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-bodyheight: 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.tsadoptHostPrefs()不得各写一份,它们曾各写一份并漂移:设置页采纳 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 / WorkBuddyCODEBUDDY_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-DomainAuthToken 的时长/刷新字段因此标为可选,取用处一律用 ?? 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-AgentCODEBUDDY_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--> idleidledaily_limit_reached 是官网的「累了,明天再来吧」,当日不再派发。接口为成长中心 /activity/growth/buddy/travel/{config,status,depart,claim},与 meter 平面不同,额外要求 x-client-platform: weborigin/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.tsalwaysBundle 中登记:DSH 浏览器模块表没有它,漏登记会残留裸 require('dayjs') 并在运行时直接报模块缺失。

    • 时间范围:默认「近 7 天」;选项按面板职责分配,总览给「总计」(回答「一共用了多少」,需要全量)、趋势给「本月」(回答「随时间怎么变」,需要有意义的当前窗口),两者不互换:把总计放到趋势上逐日图会退化成一根巨柱。选择器用 ButtonGroup(facade 的 DshButtonGroup)呈现,而不是 SplitButtonGroup:前者把相邻按钮的圆角相接成一条连续控件,符合「互斥单选一组」的语义。外观完全沿用 Semi 原生样式,插件侧不写任何 CSS 覆盖,激活项 theme="solid" type="primary"、其余 theme="borderless",底色/圆角/hover/focus 全部交回 Semi 与主题层。

组上不传 theme / type:ButtonGroup 合并子 props 的顺序是 {disabled,size,type}itm.propsrest,而 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 而非大 daysdays 有 365 上限,超过一年的历史会被静默截断,而总计的语义是全部。缓存以范围键为键而非 daysmonth 在 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 的 mediamaxWidth: 420 时把 grid.top 抬到 68,单行保持紧凑、折行自动让位。
  • 周期选择器落在每个面板内部(总览 / 趋势 / 工作区分布 / 模型分布 / 会话排行各一个):这些面板回答不同问题,读者常需要让它们停在不同的时间窗口上对比,全局选择器会强迫所有面板同时跳变。Token 活动热力图固定为最近一年(服务端 ACTIVITY_RANGE_DAYSdays 无关),因此不提供周期选择器。
  • 用量分布与模型排行的维度切换:两个面板都能按工作区或按模型排行,切换用 ButtonGroup 放在排行卡片内部(列表内容区顶部,DimensionToggle + dsh-codebuddy-token-card-toolbar),它切换的是这份列表的统计口径,与列表是同一个整体;放面板头部会像在控制整个面板(含周期选择器)。两面板维度相互独立。默认档沿用历史视角(分布=按工作区、排行=按模型),老读者看到的内容不变。这两个面板的静态副标题已随之删除,副标题原先就是写死的维度说明(「按工作区」「按模型」),改成控件后再用小字重复只会占高度;数据型副标题(总览的「N 个活跃会话」、趋势的「总计 X Token」)保留,它们随数据变化、有信息量。host 无需改动:TokenStats 早已同时聚合 workspacesmodels,此前只是两个面板各用其一。

「模型用量排行」面板已移除:维度可切换后它与分布面板能力完全重合(同数据源、同两维度),保留两个只会同屏出现镜像数据;分布面板独占一行,「按模型」视角保留在维度切换的第二档。

所有带日期档位的面板(总览 / 趋势 / 分布 / 会话排名)头部都有日期范围选择器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)。客户端 TokenStatsStoredays 缓存并复用在途请求,同范围共享一份数据、切回旧范围零成本命中;范围不同才真正多取一次,这是功能本身要求的,因为服务端对 workspaces/models/sessions 的累积带范围过滤,无法从大范围响应推导小范围结果。
  • 会话排名只显示标题,不显示会话 iduser/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.tsmapUsage 不变,它产出 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 内置跨标签同步(同时监听 storagepageshow,后者覆盖「浏览器从 bfcache 恢复页面」,手写实现只监听 storage 会漏掉);私密模式下库自动退回内存存储,不必像原先那样每处都包 try/catch。另有 useTestStorageEngine() 可注入假 storage,让持久化逻辑能在 Node 环境里直接测。

@nanostores/reactuseStore 就是 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.tsalwaysBundle 中登记,确保内联进 client 产物(否则会残留 require('nanostores') 而浏览器端无法解析)。

图标约定

左侧菜单与界面装饰使用彩色图标,来自 @douyinfe/semi-icons-lab,该包的 SVG 内硬编码多色 fill,不随前景色变化。

判断依据是实测而非包名:@douyinfe/semi-icons全部图标(包括 IconAI* 系列)都走 currentColor,是单色图标;名字带 AI 并不代表彩色。选图标时用「SVG 内是否有多个硬编码 #RRGGBB」来区分,不能靠名字推断。

由此带来两个好处与一个注意点:

  • 硬编码 fill 不会被 .semi-navigation-item-icon-infocolor 覆盖,因此导航选中/悬停时彩色图标颜色保持不变,不会出现「选中变单色」。
  • 新增 Lab 图标须同时登记到 packages/dsh-semi-uicomponents.tsindex.ts(后者是显式列表,漏加则导出为空);alwaysBundle 无需改动,实测 semi-icons-lab 会随 facade 内联,产物中无裸 require
  • 彩色图标自带配色,不要再用 CSS 给它上色,否则多色设计会被覆盖成单色。

界面约定

  • 悬停提示统一用 Tooltip 组件,不使用 DOM title 属性:原生 title 的样式与延迟不受主题控制,且在禁用按钮上不可靠。需要说明禁用原因的按钮用 DshTooltip 包裹;不处于该状态时直接渲染按钮,不挂空 Tooltip。
  • 文本截断统一用 DshTypography.Textellipsis,不使用 CSS text-overflowellipsis 开启 showTooltip 后,Semi 在真正溢出时才挂 Tooltip,短文本不会弹出多余气泡,同时省掉手写的 nowrap/overflow/text-overflow 三件套。
tsx
<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-headerbox-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 统计:每个面板有独立的刷新按钮与遮罩,只刷新该面板所属的时间范围。

刷新要满足三条不变式,缺一条就会退化成「全局刷新」:

  1. 刷新期间保留旧数据(后端 store 不清缓存)。清掉会让取数变 undefined,面板据此判定「还没数据」而回到初次加载占位,整页闪成转圈,这正是最初的现象。
  2. 加载指示按面板隔离,不按范围共享。五个面板默认都停在 30 天,若 loading 只用 range 做键,刷新总览会让另外四个同范围的面板一起转圈。
  3. 切换范围时用等高空壳占位,且面板内容只用本范围的数据,旧范围的数字顶着新标签显示会让人误读。

整页占位只在「从未取到过任何数据」时出现,且用 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 的字段 保留逻辑被改坏(见「源码结构」一节关于白名单重建的说明)。

基于 VitePress 构建