Appearance
功能规格说明:桌面端账户卡片
本规格描述侧边栏底部的账户卡片:认证方式(企业账号 / 自定义网关 / 无凭据)、用量展示、功能菜单与更新按钮。设置页见 desktop-settings.md;更新源、feed 与下载链路的宿主侧语义见 desktop-distribution.md「桌面端自动更新」。
用户场景与测试
用户故事:账户卡片 · 用量浮层内容(套餐用量 / API 余额 两个维度切换)(优先级:P1)
作为桌面端用户,我希望登录后从账户卡片行尾用量按钮弹出的浮层里看到用量信息(2026-11 交互改版起收在浮层里,浮层内的内容按本故事展示;开合与预警点见下一故事),且两个维度互斥切换:默认停在套餐用量(本周条 + 本月条 + 套餐到期日 + 计费结论行;某一维正在报警时默认改停到那一维,见下一故事场景 11),要看余额时切到 API 余额(已用 / 剩余 + 预警文案),以便我随时知道:套餐能用到什么时候、当前是哪个限额不足、以及现在的请求到底在扣套餐积分还是扣 API 余额——入口恒在行尾、不必去别处找,也不会被「套餐没耗尽时并不消耗」的 API 余额干扰。
为什么是这个优先级:账户卡片是桌面端侧边栏核心运营入口;把用量从「hover ⓘ 才出明细」升级为「一个能装下完整两维、可随时打开的视图」是账户卡片的核心变化(P1)。2026-10-09 交互定稿把原「套餐两根条 + API 余额行平铺」收敛为维度切换:套餐未用尽时 API 余额不参与计费、余额为 0 也不影响使用,故默认不占版面,维度切换取代原来的 hover ⓘ 明细气泡(气泡内容即余额维度本体)。
独立测试:desktop 登录态下发含 billing/apiQuota 的 desktopAccountInfo,点行尾用量按钮弹出浮层后验证默认停在套餐维度(本周在上、本月在下两根条 + 各维余量百分比 + progressbar role + 到期日),且不渲染 API 余额块;点页签切到余额维度渲染「已用 ¥n / 剩余 ¥N」、套餐块与到期日让位;两维只有一维可用时不渲染页签、直接展示那一维(单维保留块内标题行);切换「不限制(limit = null)/ 不可用(limit = 0)/ 触顶(reason = month / week)/ 已到期(reason = no_plan)/ 阻断(mode = blocked,三个 code)」各态验证条与结论行;table-driven 遍历每个已知 reason / code 断言结论行文案非空,再各给一个规格外取值(reason 与 blocked 的 code)断言渲染兜底文案 + 色调、且不再是「什么都不画 / 空文案红框」(见场景 23);余额维度 ⓘ 的 tooltip 说明余额用途;再单独走一遍「billing.mode × apiQuota.limit」交叉矩阵:plan + limit = 0 静默(不出现「已用完」)、api + limit = 0 读「已用完」、blocked + 耗尽复用 402 文案、billing 缺省 + limit = 0 仍读「已用完」(见场景 14 / 24)。
验收场景:
- 假设当前请求判定走套餐(
billing.mode = "plan")且两维限额均为正数,当打开账户卡片的用量浮层,则浮层顶部渲染维度页签「套餐用量 / API 余额」(role="tablist",默认选中套餐用量)与两根额度条(本周 = 自然周、本月 = 滚动月,本周在上——自然周窗口更短、更容易触顶,优先关注),每根条按自己那维的窗口量显示余量百分比max(0, round((1 − used / limit) × 100))(分母为该维限额、分子为该维窗口内用量,均由宿主下发,客户端不自行推算窗口);每根条带progressbarrole、aria-valuenow= 该维余量百分比;无结论行。页签选中态用aria-selected标记;有页签时「套餐用量」这一称呼由页签承担,块内不再重复一行标题。 - 假设某维
limit = null(管理员未设置 = 不限制),当查看该维,则该维不画条、显示「无额度限制」;另一维照常。(不得把null当0—— 会把不限额的企业误报成「不可用」。) - 假设某维
limit = 0(显式 0 = 该维度不可用)且billing.reason = "dimension_unavailable",当查看该维,则该维不画条、置灰显示「不可用」(不是「已用尽」,也不是「无额度限制」),并出现结论行「套餐额度不可用(限额为 0),当前按 API 余额计费」。 - 假设本人本滚动月用量触顶(
billing.reason = "month"),当查看月条,则月条显示空条 +「0%」(2026-10-09 交互定稿:触顶与其余百分比同样读数字、同样常规色,条只表达余量、超用封底到 0;「哪个窗口用完、现在按什么计费」由结论行承担,不在条上重复表达「已用尽」+ 琥珀),周条照常,结论行「本月额度已用尽,当前按 API 余额计费」。 - 假设本人本自然周用量触顶(
billing.reason = "week",月未触顶),当查看周条,则周条显示空条 +「0%」,月条照常,结论行「本周额度已用尽,当前按 API 余额计费」。 - 假设
billing.reason = "no_plan"且billing.plan带expireDate(存在已到期的套餐订单),当查看套餐块,则套餐块保留并显示到期日与结论行「套餐已到期(<expireDate>到期),当前按 API 余额计费」(不再整块消失)。「已到期订单」的判据以服务端为准:type='plan'且status='active'(未作废)且end_date <今日,与生效分支同谓词——已作废订单不参与,全部套餐订单已作废时视同「无套餐」(plan = null,落到场景 10 的兜底)。 - 假设
billing.reason = "enterprise"(企业维度本期额度触顶),当查看套餐块,则本人两根条照常显示个人读数(企业池触顶不改个人数字),并渲染结论行「企业本期额度已用尽,当前按 API 余额计费」,色调为琥珀预警(与月/周同族:下个窗口自动恢复,不属「需人工干预」的错误色)。2026-09-22 口径变更:原先(2026-09-21)此原因不渲染结论行,理由是「漏提示优于误提示」——该理由已判定站不住:说「企业本期额度已用尽」是事实,且与个人两根条不冲突(条 = 个人读数,结论行 = 计费结论);真正有误读风险的是把企业池的数字画成个人的条,不是这句话本身。当时(不渲染时)成员看到的是「个人两根条健康 + API 余额在掉」而毫无解释(缺陷单 3479183306383616 据此报障)。本次只补呈现层,不动计费口径(企业池与个人条之和的折算摩擦属产品口径、不在本场景范围)。 - 假设
billing.mode = "blocked"(套餐与 API 均不可用;code ∈ EXPIRED_NO_API / USER_QUOTA_ZERO / TEAM_QUOTA_ZERO),当查看账户卡片,则结论行复用 proxy 402 的同一句文案(按 code 三选一:EXPIRED_NO_API=「您订购的套餐已过期,无法使用本产品!」/USER_QUOTA_ZERO=「您的 API 额度已用完,请联系公司管理员分配额度后使用!」/TEAM_QUOTA_ZERO=「团队 API 额度已用完,请联系公司管理员充值后使用!」);blocked不下发plan四数,故此时套餐块只有该结论行。 - 假设套餐块渲染且
billing.plan带expireDate,当查看套餐维度,则维度页签行右侧显示套餐到期日(YYYY-MM-DD 到期);到期日属套餐信息,切到余额维度时不再展示(该位置改由余额的 ⓘ 占用,见场景 16)。 - 假设
billing.plan为null(无生效套餐、也没有未作废的已到期订单),当查看账户卡片,则不渲染套餐块(未购买的企业无法登录桌面端,实际不出现;全部套餐订单已作废同样落在这里——作废不是到期,不得拿作废单的截止日渲染「套餐已到期」;仅作契约兜底)。 - 假设宿主下发了
apiQuota且套餐维度也有数据,当点页签切到余额维度,则面板渲染两行「已用 ¥n」「剩余 ¥N」;金额格式 = ¥ 前缀 + 两位小数 + 千位分隔;两行金额右对齐且等宽数字(font-variant-numeric: tabular-nums,便于比较),随账户消息实时刷新。 - 假设API 余额充足(剩余 ≥ 限额 20%),当查看余额维度,则「剩余」金额以正常色展示,无预警文案。
- 假设API 余额不足(剩余 < 限额 20% 且 > 0),当查看余额维度,则「剩余」金额以预警色展示,并附同色提示「余额不足20%,建议及时充值」;「已用」行不套用预警色。
- 假设当前请求确实按 API 余额计费(
billing.mode = "api",或宿主未下发billing、只下发了余额维度)且 API 余额用完(剩余 ≤ 0),当查看余额维度,则「剩余」以错误警示色(主题变量,如--vscode-errorForeground)显示「已用完」(不显示负数),并附红色提示「额度已用完,请联系管理员充值」。2026-11-05 修正:apiQuota.limit不得用于反推「当前是否可用」——后端checkApiAvailable → TEAM_QUOTA_ZERO下发的limit = 0语义是「此刻若降级到 API 计费会被 402」,不是「当前额度已用完」;只买试用套餐、从未充值 API 的企业该值恒为 0,照旧渲染会给「套餐还剩 99%」的账号报「额度已用完」(线上实例见 issue #2776)。billing.mode是唯一判据,见场景 24。 - 假设API 不限额(
apiQuota.limit = null),当查看余额维度,则「剩余」行显示「不限额」(正常色)、不显示金额,也不参与 20% 预警;「已用」行照常显示金额。 - 假设用户 hover 或键盘 Tab 聚焦余额维度的 ⓘ 图标(
data-testid="api-quota-info",独立按钮,aria-label="API 余额说明",排在余额维度的右上角),则弹出 tooltip「将在套餐用量耗尽时使用API余额」——说明这笔余额只在套餐不可用时才参与计费。该图标与套餐维度的到期日处于同一类位置:两个维度各自承载自己的说明性信息,切换时互不干扰。 - 假设两维只有一维可用(
billing为空 ⇒ 只有余额;或apiQuota为空 ⇒ 只有套餐;私有化不会被归到这一条——它连apiQuota也不下发,落场景 22),当打开用量浮层,则不渲染页签、直接展示那一维,并在块内保留自己的标题行(「套餐用量」/「API 余额」)替代页签(仅余额这一维时,ⓘ 落到该标题行右侧——页签行没了,但 ⓘ 属余额信息,仍在该维度右上角)。 - 假设用户切到余额维度后在同一会话内继续操作,当卡片因宿主推送重新渲染,则停留的维度被记住(不会自动跳回套餐维度);而只剩一维时自动落到仅存的那一维(不停留在已消失的维度上,避免空白面板)。
- 假设用户点击余额维度的金额文本,当点击发生,则不触发任何行为(金额非热区,仅 ⓘ 为热区,且 ⓘ 只出 tooltip、不再弹出明细气泡)。
- 假设用户把指针移开 ⓘ,当指针离开,则tooltip 自动消失(无需点击),不残留任何悬浮面板——原「hover ⓘ 弹出明细气泡 + 移出延迟 150ms 收起 +
Esc/外部点击强制收起」的整套机制随气泡一并删除,在Esc/ 外部点击上不再有任何待收起的浮层。 - 假设余额维度处于预警态(不足 20% / 已用完),当切回套餐维度,则套餐维度不受余额预警影响(预警文案与色调只属于余额维度,不跨维度残留)。
- 假设宿主下发
billing与apiQuota均为空/未下发(私有化部署的常态:codechatGET /api/v1/account在SAAS_MODE非true时恒返回{}——billing/plan/apiQuota一个都不给,见 codechat specwave-desktop-account-card.md场景 13),当查看账户卡片,则浮层无内容可展示,个人信息行尾部的用量按钮不渲染;且不得把缺字段解释成「0 额度 / blocked / 不限额」——它表达的是「本部署没有额度体系」。 - 假设宿主下发的
billing.reason是规格外取值(服务端先于客户端发版新增的枚举值),当渲染账户卡片,则结论行照常渲染「套餐额度当前不可用,按 API 余额计费」(琥珀预警)——不得静默不渲染(原先switch无default,未识别的reason会隐式返回、结论行整块消失,用户只看到「本人两根条健康 + API 余额在掉」而零解释,与场景 7 的缺陷单 3479183306383616 同款失效);假设宿主下发的billing.code是规格外取值(mode = "blocked"),当渲染账户卡片,则渲染兜底阻断文案「当前用量受限,请联系公司管理员」(错误色)——不得渲染一条没有文案的结论行(空红框:查表得undefined但元素照挂);两处均按客户端既有约定console.warn记一条未知枚举日志(不新建日志/打点基建)。2026-09-22 追加:兜底文案「说少而准」——结论行要回答的是「为什么在扣 API」,而这件事只需mode = "api"就能确定,故不猜是哪一池(月/周/企业)用完;色调沿用本规格既有的「会不会自愈」分档(见场景 3–7 的说明):未知原因不预设「需人工干预」(那是错误色那档的语义),给琥珀,避免无谓制造焦虑。起因:客户端遇到不认识的枚举就静默是同一类失效——09-22 修掉enterprise(场景 7)后复核发现同类还有这两处,本次一并收口;根因是两端不同机发布(服务端先加枚举、客户端旧版本不认识),故除运行期兜底外,reason的switch另加default分支做编译期穷尽性检查(const _exhaustive: never = billing.reason,仓内新增AccountBillingDegradeReason却忘了处理时直接编译失败——但它拦不住跨仓先发,两者都要有)。 - 假设
billing.mode = "plan"(套餐可用、请求按套餐计费)而apiQuota.limit = 0(只买试用套餐、从未充值 API 的企业,团队余额恒为 0;线上实例见 issue #2776),当切到余额维度,则该维度不得出现「已用完」、错误警示色或「额度已用完,请联系管理员充值」提示——剩余按数值渲染、「已用完」一侧的金额封底到 0(读「¥0.00」),无任何 API 侧预警/拦截结论。假设billing.mode = "blocked"(此时 API 侧是否可用已由 402 结论判定)且余额耗尽,当查看余额维度,则同样不自行下「已用完」结论,改由余额面板内复用 proxy 402 的同一句文案(按code三选一,与场景 8 同源;data-testid="account-api-conclusion")。只有billing.mode = "api"(reason ∈ month | week | enterprise | no_plan | dimension_unavailable)或宿主未下发billing、只下发了余额维度时,limit = 0/ 剩余 ≤ 0 才等于「当前真的用完」(降级后无套餐兜底),按场景 14 渲染。判据只有billing.mode:客户端不得用apiQuota.limit反推可用性。
用户故事:账户卡片 · 用量浮层与预警点(优先级:P2)
作为桌面端用户,我希望账户卡片默认只显示个人信息一行,用量信息收进一层浮层——点个人信息行尾部的用量按钮就在卡片上方弹出与卡片等宽的浮层,看完点浮层外或按 Esc 即收起;同时用量出问题时不能在收起态彻底沉默,用按钮右上角的一颗小圆点告诉我「这里有事」,以便卡片保持清爽但不会漏掉余额不足、额度耗尽这类预警。
为什么是这个优先级:空间收纳是浮层的辅助能力,先保证浮层里的展示正确(P1 故事)后再谈收纳。2026-11 交互改版:原「默认展开 + 显隐按钮就地收起/展开」改为「默认收起 + 点击弹出浮层」——就地展开会把卡片顶高、把侧边栏下方的会话列表往下推,弹层则不改变卡片高度;收起态下预警不可见,故保留一颗点透出(用户否掉了「不提示」与「有预警自动展开一次」两个方案)。入口限定在行尾用量按钮:个人信息行热区仍然只开纯功能菜单,一颗入口做一件事。
独立测试:登录态进入卡片验证无浮层、卡片仅剩个人信息一行、按钮 icon 为仪表盘(dashboard)且 aria-label="查看用量"、aria-haspopup="dialog";点按钮验证浮层出现且内容按 P1 故事展示(默认停在套餐维度)、与卡片等宽、底边贴个人信息行顶向上弹;再点按钮 / 点浮层外 / 按 Esc 验证收起(打开后焦点进入浮层;键盘打开的按 Esc 或再点一次按钮收起后焦点回到按钮,鼠标打开的焦点不抢);打桩 getBoundingClientRect 后改窗口高度并派发 resize,验证浮层与菜单的 bottom 都随锚点重算(不跟卡片分家);同一套桩把个人信息行顶压到视口顶部附近,验证浮层 maxHeight 跟着收缩、不越过视口顶;把 documentElement.clientWidth/clientHeight 打桩成小于 innerWidth/innerHeight(模拟滚动条),验证浮层右缘按前者算;重新挂载验证仍回到收起态(不记忆);分别用「套餐正常 / 周月耗尽 / 余额不足 20% / 余额耗尽 / 套餐已到期 / 被阻断」场景验证圆点有无与颜色,并验证读屏文案分「有预警」「有预警,需处理」两档;浮层开着时点热区验证菜单打开、浮层收起(互斥);billing+apiQuota 均未下发时验证用量按钮不渲染。
验收场景:
- 假设用户进入桌面端(组件挂载,含重新打开客户端、切换会话后重新挂载),当查看账户卡片,则卡片只渲染个人信息行(无用量块、无页签、无浮层),个人信息行尾部用量按钮 icon 为仪表盘(dashboard,
aria-label="查看用量"、aria-haspopup="dialog"、aria-expanded="false");收起状态不做持久化,每次挂载都回到收起态(2026-11-05 交互定稿:用户明确选「每次都收起」,不记住上次的开合选择)。 - 假设当前没有任何用量预警,当查看用量按钮,则按钮上不出现任何角标(保持干净);圆点只在真有预警时挂上。
- 假设当前存在用量预警,当查看用量按钮,则按钮右上角出现一颗 6px 实心圆点(距按钮上边缘、右边缘各 2px,
pointer-events: none不参与点击);严重度不能只靠颜色表达(圆点本身aria-hidden),读屏aria-label按档区分:「查看用量(有预警)」= 琥珀档(会自愈),「查看用量(有预警,需处理)」= 红档(需人工干预);无预警时只用「查看用量」。 - 假设预警点的严重度判定:红 = 结论行
tone = "error"(blocked/ 维度不可用 / 套餐已到期)或 API 余额耗尽(剩余 ≤ 0);琥珀 = 结论行tone = "warning"(本周/本月/企业池触顶)或 API 余额不足 20%;当两种情况同时成立,则取红(高严重度优先)。色调复用既有主题变量:--vscode-errorForeground(错误)/--vscode-editorWarning-foreground(预警),不新造色值;新增预警来源时并入同一判定,不另起一套颜色/图标。 - 假设用户点击用量按钮,当浮层弹出,则浮层在卡片上方出现:宽度与卡片一致(约 235px,右缘对齐)、底边贴个人信息行顶部向上展开、盖住卡片上方内容(不改变卡片自身高度、不推动侧边栏其它区域);浮层内容按 P1 故事渲染(默认停在套餐用量维度,页签可切到 API 余额;若某一维正在报警,默认改停到那一维,见场景 11);浮层的定位用 fixed(侧边栏
overflow: hidden会裁掉 absolute 后代);向上空间不足时浮层内部滚动,可用高度按「行顶到视口顶」实算、不设最小高度兜底,绝不越过视口顶部(行顶贴顶时宁可只剩几十像素,也不留一截滚不到的浮层在视口外);右缘以视口可用宽度为界(documentElement.clientWidth,扣掉经典滚动条,不是innerWidth)。按钮aria-expanded="true",icon 恒为仪表盘(开合不换图标)。 - 假设浮层已打开,当再次点击用量按钮 / 点击浮层外任意位置 / 按 Esc,则浮层收起、卡片恢复为个人信息行;三种路径都关闭浮层(打开时焦点进入浮层,符合 dialog 语义;点外部关闭时焦点留给用户点中的位置,不抢)。键盘关闭的两条路径——Esc、再按一次用量按钮(Enter/空格 toggle)——都把焦点交还用量按钮;鼠标关闭的焦点落空——鼠标用户此刻指针多半已离开按钮,把焦点塞回去会让按钮进入
:focus态、顺带弹出「查看用量」气泡,这条气泡再没有mouseleave能收掉,会一直挂在空中(2026-11-05 修的「鼠标移开后仍显示提示文案」)。2026-11-05 补:按钮 toggle 关闭同样要交还焦点——Tooltip的disabled在开合间把 wrapper 摘掉再装回,按钮 DOM 节点随之重建,不同步补 focus 的话键盘用户的焦点会落到body、Tab 顺序回卷到页首。 - 假设用量浮层或个人信息菜单处于打开态(两者互斥,同时最多一个),当宿主/用户改变了窗口尺寸,则打开的那一层重新测量锚点、继续贴住个人信息行顶与卡片右缘(
fixed坐标是打开那一刻的快照,卡片贴侧栏底部、随窗口高度移动,不重测就与卡片分家);两层共用同一把取景尺子(documentElement.clientWidth/clientHeight,扣掉经典滚动条,不用innerWidth/innerHeight);未登录态那颗菜单的锚点是「更多」按钮自身,重测走同一条口径。 - 假设当前有预警,当浮层打开或关闭,则按钮上的圆点始终保留(不闪烁、不随开合切换)——浮层默认只展示套餐维度,预警可能落在余额维度(如余额耗尽),圆点是唯一的常驻出口。
- 假设浮层处于打开态,当点击个人信息热区,则纯功能菜单打开、用量浮层收起;假设纯功能菜单处于打开态,当点击用量按钮,则浮层打开、菜单收起——两个弹层互斥,同一位置不叠两层。
- 假设宿主下发
billing与apiQuota均为空/未下发(私有化部署的常态,见上一故事场景 22),当查看账户卡片,则没有用量可看,用量按钮不渲染(卡片只剩个人信息行 + 有更新时的更新按钮)。 - 假设浮层打开时某一维正在报警(结论行
tone为 warning/error,或余额不足 20% / 已耗尽),当浮层弹出,则默认停到报警的那一维——免得「打开只看到健康的套餐条、预警藏在另一页签里」(2026-11-05 定稿:用户在有预警时选「停在报警的那一维」,而不是「总是套餐」);两维同时报警时取更严重的那档(错误 > 预警),同档回到套餐维度;两维都没有预警时保持套餐维度,也不覆盖用户在本次打开期间的切换。
用户故事:账户卡片 · 用量刷新时机(响应头驱动 + 打开浮层时核对)(优先级:P2)
作为桌面端用户,我希望用量数字在我真正消耗额度的时刻刷新——每次模型调用的响应里就带着当时的额度读数,卡片(含收起态的预警点)因此当场变色;我点开用量浮层时,客户端再向账户接口核对一次全量状态(套餐到期日、企业池、余额),以便我不必等一分钟的定时轮询、也不会在私有化部署下被客户端每分钟白打一次恒返回空对象的接口。
为什么是这个优先级:这是数据刷新链路的改造(配额仍然由既有故事渲染,本故事不改任何展示口径),排在展示与交互定稿之后。2026-11-06 定稿(A 形态):服务端(codechat issue #51)已在 proxy 每次模型调用上发出 x-codechat-quota-* 头(数值就是闸口那一次 getBillingVerdict 的结果,零额外查询),故「每次调用都在变」的读数改由响应头搭载;GET /api/v1/account 保留但降频到「点开用量浮层时核对一次」,60 秒定时轮询删除——它在私有化下每分钟打一次恒返回 {} 的接口,而真正的变化时刻本来就有响应可搭载。已接受的代价:共享 API 余额被同事消耗时,最长到下次打开浮层才发现(预警点在本人发起调用时立即跟上)。
独立测试:给宿主喂一组带 x-codechat-quota-* 的响应头,验证 desktopAccountInfo 随之推送且 billing / apiQuota 按映射表(见「额度响应头」小节)更新——table-driven 覆盖 plan / api(含 no_plan 只有到期日)/ blocked / 只有 api-*(只更新余额维度)/ 一个头都没有五类,以及 limit 三态解码(unlimited → null、unavailable → 0、数字原样)与 api-limit 的两态(unlimited → null、0 → 已用完);喂一组头缺失或数值不可解析的响应,验证不推送、不请求、不错写成 0;模拟 402(code + 头)与上游 429/5xx(错误响应同样带头)两条错误路径,验证读数照常刷新;用 fake timers 断言登录后不再有任何周期性 getAccountInfo 请求(删轮询);webview 侧点开浮层验证发出一次 desktopRefreshAccountUsage、且收起再打开会再发一次。
验收场景:
- 假设用户在会话里发起一次模型调用,当该次调用的响应带
x-codechat-quota-*头,则宿主把头顶层的读数映射成新的billing/apiQuota并推送desktopAccountInfo——卡片收起态下预警点立刻跟上(用的还是既有判定:结论行 tone / 余额不足 20% / 已用完),浮层开着时两根条与结论行当场更新;用户无需任何额外操作。 - 假设响应是非 2xx(proxy 402 阻断、上游 429 被限流、5xx),当错误响应同样带头,则读数照常刷新(402 当场变「被阻断」结论行 + 红档预警点;429/5xx 也刷新余量)——错误路径的头与成功路径同等对待,不能只看成功响应。
- 假设头里的读数是「本次调用前」的快照(流式响应在首字节前就把头发完、扣费在流结束后),当客户端渲染,则不得据此反推本次消耗、不得把「读数与上次相同」当成「这次没计费」,也不得自行 +1 或做任何补偿;下一次调用会用新的快照把差异补上。
- 假设用户在浮层收起态点开用量按钮,当浮层打开,则 webview 向宿主发一次
desktopRefreshAccountUsage,宿主调getAccountInfo做一次全量核对(套餐两根条、到期日、结论行、企业池、API 余额)并推送;一次打开只发一次(不随渲染反复发、不因页签切换再发);核对失败时保留上次成功显示,不弹错、不清空(与既有「失败保留」口径一致)。 - 假设登录态发生变化(登录 / 登出 / 切换主机)或宿主首次拿到账户数据,当状态变化,则照旧推送
desktopAccountInfo;此后没有任何周期性请求——刷新的触发源只有三个:账户状态变化的推送、每次带额度头的响应、打开浮层时的那一次核对(60 秒定时轮询删除,见边界情况)。 - 假设响应里没有任何
x-codechat-quota-*头(私有化部署、自定义网关、中间层剥掉了不认识的响应头、或对端根本不是 codechat),当收到该响应,则不更新任何用量数据、不触发任何额外查询,已有数据保持不变;用量区仍按「有没有用量数据」渲染(billing/apiQuota双缺 = 不渲染,见上一故事场景 22)——客户端不判部署模式,「没有头」就是「这次没有新信息」,不等于「0 额度 / 不限额 / 被阻断」。 - 假设头解析:
limit三态解码为unlimited → null(不限制)、unavailable → 0(该维度不可用)、数字原样;api-limit只有两态(unlimited → null、数字原样 —— API 余额是充值制,0就是「已用完」,与套餐侧的0语义不同);数值不可解析(非数字、负数、空串)时该项按「没给」处理(不更新、不错写成 0,避免把健康的账号渲染成「已用尽」)。 - 假设头里没有
api-*三个字段(ok_plan路径故意不发:套餐可用时不查api_quotas,没有数字可发),当映射apiQuota,则保留上次已知的apiQuota、不清空——否则正停在余额维度的用户会被下一次套餐计费调用的响应把余额行挤掉(apiQuota从此只在「打开浮层核对」与「头里确实给了api-*」两种时机变化)。 - 假设同一份
billing既来自响应头也来自账户接口,当两者先后写入,则后到的覆盖先到的、不做新旧比较也不打时间戳——两者数值同源(服务端 proxy 闸口与账户卡片共用getBillingVerdict同一份判定),差异只可能是「一次调用的时间差」;客户端不得为了对齐两者而额外请求。 - 假设该响应同时带
x-codechat-quota-utilization/-surpassed-threshold/x-codechat-quota-status,当渲染预警,则仍按浮层既有的「剩余 < 限额 20%」自算,不改用头里的阈值——同一件事只有一套阈值,避免头(0.8)与浮层(20%)两处算法给出不同档位;claim/reset/weekly-reset/monthly-reset当前只解析、不渲染(重置时刻与「哪个窗口用完」由到期日与结论行承担,见故事一场景 4–7)。
用户故事:账户卡片 · 个人信息行与纯功能菜单(优先级:P1)
作为桌面端用户,我希望点击头像/姓名热区弹出纯功能菜单(设置/企业控制台/帮助文档/退出登录),菜单贴着个人信息行向上弹出、与卡片等宽,再次点击热区或失焦/Esc 可收起,以便操作账户相关功能且不被用量信息干扰。
为什么是这个优先级:这是账户卡片新模型对既有「更多按钮 + 用量浮层」双入口的结构性替换(已登录不再有独立「…」更多按钮),是账户卡片的核心差异,必须最先明确(P1)。
独立测试:登录态验证热区点击开/关菜单、菜单含 4 项且无用量块、贴行弹出、与卡片等宽(右缘对齐);点击外部与 Esc 收起;方向键/Enter/Space 键盘操作;未登录态验证整条登录按钮 + 更多按钮仍保留;远程主机标注。
验收场景:
- 假设
credential.kind = "none"(既无 SSO 也无自定义网关凭据),当查看账户卡片,则显示整条「登录」按钮(占满卡片宽度)+ 右侧「更多」(…)按钮;点登录走现有 SSO 流程;「更多」菜单含登录项。(「未登录」不是本条的判据——有网关凭据时卡片走「认证方式」故事的网关形态,不渲染登录按钮。) - 假设企业账号形态(
credential.kind = "sso"),当查看个人信息行,则显示头像(邮箱前缀首字母圆形占位)+ 姓名(邮箱前缀;无邮箱显示「已登录」),行尾部为更新按钮(有更新时)+ 用量按钮(打开用量浮层);不显示独立「…」更多按钮。 - 假设企业账号形态下用户点击头像/姓名热区(
data-testid="account-card-hotzone",热区aria-expanded反映菜单开合,支持 Enter/空格开合),当菜单展开,则菜单纯功能、仅 4 项:「设置 / 企业控制台(↗)/ 帮助文档(↗)/ 退出登录」(退出登录为危险色 + 上方分隔线),不含任何用量信息(网关形态的 3 项菜单见「认证方式」故事场景 3)。 - 假设菜单打开,当查看菜单,则菜单向上弹出、盖住卡片上方内容(不遮挡个人信息行);内容自动换行、不超出面板。
- 假设菜单已打开,当再次点击个人信息热区,则菜单收起(toggle);若用量浮层同时开着,则按下一故事的互斥规则先收起浮层。
- 假设菜单已打开,当点击菜单外部任意区域或按
Esc,则菜单收起(焦点回到触发热区)。 - 假设菜单已打开,当使用键盘(方向键在菜单项间移动焦点、Enter/Space 激活、Esc 关闭——roving tabindex),则焦点按方向键循环移动,激活项执行对应行为。
- 假设用户选择菜单项,当点击「设置」,则打开设置页面;点击「企业控制台(↗)」则打开对应网址(新标签页/外部);点击「帮助文档(↗)」则浏览器新标签页打开
serverUrl + /docs;点击「退出登录」则执行登出,卡片形态由退出后的凭据决定——本机还配着网关凭据时落到「自定义网关」形态(并因此在菜单项上提前带副标题说明,见「认证方式」故事场景 4),否则落到无凭据形态。 - 假设账户卡片处于远程主机会话(host 非 local),当查看菜单,则「设置 / 退出登录」项旁标注主机名(如「设置(prod)」),本地主机不标注;用量与菜单登录项跟随聚焦分屏所属主机。
用户故事:账户卡片 · 认证方式(企业账号 / 自定义网关 / 无凭据)(优先级:P1)
作为把网关凭据写在自己 ~/.wave/settings.json 的 env 里(或机器环境变量里)、并用它正常干活的桌面端用户,我希望卡片如实告诉我「当前靠哪种凭据在调模型」,而不是因为我没登录企业账号就把整张卡片渲染成「未登录 + 一个登录按钮」(甚至把我的发送入口也锁掉),以便我一眼确认自己到底是什么身份在用这个产品,也不会误以为产品认为我用不了。
为什么是这个优先级:这是账户卡片的语义层修正——卡片此前只有「已登录 / 未登录」二分,把「没有 SSO token」直接当成「没有凭据」,于是「有可用凭据的人被渲染成不能用的状态」这一整类失效(含发送入口被锁)都归它。它与用量、菜单、更新并列在卡片主结构上,故 P1。
核心口径(2026-11-09 定稿,B 形态):凭据是「认证方式」,不是「登录 / 未登录」二分
- 卡片形态由凭据解析结果决定,优先级硬编码对齐 SDK 的
resolveGatewayConfig:企业账号(SSO token)> 自定义网关(WAVE_API_KEY,WAVE_BASE_URL可选)> 无凭据。两者都配了就是企业账号形态,网关凭据退化为「退出登录后会用到的那份」,由菜单项副标题点名(场景 4)。 - 判据必须是 SDK 真正会用的那份解析结果:
settings.json的env、组织托管 / 远端下发后镜像进进程环境的同名变量、OS 环境变量都算。不得用「配置文件里有没有WAVE_BASE_URL/WAVE_API_KEY这两行字面量」当判据——组织托管下发的凭据不在用户文件里,按字面量判会把配好了的人判成「无凭据」。 - 不新增凭据配置入口:本故事只改状态展示。设置页仍然不得出现 API Key / Base URL / headers 输入控件(sso-auth.md 边界情况「插件端不再有直连免登录旁路」2026-09-10 拍板不下线、本次也不回加);网关凭据的唯一配置路径仍是
settings.json的env与 OS 环境变量。 - 不做「运行时切换凭据」开关:切换凭据就是登录 / 退出登录这两个既有动作(对齐
resolveGatewayConfig的硬优先级)——SSO token 在时永远走企业账号,要落到网关只能退出登录。故卡片不给「切换到网关」这类按钮,改为把「退出登录之后会发生什么」写在菜单项上。
独立测试:给宿主喂三种 credential(sso / gateway / none)验证卡片形态、菜单项与用量按钮;gateway 形态下验证主行「自定义网关」、无用量按钮、有更新按钮、无「更多」按钮、热区可开菜单、菜单恰为「设置 / 帮助文档↗ / 登录企业账号」;几何断言(先红后绿):同一容器宽度下取 sso 与 gateway 两态的 .account-card-main getBoundingClientRect().height 与 .account-card 的 clientHeight,要求相等(行业高与卡片总高都不得因形态变化而变),并断言主文本容器 scrollWidth <= clientWidth(「自定义网关」6 字在 235px 卡内不截断)——改动前先让这条在旧实现上跑出预期值、换实现后不复现;喂 gateway 且 gateway.host: null 验证 title 读「自定义网关 · 默认端点」、行内文本仍是「自定义网关」(域名缺省不进可见文本);喂 sso + gateway 双凭据验证「退出登录」项出现副标题「退出后使用自定义网关 …」、点退出后直接落到网关形态(不经过「无凭据」中间态);旧宿主不下发 credential 时验证卡片回落到 isAuthenticated 的旧行为(向后兼容);网关形态点「登录企业账号」验证走既有 SSO 流程且成功后切形态;手改 settings.json 的 env 后验证刷新时机(场景 10)与卡片一致。
验收场景:
- 假设
credential.kind = "sso"(企业账号),当查看账户卡片,则渲染与既有登录态逐字一致(个人信息行 + 用量按钮 + 有更新时的更新按钮 + 4 项菜单),本故事不改变任何已登录形态的展示。 - 假设
credential.kind = "gateway"(无 SSO、解析出WAVE_API_KEY),当查看账户卡片,则个人信息行与账号形态完全同构、单行、行高不变(24px 圆形图标位 + 单行 14px/500 主文本 + 行尾按钮区):「图标位内为网关图标」(不再是首字母头像),主文本为「自定义网关」,行尾不渲染用量按钮(网关通路拿不到billing/apiQuota)、照常渲染更新按钮(应用更新是 app-global、与凭据无关);不渲染尾部「更多」(…)按钮,菜单改由个人信息行热区打开(与已登录形态同一套结构,避免同一张卡片出现两种入口习惯)。网关主机名不进行内:卡片可用宽度约 235px、图标位与行尾按钮之后只剩约 160px,「自定义网关」+ 域名同行会两段都被 ellipsis 截断(「自定义…api.ex…」,两端都没信息量),加第二行则把行高从 30px 抬到约 46px——卡片flex-shrink: 0钉在侧栏底部,长高会挤压会话列表,也让两种形态不对称。故域名只放该行的title(「自定义网关 ·api.example.com」,hover 出全文),行高与账号形态逐像素一致(几何断言见「独立测试」)。 - 假设
credential.kind = "gateway",当点击个人信息行热区,则菜单恰为 3 项:「设置 / 帮助文档(↗)/ ─── / 登录企业账号」——不含「企业控制台」(企业账号专属,网关形态下没有对应的运营端身份)与「退出登录」(没有登录可退);点「登录企业账号」走既有 SSO 流程,成功后卡片切到场景 1 的形态。 - 假设企业账号与网关凭据同时存在,当查看菜单,则卡片仍是企业账号形态(优先级对齐
resolveGatewayConfig),且「退出登录」项带副标题「退出后使用自定义网关<host>」——把「退出登录 ≠ 断网,而是落到你配的那份凭据」提前说清,避免用户点完以为自己把产品弄坏了;假设此时点「退出登录」,则直接落到网关形态(场景 2),不经过「无凭据 + 登录按钮」的中间态。 - 假设
credential.kind = "none"(既无 SSO 也无网关凭据),当查看账户卡片,则渲染与既有未登录态逐字一致(整条「登录」按钮 + 尾部「更多」按钮;菜单含登录项),本故事不改变无凭据形态的展示。 - 假设
credential.gateway.host为null/ 缺失 / 无法解析(只配了WAVE_API_KEY、WAVE_BASE_URL缺省 → 走 SDK 默认端点),当查看卡片,则行内文本仍是「自定义网关」,title读「自定义网关 · 默认端点」,不留空、不报错、不把主机名渲染成undefined。 - 假设
gateway形态下用量浮层的入口不存在,当卡片渲染,则webview 不发desktopRefreshAccountUsage(没有任何入口能触发它),也不出现任何额度结论行;私有化/网关场景的「双缺即不渲染」口径(故事一场景 22)继续成立,本故事不把「网关」解释成任何额度状态。 - 假设
gateway形态且聚焦分屏属于远程主机,当查看菜单,则「设置 / 登录企业账号」项旁标注主机名(如「设置(prod)」,与故事四场景 9 同口径)——凭据形态本身也跟随聚焦分屏所属主机(远端有自己的~/.wave/settings.json与环境)。 - 假设宿主不下发
credential(旧版宿主 / 尚未接线的宿主),当卡片渲染,则回落到isAuthenticated的旧判据(已登录 = 企业账号形态,未登录 = 无凭据形态),不出现空白卡片、不抛错;这是三宿主接线期间唯一允许的降级路径。 - 假设用户在外部手改了
settings.json的env(加/删WAVE_API_KEY),当卡片刷新,则形态跟着变——刷新时机与既有认证状态一致(登录 / 退出登录 / 切换聚焦主机 / webview 就绪这四个时机),不承诺热跟随文件改动(宿主不监听用户级配置文件);这一条要写在用户可读的地方,避免「改完文件卡片没变 = 坏了」。落点(2026-11 定稿):docs/tutorials.md「接入本地模型」末段紧接「改完下一轮对话即生效,无需重启」之后补一句——对话已用新配置、卡片只在窗口重新加载/切主机/登录退出时重查、重开一次窗口即对齐。不写进设置页:设置页没有env/ 凭据编辑入口(这些键只能手改文件或由远端下发),在一张与凭据无关的卡片旁挂说明会变成孤立文案;也不新增「窗口聚焦即重查」的热跟随行为(属另一件事,未立项)。 - 假设网关凭据指向的端点其实要求企业账号(请求会 401/403),当卡片渲染,则仍显示网关形态——卡片只表达「配了哪种凭据」,不预判这份凭据能不能用(可用性只有真实请求的响应知道),错误由会话里的报错气泡承担,不把卡片降级成「未登录」。
用户故事:账户卡片 · 更新按钮状态机 S0–S3(优先级:P2)
作为桌面端用户,我希望新版本在后台安静下载好,等我想升级时点开「更新」,先看清这一版改了什么,再决定什么时候重启,以便升级既不打断当前工作,也不用为一次升级多做无谓的点击或看一屏自动弹出的窗口。
为什么是这个优先级:按钮状态机依赖宿主下发 update 快照并响应重启命令,涉及 desktop 主进程改动(宿主须把 electron-updater 事件映射为快照并响应 desktopUpdateRestart 命令);宿主侧就绪前按钮无数据可渲染,故整体排 P2(在 P1 的用量常驻/菜单稳定后接线)。webview 侧状态机与宿主接线均属本规格验收范围。
产品口径:后台下载完全静默、重启前保留一次知情与确认(2026-11-06 定稿)。这一版把更新收成两下:宿主检查命中即在后台下载,下载中与下载失败都不显示任何按钮(不转圈、不占位、不弹窗);下载完成才出现「更新」按钮,点开是「重启以完成更新」确认框——正文下面是本次更新内容(release notes),底部「稍后 / 立即重启」,由用户看完再点一下「立即重启」。不自动弹框、不自动重启:下载可能在用户专注工作时完成,弹窗会打断;而重启一定要由用户按下。演进:更早的版本要求下载前再确认一次(旧「更新到新版本」对话框),一次更新最少点三下(更新 → 下载更新 → 立即重启);同日早先的版本改为「下载中显示『正在下载』disabled + 就绪自动弹一次确认框」(一次点击,但会主动打断),最终按本节口径定稿。代价(已接受):下载中用户看不到任何进度,弱网下也看不到「正在下」的状态,只能等按钮出现;发现即下载,计量网络下会主动吃流量,且没有「先别下」的入口。
独立测试:webview 侧(1)update 为 null / available: false 时不渲染按钮;下发 { available: true, version, releaseNotes } 时按钮呈现「更新」且不自动弹出任何对话框、不发任何命令;(2)点击按钮弹出「重启以完成更新」确认框(按钮「稍后 / 立即重启」),[稍后] 关闭后按钮仍在并可再次打开,[立即重启] 发 desktopUpdateRestart;(3)快照带 releaseNotes(数组,含两个版本)时确认框按版本分段渲染「本次更新内容」块(段标题 vX.Y.Z、新 → 旧、块内滚动、不撑破按钮区),空数组/缺失时不渲染该块、确认框其余部分逐字不变;宿主侧用例覆盖:update-available 不推送快照(按钮不出现)、update-downloaded 才推 available: true + 过滤后的 releaseNotes、归一化(字符串 → 单元素数组、数组按已装版本过滤 + 坏版本号丢弃 + 倒序、过滤后为空不下发)、下载失败只记日志不推可点状态;点「立即重启」后 quitAndInstall 立即执行,并给退出保护留下一次性标记(同一次退出不再弹退出确认框)。
验收场景:
- 假设无更新、或宿主仍在后台下载、或下载失败,当查看个人信息行,则不显示更新按钮(S0):下载中不出现「正在下载」之类的进行中态,也不提供取消入口;下载失败静默,等下一次检查(运行期轮询 / 下次启动)自动重试。
- 假设宿主完成下载(
update.available: true),当查看个人信息行,则显示「更新」按钮(S1)——品牌红实底的主行动样式;不自动弹任何对话框、不抢焦点、不发命令。 - 假设用户点击「更新」,当点击,则弹出居中的「重启以完成更新」确认框(S2):标题「重启以完成更新」,正文提示「新版本已就绪,重启后生效。重启会中断正在运行的任务,建议先保存工作。是否立即重启?」,按钮「稍后 / 立即重启」;不自动重启。当快照还带
releaseNotes(宿主已按已装版本过滤、按版本倒序的数组,来源与口径见 desktop-distribution.md「更新内容(Release Notes)」),则在正文下方渲染「本次更新内容」块:每一项一段,段标题为版本号vX.Y.Z,其下是该版本的平铺变更条目(即使只有一项也保留版本号段标题——渲染形态只有一种);段落之间按数组顺序(新 → 旧)排列,块整体限高滚动(超出对话框可视高度时块内滚动,不得撑高对话框挤坏按钮区)。条目是该版本的 markdown 文本,用仓库已有的marked+DOMPurify渲染,仅允许段落/列表/加粗等轻量语法,不得渲染可点击链接(生成的记录本身不含链接与作者)——记录块是纯展示,无可交互元素、无折叠、无「查看全部」。releaseNotes缺失 / 空数组 / 所有项note为空或仅空白时不渲染该块,确认框标题、正文、按钮与交互与无记录时逐字一致。这一屏对齐 Electron 官方的 auto-updater 最佳实践(update-downloaded时把releaseNotes放进取舍框),不新增状态机步骤。 - 假设用户在确认框点击「稍后」(或按
Esc、点对话框外部——与「稍后」等价),当对话框关闭,则按钮保持「更新」,期间应用可正常使用;再次点击按钮重新弹出同一确认框。 - 假设用户点击「立即重启」,当确认重启,则 webview 向宿主发送
desktopUpdateRestart命令,更新状态复位、按钮消失(S3);是否实际退出安装由宿主处理——宿主不替用户判断运行中的会话:确认框正文已写明「重启会中断正在运行的任务」,点下「立即重启」即知情下的强制重启,立即quitAndInstall(没有「等会话结束再重启」的等待态);这次退出同时给退出保护留一个一次性标记(consumeUpdateRestartQuit),见 desktop-shell.md「退出时保护运行中的会话」。这是用户在这次更新里唯一的「执行」点击(连同打开确认框的那一下,共两下)。
边界情况
三种凭据形态,不是「登录 / 未登录」二分(2026-11-09,见「认证方式」故事):企业账号(
credential.kind = "sso",既有登录态)/ 自定义网关("gateway",无 SSO 但解析出WAVE_API_KEY——单行「自定义网关」且行高与账号形态一致(域名只在title里),无用量按钮、有更新按钮、热区开 3 项菜单)/ 无凭据("none",即原「未登录」:登录按钮 + 更多按钮、无用量浮层、无个人信息行、无更新提示)。判据取 SDK 实际解析的凭据,不取配置文件字面量;credential缺失(旧宿主)时回落isAuthenticated。网关凭据不预判可用性:
kind = "gateway"只说明「配了这份凭据」,不说明它一定通得过(端点可能 401 / key 可能过期 / 远端主机上根本是另一份配置)。卡片不做预检、不因此降级成「未登录」,失败由会话报错承担。同理,网关形态不展示任何额度信息(网关通路没有x-codechat-quota-*头也没有/api/v1/account,见「用量刷新时机」故事场景 6)。未配置套餐 / 均未下发:
billing.plan为null(无生效套餐、且没有未作废的已到期订单——从未购买、或订单已全部作废)时套餐块不渲染;billing与apiQuota均未下发(私有化部署的常态,见下条「私有化部署」)时用量浮层没有内容、用量按钮不渲染。套餐三态别混:
limit = null(不限制)与limit = 0(不可用)是两种状态,都不画条,但文案与视觉必须区分;触顶(该维used ≥ limit)读「0%」(空条 + 常规色,2026-10-09 起)——条只表达余量、超用封底到 0,「哪个窗口用完 / 现在按什么计费」由结论行回答。旧「整单大池子」口径(monthlyQuota × months)随旧plan字段一并退出卡片:后端仍保留旧字段兼容已发布客户端,新卡片不再取数。用量浮层内 = 两个互斥维度(2026-10-09 交互定稿):套餐用量与 API 余额不并列展示,用页签切换,默认套餐维度。理由是二者地位不对等——套餐没耗尽时 API 余额不参与计费、余额为 0 也不影响使用,故默认不占版面;两维只有一维有数据时不渲染页签、直接展示那一维。维度选择是卡片内的视图状态(不落盘、不跨会话)。
结论行优先级:
blocked> 不可用(dimension_unavailable)> 已用尽(月优先于周)> 无结论行;enterprise照渲染(企业本期额度触顶 ⇒ 琥珀预警,与月/周同档;2026-09-22 起,原先刻意不渲染)。枚举是开放集合(服务端可能先于客户端发版):
billing.reason/billing.code的新取值由服务端先上线、已发布的客户端不认识(两端不同机发布)。客户端必须对未识别取值给兜底结论行(文案与色调见场景 23),不得静默;兜底文案不含对具体原因的猜测(不点名月/周/企业哪一池用完)。spec 背书、因此维持不渲染的三种情形(本次不碰、也不是缺陷):mode = "plan"(套餐可用,无话可说)、reason = "no_plan"且plan = null(从未购买或订单已全部作废,无到期日可讲)、billing与apiQuota双缺(用量区整体不渲染,= 私有化部署,见「边界情况」的私有化条)。API 余额(余额维度):面板两行「已用 / 剩余」,金额右对齐 + 等宽数字;剩余 < 限额 20% 金额预警色 + 琥珀提示;剩余 ≤ 0 显示「已用完」错误色 + 红色提示;
limit = null显示「不限额」。ⓘ 排在余额维度的右上角(与套餐维度的到期日同一处,不回金额行,见场景 16),只出 tooltip 说明用途。apiQuota.limit≠ 可用性(2026-11-05):limit只是「此刻若降级到 API 计费还能用多少」,不是「当前能不能用」——TEAM_QUOTA_ZERO下未充值企业恒得limit = 0,而套餐可能还剩大半。故余额维度的「已用完 / 预警色 / 红色充值提示」只在billing.mode = "api"或billing缺省(宿主只下发了余额维度)时出现;mode = "plan"一律按数值渲染(封底 0,「¥0.00」),mode = "blocked"改用 proxy 402 文案(面板内account-api-conclusion)。客户端不得用apiQuota.limit反推计费结论(issue #2776)。下载中:更新按钮 disabled,点击无反应;不提供取消入口。
不替用户判断运行中的会话:确认框正文已写明「重启会中断正在运行的任务」,用户点下「立即重启」就是知情下的强制重启——宿主立即
quitAndInstall,不再数会话、也没有「等会话结束再自动重启」的等待态与后台复查定时器(2026-11 定稿:人工确认已经足够,自动等待是同一个决定被问两遍)。对话框:重启确认框(S2)点对话框外部不关闭,
Esc/ 「稍后」等价;不自动关闭、不自动重启。远程主机:个人信息菜单「设置 / 退出登录」标注主机名;用量与登录态跟随聚焦分屏所属主机。
用量数据刷新失败:打开浮层时的核对请求失败(网络错误 / 超时 / 服务端 5xx)或推送写不进去时,保留上次成功显示——不清空、不弹错、不把卡片降级成「没有用量」;下一次带头的响应或下一次开浮层再补上。
60 秒轮询已删除(2026-11-06,A 形态):不再有任何周期性的
GET /api/v1/account。刷新只剩三个触发源——账户状态变化的推送、每次带x-codechat-quota-*的响应、打开用量浮层时的一次核对(场景 5)。收益:私有化部署不再每分钟打一次恒返回{}的接口;代价(已接受):共享 API 余额被同事消耗时,最长到下次打开浮层才发现。额度头是热路径上的免费输出:头里的数字全部来自 proxy 那一次
getBillingVerdict(零额外 DB 查询),故客户端不得因为「头不全」就去补一次账户接口请求——头缺什么就只更新它给了的那几个字段(场景 7、8),也不得为解析头新增任何定时器或后台任务。头的值一律纯 ASCII 英文标识(
month/unlimited/EXPIRED_NO_API等),中文文案由客户端映射;服务端新增的未知reason/code取值仍走上一故事场景 23 的兜底文案,不得静默(两端不同机发布,头与会话接口同一个风险面)。私有化部署整块不下发(2026-11-06 对齐 codechat 定稿):codechat
GET /api/v1/account在私有化(SAAS_MODE非true,未设置即私有化,判定入口是 codechat 的deploymentMode.ts)下恒返回空对象{}——billing/plan/apiQuota一个都不给(三块数据全由套餐订单 / API 充值订单 /api_quotas派生,私有化不部署运营端、这些数据不存在)。客户端以「billing与apiQuota双缺」为「本部署没有额度体系」的信号:用量按钮整颗不渲染、不出现任何额度结论行,且不得解释成 0 额度 / blocked / 不限额三者中的任何一个。判据为什么是「双缺」而不是 codechat spec 措辞里的「缺billing为唯一信号」:本规格的场景 14 / 17 / 24 明确支持「宿主只下发余额维度」这一契约形态(billing缺、apiQuota在 ⇒ 只展示余额),而私有化两者都缺,两条口径在私有化下结论一致、不冲突。桌面端拿不到部署模式字段(saasMode只随企业端登录响应与权限目录下发,wave 的getAuthStatus只回isAuthenticated/user),因此客户端不做任何「是不是私有化」的判断,只按「有没有用量数据」渲染;SaaS 下非 active 成员(待审核 / 已拒绝 / 已禁用 / ops_admin)同样是空对象,走同一条路径。
数据契约与命令
desktopAccountInfo(窗口级推送,无 paneId)
update? 字段承载更新按钮状态机输入(S0–S3),完整契约如下:
ts
export interface AccountUpdateInfo {
available: boolean; // 新版本已下载完成、可重启(下载中宿主不推快照)
version?: string; // 新版本号(宿主从 UpdateInfo.version 带回,随快照下发)
/** 变更记录,宿主已按「已装版本」过滤并按版本倒序(每项一段,见场景 3)。
* 取自 electron-updater 的 `UpdateInfo.releaseNotes`,来源与生成口径见
* desktop-distribution.md「更新内容(Release Notes)」。
* 缺失 / 空数组 = 没有可展示的记录,确认框不渲染记录块。 */
releaseNotes?: Array<{ version: string; note: string }>;
}
/** 套餐两根额度条的四个数 + 到期日(codechat `GET /api/v1/account` → `billing.plan`). */
export interface AccountBillingPlanUsage {
monthUsed: number;
monthLimit: number | null; // 滚动月限额;null = 不限制,0 = 该维度不可用
weekUsed: number;
weekLimit: number | null; // 自然周限额;null = 不限制,0 = 该维度不可用
expireDate: string; // 套餐到期日(YYYY-MM-DD)
}
/**
* 降级原因(客户端据此选结论行文案;枚举由后端定义,见 codechat spec 场景 5–11).
* 五个原因**各有结论行**(`enterprise` 自 2026-09-22 起也渲染,见场景 7);唯一
* 「不渲染」的组合是 `no_plan` 且 `plan = null`(从未购买)。
* 该类型只是**客户端侧的枚举快照**:服务端可能先发版新增取值,客户端必须对规格外
* 取值渲染兜底结论行(见场景 23),不得静默。
*/
export type AccountBillingDegradeReason =
| "month"
| "week"
| "enterprise"
| "no_plan"
| "dimension_unavailable";
/** 阻断码(客户端按 code 复用 proxy 402 文案;同样只是枚举快照,规格外取值走场景 23 的兜底文案). */
export type AccountBillingCode =
| "EXPIRED_NO_API"
| "USER_QUOTA_ZERO"
| "TEAM_QUOTA_ZERO";
/**
* 计费结论(codechat 形态甲:闸口镜像)。判定出自后端 `getBillingVerdict`——与 proxy 扣费
* **同一份代码**;客户端只渲染、**不自行判断「走套餐还是走 API」**(旧方案由客户端比较数字,
* 已因限额「0」语义翻转发生过一次静默失效)。`plan` 子对象:有生效套餐 = 四数 + 到期日;
* 无生效套餐但有**未作废**的已到期订单 = 仅 `{ expireDate }`;从未购买、或订单已全部作废 = null。
*/
export type AccountBillingInfo =
| { mode: "plan"; plan: AccountBillingPlanUsage }
| {
mode: "api";
reason: AccountBillingDegradeReason;
plan: AccountBillingPlanUsage | { expireDate: string } | null;
}
| { mode: "blocked"; code: AccountBillingCode };
/**
* 凭据形态(「认证方式」故事的判据)。取值由 CLI 侧按 SDK `resolveGatewayConfig`
* 的同一套优先级算出:sso(SSO token 在)> gateway(无 SSO、解析出 WAVE_API_KEY)>
* none。**不允许**由 webview 自己读配置文件或 env 推断——组织托管 / 远端下发 /
* OS 环境变量这三层只有会话进程看得全。
*/
export type AccountCredentialKind = "sso" | "gateway" | "none";
export interface AccountCredential {
/** 当前生效的那一份(sso 与 gateway 都算「有可用凭据」)。 */
kind: AccountCredentialKind;
/** 已配置的自定义网关:host = baseURL 的 host;null = 只配了 key、baseURL 缺省
* (走 SDK 默认端点,卡片读「默认端点」)。
* - kind = "gateway" 时它就是当前生效的网关;
* - kind = "sso" 且存在时,它是**退出登录后会落到的那份**(「退出登录」项据此
* 带副标题,场景 4);
* - null / 缺失 = 本机没有网关凭据(kind = "none" 时必然如此)。 */
gateway?: { host: string | null } | null;
}
export interface DesktopAccountInfoMessage {
command: "desktopAccountInfo";
/** SSO 登录态(语义不变,= credential.kind === "sso")。保留是为了兼容
* 未接线的宿主 / 其他消费者;卡片与新逻辑一律读 `credential`。 */
isAuthenticated: boolean;
/** 凭据形态。`sso` 与 `gateway` 都算「有可用凭据」;缺失 = 旧宿主,回落 isAuthenticated。 */
credential?: AccountCredential;
user?: { id: string; email?: string } | null;
billing?: AccountBillingInfo | null; // 套餐两根条 + 到期日 + 计费结论
apiQuota?: AccountApiQuotaInfo | null; // { limit: number|null, used }
update?: AccountUpdateInfo | null; // 应用更新状态(非 null = 就绪可重启,S1 起)
}下发规则:
credential与isAuthenticated同源、同一次查询:都来自 CLI 的getAuthStatus(该 RPC 回包新增credential),desktopHost 把它连同isAuthenticated一起写进accountCache并推进两个 webview 报文(authStatusResponse与desktopAccountInfo),不新增 RPC。计算放在 CLI 侧(agentBridge.getAuthStatus):AuthService.isSSOAuthenticated()为真 →sso;否则先问一个活着的会话的Agent.getGatewayConfig()(它与真实请求同源,已含用户 / 项目 / 远端三层env),没有会话时退回「用户级~/.wave/settings.json的env(loadUserConfigEnv())→ OS 环境变量」;gateway.host取该baseURL的 host(缺省 / 不可解析 →null);都没有 →none。两处实现陷阱:①settings.json的env里只有WAVE_SERVER_URL会被镜像进process.env(setEnvironmentVars),故不能只看process.env判网关凭据;②sso分支下不能用getGatewayConfig()(此时它返回的是 SSO 凭据),回落的那份网关只能从配置层读。判据只有这一处,取值顺序必须与resolveGatewayConfig一致。- 无凭据(
kind: "none"):isAuthenticated: false,billing/apiQuota/update可不带。 - 网关(
kind: "gateway"):isAuthenticated: false但有可用凭据——billing/apiQuota必不带(网关通路没有用量数据),update照常(与凭据无关)。消费方不得再用isAuthenticated === false反推「不能用」(发送入口、欢迎页登录 CTA 同理,见 sso-auth.md 场景 5 / 8 的判据改写)。 - 无生效套餐、且没有未作废的已到期订单(从未购买,或订单已全部作废):
billing.plan: null(套餐块不渲染);billing与apiQuota均未下发时用量浮层没有内容、用量按钮不渲染。 - API 不限额:
apiQuota.limit: null(共用团队余额)。 - 无更新:不带
update或update.available: false。 releaseNotes由宿主从 electron-updater 的UpdateInfo.releaseNotes归一化并按已装版本过滤后下发(generic provider 把 feedlatest*.yml里的同名字段原样透传上来):数组形态逐项保留compareVersions(parseVersion(item.version), parseVersion(app.getVersion())) > 0的项、版本号不可解析的项丢弃、结果按版本倒序;字符串形态包装成单元素数组[{ version: info.version, note }];过滤后为空、缺字段、空串/仅空白一律不下发(webview 按 S2 场景 4 不渲染记录块)。版本比较只在宿主做——webview 不认识版本比较,只渲染收到的数组,也不做任何过滤或排序。- 账户/用量/结论/更新状态变化时宿主主动推送新快照;轮询失败保留上次成功数据。
- 宿主只做透传:
billing有两个来源,都是服务端同一份判定(proxy 闸口与账户卡片共用getBillingVerdict)——① codechatGET /api/v1/account下发,经 CLIgetAccountInfo→ desktopHostaccountCache原样带到卡片(只在打开浮层时核对一次);② 模型响应头x-codechat-quota-*,由宿主按下方「额度响应头」小节映射成同一份契约。宿主不重算任何判定或窗口(窗口起点、限额聚合口径都在服务端,客户端一无所知)。
额度响应头 x-codechat-quota-*(模型调用响应 → 宿主输入)
模型调用的响应(proxy 转发)在每次调用上带一组额度头,数值即该次闸口判定的结果。19 个字段(缺失 = 本维度没有数据,不得用 0 或 rejected 表达):
| 头 | 取值 | 说明 |
|---|---|---|
x-codechat-quota-status | allowed / allowed_warning / rejected | 有快照即恒发。本规格只用来判断「这次带了额度头」;预警档位仍按浮层自算(场景 10) |
x-codechat-quota-claim | weekly / monthly / enterprise / api / none | 代表性限额(哪一池最可能先触顶)。只解析、不渲染 |
x-codechat-quota-reset | unix 秒 | 代表限额的重置时刻;claim 为 api / enterprise / none 时省略。只解析、不渲染 |
x-codechat-quota-weekly-used | 数字 | 本自然周已用积分 |
x-codechat-quota-weekly-limit | 数字 / unlimited / unavailable | 三态:null→不限制、0→该维度不可用、正数→限额 |
x-codechat-quota-weekly-reset | unix 秒 | 本窗口重置时刻(下周一 00:00,北京时间)。只解析、不渲染 |
x-codechat-quota-weekly-utilization | 0–1(4 位小数) | 该维限额为正数时才发。只解析、不渲染 |
x-codechat-quota-weekly-surpassed-threshold | 0.8 | 越过 80% 才发。只解析、不渲染 |
x-codechat-quota-monthly-used | 数字 | 本滚动月已用积分 |
x-codechat-quota-monthly-limit | 数字 / unlimited / unavailable | 同周三态 |
x-codechat-quota-monthly-reset | unix 秒 | 本窗口重置时刻。只解析、不渲染 |
x-codechat-quota-monthly-utilization | 0–1(4 位小数) | 限额为正数时才发。只解析、不渲染 |
x-codechat-quota-monthly-surpassed-threshold | 0.8 | 越过 80% 才发。只解析、不渲染 |
x-codechat-quota-plan-expire | YYYY-MM-DD | 有生效套餐(或仅剩已到期订单)时才发 |
x-codechat-quota-reason | month / week / enterprise / no_plan / dimension_unavailable | 降级原因,只在判定走 API 时下发 |
x-codechat-quota-code | EXPIRED_NO_API / USER_QUOTA_ZERO / TEAM_QUOTA_ZERO | 402 阻断码 |
x-codechat-quota-api-status | allowed / rejected | 只在判定走 API 时下发(ok_plan 路径不查 api_quotas,三个 api-* 一个都不发) |
x-codechat-quota-api-used | 数字 | 同上 |
x-codechat-quota-api-limit | 数字 / unlimited | 充值制:0 就是「已用完」(与套餐侧 0 语义不同) |
映射(只在宿主做一次,webview 认原契约):
| 头里有什么 | → billing | → apiQuota |
|---|---|---|
-code(或 status = rejected) | { mode: "blocked", code }(code 未知时原样透传,webview 走上一故事场景 23 的兜底文案) | 有 api-* 则按右边更新 |
-reason | { mode: "api", reason, plan }:两根条四数齐 ⇒ 四数 + 到期日;只有 plan-expire ⇒ { expireDate };都缺 ⇒ null | 同上 |
无 -reason、无 -code,两根条在 | { mode: "plan", plan: 四数 + 到期日 } | 通常不下发(ok_plan 不发 api-*) |
无 -reason、无 -code、无两根条,只有 api-* | 不下发(webview 走「只下发余额维度」形态,见上一故事场景 17 / 24) | 按 api-* 更新 |
| 一个头都没有 | 不动 | 不动 |
apiQuota由api-limit(unlimited→null)+api-used组装;-api-*缺省时保留上次值(场景 8)。blocked快照在服务端结构上可能仍带两根条四数(quotaFromVerdict的base对 blocked 也生效),但 webview 的blocked分支本来不画条(故事一场景 8),故映射时丢弃——不为此扩张契约。- 时序:流式响应在首字节前设头 ⇒ 头里是「本次调用前」的读数;非流式与 402 / 上游错误路径同值。客户端不得据此反推本次消耗(场景 3)。
- 私有化不发任何头:服务端私有化短路路径根本不生成快照,因此一个
x-codechat-quota-*都不发(不是发 0、也不发rejected)——与GET /api/v1/account恒返回{}同源。客户端以「这次没有头」处理(场景 6),不判部署模式。 - 传递链(实现约束):agent-sdk 在每次请求返回后从响应头筛出这批字段(成功响应取
response.headers;错误响应取 SDK 自己抛出的 HTTP 错误对象上的.headers—— 这一层必须把响应头挂到错误上,否则 402/429 的读数会在热路径上丢掉),经 CLI → stdio 通知送到 desktopHost,由宿主一处映射。VS Code / JetBrains 宿主当前不消费该通知(它们没有本规格的账户卡片)。
套餐额度条计算(按余量计)
barPercent(used, limit) = max(0, round((1 − used / limit) × 100)) // limit > 0- 每根条用自己那维的数(月条 = monthUsed/monthLimit,周条 = weekUsed/weekLimit);
limit = null或0时不画条(分别显示「无额度限制」「不可用」)。 - 100% = 满(未使用);触顶时该条显示空条 +「0%」(常规色,2026-10-09 起)。
命令契约
desktopUpdateRestart(webview → host):用户在重启确认框选择「立即重启」后通知宿主安装并重启(S3)。这是更新流程里唯一由 webview 发出的命令——下载由宿主自己开始,不需要 webview 参与。desktopRefreshAccountUsage(webview → host):打开用量浮层时发一次,宿主据此调getAccountInfo核对全量用量(套餐两根条 + 到期日 + 结论行 + 企业池 + API 余额)并推送新的desktopAccountInfo;失败保留上次成功数据。这是取代 60 秒轮询的主动核对时机(见「用量刷新时机」故事场景 4)。桌面端专属命令(desktop前缀),只需注册桌面端路由。宿主接线:宿主(desktopHost.ts)把 electron-updater 事件映射为
update快照随desktopAccountInfo推送,并响应desktopUpdateRestart。autoDownload必须保持 electron-updater 的默认开启态(不得显式置 false):update-available到达即开始后台下载,但不推快照——webview 因此看不到任何「下载中」形态(S0);update-downloaded才推{ available: true, … }(S1,按钮出现);下载失败只记日志、不推任何可点状态(S0),等下一次检查自动重试;已就绪后的安装阶段错误不降级。toast 更新链路仍然不提供。触发检查的时机(启动自动 / 手动 / 运行期固定间隔轮询,见 desktop-distribution.md「桌面端自动更新」场景 11)不改变状态机语义——任一时机发现新版本都在后台自动下载。降级边界见 desktop-distribution.md「桌面端自动更新」故事场景 5。用量侧接线:
desktopHost消费 stdio 的额度头通知(映射成billing/apiQuota,见「额度响应头」小节)、响应desktopRefreshAccountUsage,并删除原有的accountPollIntervalMs/accountPollTimer/startAccountPolling及其在webviewReady时的启动分支(2026-11-06)。账户状态变化时(登录 / 登出 / 切换主机)仍主动推送快照。
非目标(明确排除)
不做本地用量推算:不从响应头反推本次调用的消耗、不在客户端记账或做「+1 / −1」补偿,也不引入「读数没变 = 没计费」这类推断(场景 3)。额度变化一律以服务端下一次给数为准。
不改预警阈值口径:头里的
utilization/surpassed-threshold/status不接管渲染判据,预警档位仍按浮层既有的「剩余 < 限额 20%」一套算法(场景 10)——同一件事不能有两套阈值。不新增推送通道:不引入 WebSocket / SSE 之类的额度推送,也不把 60 秒轮询换个名字装回来;刷新靠「带头的响应 + 打开浮层时核对」两条既有链路(场景 5)。
账户卡片的通知语义:账户卡片不含 toast/通知语义(信息类 toast 自动消失、带操作按钮 toast 不自动消失等通知行为由会话/后台通知故事承载,桌面端更新提醒由「更新按钮状态机」全权接管)。
不做「切换凭据」开关、也不回加凭据配置入口(2026-11-09):切换凭据就两个既有动作——登录(→ 企业账号)、退出登录(→ 自定义网关或回无凭据),沿
resolveGatewayConfig的硬优先级,不引入「当前用哪份凭据」的运行期选择器(那会让「SDK 实际用了哪个 key」多出一个只有 UI 知道的真相)。设置页仍不得出现 API Key / Base URL / headers 输入控件(sso-auth.md 边界情况 2026-09-10 拍板);网关凭据的唯一配置路径仍是settings.json的env与 OS 环境变量。卡片只做状态展示。不猜未识别的降级原因、也不扩张「静默」的范围:对规格外的
billing.reason,卡片只说明「套餐额度当前不可用,按 API 余额计费」,不猜测哪一池(月/周/企业)用完、也不据此推断恢复时间或提示用户找谁(见场景 23)。同时明确:spec 背书的静默仍是静默——mode = 'plan'(无话可说)、no_plan且plan = null(无到期日可讲)、billing/apiQuota双缺(用量区整体不渲染)这三种维持不渲染,本次兜底只针对「客户端不认识服务端下发的取值」这一类失效(2026-09-22)。