@rayadesu/dsh-client-ui-billing 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record both hashes with:
4
4
  # git hash-object README.md README.zh.md
5
- README.md: 72a43fe5a4e2fd6cb5f0e95232387a3418a4abf8
6
- README.zh.md: 8f3c5aad147d645af91214f4b35015a413722baf
5
+ README.md: cd35c163925d48ff81774fefd45ea8deb06d59bd
6
+ README.zh.md: 7d06d48a6da7337faa1acea887b59a6624a81ea3
package/README.md CHANGED
@@ -2,21 +2,22 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
- Web billing feature owner: contributes one entry to `conversation.session.header.utilities` that mounts its own `billing` Remote, reads the DeepSeek account balance, this session's billed spend, today's spend across every session, today's per-session ranking, and one turn's cost through `billing/getBalance`, `billing/getSessionSpend`, `billing/getTodaySpend`, `billing/getTodaySessionsSpend`, and `billing/getTurnSpend`, and renders them as a top-right label box. The host half of the capability lives in [`dsh-llm-billing`](../llm-billing/README.md), which owns the `/user/balance` transport, the peak/off-peak pricing table, and the `billing` Remote namespace; this package mounts that Remote and renders what it returns.
5
+ Web billing feature owner: contributes one entry to `conversation.session.header.utilities` that mounts its own `billing` Remote and renders the DeepSeek account balance, this session's billed spend (read from the host-pushed `billingTodaySpend` projection, with `billing/getSessionSpend` as the fallback when the projection registry is absent), today's spend across every session (`billing/getTodaySpend`), today's per-session ranking (`billing/getTodaySessionsSpend`), and per-turn costs (`billing/getSessionTurnSpends`) as a top-right label box. The host half of the capability lives in [`dsh-llm-billing`](../llm-billing/README.md), which owns the `/user/balance` transport, the peak/off-peak pricing table, and the `billing` Remote namespace; this package mounts that Remote and renders what it returns.
6
6
 
7
- The trigger shows two lines — the remaining balance and this conversation's billed spend ("本轮对话花费 ¥X") — and renders nothing while the first fetch is in flight. Clicking it opens a label box with the remaining amount, this session's billed spend (本会话花费, priced per message at the official peak/off-peak rate — peak windows apply weekdays Mon–Fri only, weekends are off-peak; a forked session prices only its own post-fork work) with today's all-session spend beside it (今日共花费), one priced row per model with the cache-hit / cache-miss-input / output cost breakdown ("缓存命中 ¥X · 未命中输入 ¥Y · 输出 ¥Z"), a manual refresh action, and a spend disclaimer. The remaining-amount row (`API 剩余金额`) renders larger than the spend rows below it. Below the model rows the panel ends with the **today session-spend ranking** (`今日会话花费`): sessions sorted by today's cost, highest first, each row a fixed right-aligned rank column, then the same middot separator the cost breakdown uses ("·" with the same size, tone, and spacing), then the session's durable title (renames sync automatically), then its amount — the title keeps its indented rank column; at most `SESSION_RANKING_LIMIT` (10) rows render, with a "…还有 N 个会话" hint for the rest. Refreshing keeps the last values visible rather than blanking them, and a refresh failure retains the last good value. A session or day without priced usage shows a "no usage recorded" word rather than a fabricated figure. A failure — no API key configured, a rejected credential, a transport error — renders a muted "Balance unavailable" word whose tooltip carries the Remote's own error message.
7
+ The trigger shows two lines — the remaining balance and this conversation's billed spend ("本轮对话花费 ¥X") — and renders immediately from the last settled balance (kept per plugin instance), revalidating in the background; before the first balance ever lands it renders nothing. Clicking it opens a label box with the remaining amount, this session's billed spend (本会话花费, priced per usage sample at the official peak/off-peak rate — peak windows apply weekdays Mon–Fri only, weekends are off-peak; a forked session prices only its own post-fork work) with today's all-session spend beside it (今日共花费), one priced row per model with the cache-hit / cache-miss-input / output cost breakdown ("缓存命中 ¥X · 未命中输入 ¥Y · 输出 ¥Z"), a manual refresh action, and a spend disclaimer. The remaining-amount row (`API 剩余金额`) renders larger than the spend rows below it. Below the model rows the panel ends with the **today session-spend ranking** (`今日会话花费`): sessions sorted by today's cost, highest first, each row a fixed right-aligned rank column, then the same middot separator the cost breakdown uses ("·" with the same size, tone, and spacing), then the session's durable title (renames sync automatically), then its amount — the title keeps its indented rank column; at most `SESSION_RANKING_LIMIT` (10) rows render, with a "…还有 N 个会话" hint for the rest. Refreshing keeps the last values visible rather than blanking them, and a refresh failure retains the last good value. A session or day without priced usage shows a "no usage recorded" word rather than a fabricated figure. A failure — no API key configured, a rejected credential, a transport error — renders a muted "Balance unavailable" word whose tooltip carries the Remote's own error message.
8
8
 
9
- The package also contributes the **turn-cost entry** (`billing-turn-cost`, order 20) to ui-chat's `conversation.chat.assistant-actions` list slot — the same strip `ui-message-feedback` uses, so entries coexist; the entry uses flex `order: -1` to sit at the **front** of the actions row (before the copy control). For each completed turn's closing assistant message, the entry reads `billing/getTurnSpend` (memoized per session + message, since settled turns never change) and renders `本轮花费 ¥X` the "本轮花费" word in the usage-card title tone and the amount in the summary tone, **always visible** (the hover rule only targets the clock text). Turns that priced to zero (no DeepSeek usage) and failed loads render nothing, so a Remote outage never clutters the row.
9
+ The package also contributes the **turn-cost entry** (`billing-turn-cost`, order 20) to ui-chat's `conversation.chat.assistant-actions` list slot — the same strip `ui-message-feedback` uses, so entries coexist; the entry's own CSS `order: 1` sorts it after every order-0 sibling, so the amount lands at the **end** of the actions row (after the clock). The entry renders a plain static `¥X` (no icon, no label, no interaction) for each completed turn's closing assistant message, resolved from a shared per-session `messageId cost` map (`billing/getSessionTurnSpends`, one fetch per session, coalesced across concurrently mounting rows; a message id the map does not know triggers exactly one refetch for a newly completed turn). Turns that priced to zero (no DeepSeek usage) and failed loads render nothing, so a Remote outage never clutters the row.
10
10
 
11
- The badge is account-level even though the slot is session-scoped: the header utilities row is simply the one always-visible title-bar seat the shell offers, and the balance it shows does not vary by session — but the spend lines do, because they price the current session's own token usage through `billing/getSessionSpend` and every session's usage through `billing/getTodaySpend`. Copy goes through the package's own `billing` locale namespace; styling uses tokens only.
11
+ The badge is account-level even though the slot is session-scoped: the header utilities row is simply the one always-visible title-bar seat the shell offers, and the balance it shows does not vary by session — but the spend lines do, because they price the current session's own usage (the pushed projection, or `billing/getSessionSpend`) and every session's usage through `billing/getTodaySpend`. Copy goes through the package's own `billing` locale namespace; styling uses tokens only.
12
12
 
13
13
  ## Refresh mechanics
14
14
 
15
- The spends follow the conversation and the balance stays a manual snapshot:
15
+ The spends follow the conversation and the balance is cached, not polled:
16
16
 
17
- - **Spends follow settled turns, debounced** — the component subscribes to the current session's running flag through the framework `useSession` seat. When a prompt turn settles (the flag flips back to idle), it recomputes this session's spend (`billing/getSessionSpend`), today's spend (`billing/getTodaySpend`), and the ranking (`billing/getTodaySessionsSpend`), local pricing passes with no network request, so the spend lines stay live during an ongoing conversation. The recompute is debounced for two seconds, so a burst of turns (an agent continuing across turns) prices once instead of once per turn; the host-side cache (see `dsh-llm-billing`) then serves the first miss for the rest of the minute.
18
- - **Balance is manual** — the account balance is fetched on mount, on session switch, and on the explicit refresh action (`billing/getBalance`). There is no polling and no automatic refetch: the balance line only changes when one of those events happens.
19
- - **Refresh keeps the last values** — an in-flight refresh leaves the previous values on screen, and a failed refresh retains the last good value instead of blanking it. The refresh action calls `billing/getTodaySpend(true)` and `billing/getTodaySessionsSpend(true)`, which bypass the host-side cache; the turn-triggered recompute and the mount/session-switch read use the cached path.
17
+ - **Session spend is pushed, not fetched** — the host folds each session's spend into the client-visible `billingTodaySpend` projection and pushes every change; the badge subscribes with the framework `useProjection` seat, so the session line is live with zero Remote calls while the projection registry is composed. When the key is absent, the same line falls back to `billing/getSessionSpend`.
18
+ - **Today's spend follows settled turns, debounced** — the component subscribes to the current session's running flag through the framework `useSession` seat. When a prompt turn settles (the flag flips back to idle) it recomputes today's spend (`billing/getTodaySpend`); the recompute is debounced for two seconds, so a burst of turns (an agent continuing across turns) prices once instead of once per turn. The ranking (`billing/getTodaySessionsSpend`) is fetched only while the detail panel is open (and on refresh), so a badge that stays closed never pays for the all-session scan.
19
+ - **Balance is TTL-cached** — the account balance is fetched on mount, on session switch, and on the explicit refresh action. The host reuses one snapshot for up to 15 seconds (`force` on the manual refresh bypasses it) and aborts a request after 5 seconds; the browser keeps the last settled value so a session switch renders the amount immediately. There is still no polling and no automatic refetch.
20
+ - **Refresh keeps the last values** — an in-flight refresh leaves the previous values on screen, and a failed refresh retains the last good value instead of blanking it. The refresh action calls `billing/getBalance(true)`, `billing/getTodaySpend(true)`, and `billing/getTodaySessionsSpend(true)`, which bypass the host-side caches; the turn-triggered recompute and the mount/session-switch reads use the cached path.
20
21
 
21
22
  ## Model Experience
22
23
 
@@ -31,4 +32,4 @@ None; the package never assembles or sends provider requests, and its one RPC is
31
32
  - **First balance line only** — the balance reads the primary (`balance_infos[0]`) currency line; other currency lines are not shown.
32
33
  - **Ranking capped at 10** — the panel shows at most `SESSION_RANKING_LIMIT` sessions, with a "…N more sessions" hint.
33
34
  - **Turn cost needs a finalized closing message** — interrupted turns have no actions row, so no turn cost is shown for them.
34
- - **Balance does not follow automatically** — the balance is a manual snapshot (no polling): spending from another client does not move the shown value until a refresh or browser reload.
35
+ - **Balance is up to 15s stale** — the host reuses one snapshot for 15 seconds and the browser keeps the last settled value: spending from another client does not move the shown value until the TTL expires, a manual refresh, or a browser reload (there is no polling).
package/README.zh.md CHANGED
@@ -2,21 +2,22 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- Web 额度特性的归属方:向 `conversation.session.header.utilities` 贡献一个条目,自己挂载 `billing` Remote,通过 `billing/getBalance`、`billing/getSessionSpend`、`billing/getTodaySpend`、`billing/getTodaySessionsSpend` 与 `billing/getTurnSpend` 读取 DeepSeek 账户余额、本会话的计费花费、今日所有会话的计费花费、今日按会话的排行与单个回合的花费,并在右上角渲染成一个标签框。该能力的宿主侧在 [`dsh-llm-billing`](../llm-billing/README.md),它拥有 `/user/balance` 传输、峰谷计价表与 `billing` Remote 命名空间;本包挂载那个 Remote 并渲染它返回的内容。
5
+ Web 额度特性的归属方:向 `conversation.session.header.utilities` 贡献一个条目,自己挂载 `billing` Remote,并渲染 DeepSeek 账户余额、本会话的计费花费(读宿主推送的 `billingTodaySpend` 投影,投影注册表不存在时回退 `billing/getSessionSpend`)、今日所有会话的计费花费(`billing/getTodaySpend`)、今日按会话的排行(`billing/getTodaySessionsSpend`)与各回合花费(`billing/getSessionTurnSpends`),在右上角渲染成一个标签框。该能力的宿主侧在 [`dsh-llm-billing`](../llm-billing/README.md),它拥有 `/user/balance` 传输、峰谷计价表与 `billing` Remote 命名空间;本包挂载那个 Remote 并渲染它返回的内容。
6
6
 
7
- 触发器显示两行——剩余额度与本轮对话的计费花费(「本轮对话花费 ¥X」)——首个请求在途时不渲染任何东西。点击后展开一个标签框:剩余金额、本会话的计费花费(本会话花费,按官方峰/谷单价逐条消息计价——高峰窗口仅周一至周五适用,周末按低谷价——分叉会话只按分叉后的自有工作计价——右侧并列今日所有会话的合计「今日共花费」)、每个模型一行的花费及其缓存命中/未命中输入/输出分项(「缓存命中 ¥X · 未命中输入 ¥Y · 输出 ¥Z」)、手动刷新按钮与花费说明。剩余金额行(`API 剩余金额`)字号大于下方花费行。模型行下方是**今日会话花费排行**(`今日会话花费`):按今日花费从高到低排列的会话列表,每行以右对齐的名次列开头,后接与花费分项相同的小圆点分隔符(「·」,字号、色调与间距一致),再是会话的持久标题(重命名自动同步),最后是金额——标题在名次列后缩进对齐;最多渲染 `SESSION_RANKING_LIMIT`(10)行,其余以「…还有 N 个会话」提示。刷新时旧值不消失,刷新失败保留上一次有效值。没有可计价消耗的会话或日子显示「暂无消耗记录」而不是编造数字。失败——未配置 API key、凭据被拒绝、传输错误——渲染弱化的「额度不可用」,其提示携带 Remote 自己的错误信息。
7
+ 触发器显示两行——剩余额度与本轮对话的计费花费(「本轮对话花费 ¥X」)——有上次已落定的余额就立即渲染(按插件实例保留),后台校验;首次余额尚未到达时不渲染任何东西。点击后展开一个标签框:剩余金额、本会话的计费花费(本会话花费,按官方峰/谷单价逐条用量样本计价——高峰窗口仅周一至周五适用,周末按低谷价——分叉会话只按分叉后的自有工作计价——右侧并列今日所有会话的合计「今日共花费」)、每个模型一行的花费及其缓存命中/未命中输入/输出分项(「缓存命中 ¥X · 未命中输入 ¥Y · 输出 ¥Z」)、手动刷新按钮与花费说明。剩余金额行(`API 剩余金额`)字号大于下方花费行。模型行下方是**今日会话花费排行**(`今日会话花费`):按今日花费从高到低排列的会话列表,每行以右对齐的名次列开头,后接与花费分项相同的小圆点分隔符(「·」,字号、色调与间距一致),再是会话的持久标题(重命名自动同步),最后是金额——标题在名次列后缩进对齐;最多渲染 `SESSION_RANKING_LIMIT`(10)行,其余以「…还有 N 个会话」提示。刷新时旧值不消失,刷新失败保留上一次有效值。没有可计价消耗的会话或日子显示「暂无消耗记录」而不是编造数字。失败——未配置 API key、凭据被拒绝、传输错误——渲染弱化的「额度不可用」,其提示携带 Remote 自己的错误信息。
8
8
 
9
- 本包还贡献**本轮花费条目**(`billing-turn-cost`,order 20)到 ui-chat 的 `conversation.chat.assistant-actions` list slot——与 `ui-message-feedback` 同一条操作条,多条目共存;条目经 CSS `order: -1` 排到操作行**最前**(复制按钮之前)。对每条已完成回合的收尾助手消息,条目通过 `billing/getTurnSpend` 读取花费(按会话 + 消息 memo,回合落定后不再变化),渲染「本轮花费 ¥X」——「本轮花费」用卡片标题色、金额用卡片摘要色,**始终显示**(悬停规则只作用于时钟文本)。回合没有 DeepSeek 用量(花费为 0)或加载失败时不渲染,Remote 故障不会污染操作行。
9
+ 本包还贡献**本轮花费条目**(`billing-turn-cost`,order 20)到 ui-chat 的 `conversation.chat.assistant-actions` list slot——与 `ui-message-feedback` 同一条操作条,多条目共存;条目自身的 CSS `order: 1` 把它排到所有 order-0 兄弟之后,金额因此落在操作行**行尾**(时钟之后)。它为每个已完成回合的收尾助手消息渲染一个纯静态 `¥X`(无图标、无文字标签、不可点击),金额来自每会话共享的 `messageId 金额` 映射(`billing/getSessionTurnSpends`,每会话只拉一次,并发挂载的行共享同一请求;映射里没有的消息 id 只触发一次重拉,对应当前刚完成的回合)。回合没有 DeepSeek 用量(花费为 0)或加载失败时不渲染,Remote 故障不会污染操作行。
10
10
 
11
- 角标是账户级的,尽管插槽是会话作用域:顶栏工具行不过是外壳提供的那个始终可见的标题栏席位,余额并不随会话变化——但花费行会,因为它通过 `billing/getSessionSpend` 按当前会话自己的 token 用量计价、通过 `billing/getTodaySpend` 按所有会话的用量计价。文案走本包自己的 `billing` locale 命名空间;样式只用 token。
11
+ 角标是账户级的,尽管插槽是会话作用域:顶栏工具行不过是外壳提供的那个始终可见的标题栏席位,余额并不随会话变化——但花费行会,因为它按当前会话自己的用量(推送的投影,或 `billing/getSessionSpend`)计价、通过 `billing/getTodaySpend` 按所有会话的用量计价。文案走本包自己的 `billing` locale 命名空间;样式只用 token。
12
12
 
13
13
  ## 数据更新机制
14
14
 
15
- 花费跟随会话、额度保持手动快照:
15
+ 花费跟随会话、额度带缓存但不轮询:
16
16
 
17
- - **花费随回合结束自动更新(去抖)** —— 组件通过框架 `useSession` 座位订阅当前会话的「运行中」标志。每当一轮 prompt 回合结束(标志翻回空闲),就只重算**本会话的花费**(`billing/getSessionSpend`)、**今日共花费**(`billing/getTodaySpend`)与**排行**(`billing/getTodaySessionsSpend`,纯本地计价、不发网络请求),连续对话时花费行实时跟随。重算有 2 秒去抖,回合突发(agent 连续多轮)只计价一次;宿主侧缓存(见 `dsh-llm-billing`)随后在一分钟内服务首次未命中。
18
- - **额度保持手动** —— 账户余额在挂载、切换会话、点刷新按钮时拉取(`billing/getBalance`)。**没有轮询、不会自动重拉**:余额行只在这几个时机变化。
19
- - **刷新保留旧值** —— 刷新在途时旧值留在屏幕上,刷新失败保留上一次有效值,不清空。刷新按钮调用 `billing/getTodaySpend(true)` `billing/getTodaySessionsSpend(true)`,绕过宿主侧缓存;回合结束触发的重算与挂载/切换会话的读取走缓存路径。
17
+ - **本会话花费靠推送,不靠拉取** —— 宿主把每个会话的花费折进客户端可见的 `billingTodaySpend` 投影并推送每次变化;徽标用框架 `useProjection` 座位订阅,因此投影注册表装配时该行零 Remote 调用、实时更新。键不存在时同一行回退到 `billing/getSessionSpend`。
18
+ - **今日共花费随回合结束更新(去抖)** —— 组件通过框架 `useSession` 座位订阅当前会话的「运行中」标志。每当一轮 prompt 回合结束(标志翻回空闲)就重算**今日共花费**(`billing/getTodaySpend`);重算有 2 秒去抖,回合突发(agent 连续多轮)只计价一次。**排行**(`billing/getTodaySessionsSpend`)只在详情面板打开时(以及手动刷新时)拉取,徽标一直关着就不会为全量会话扫描买单。
19
+ - **额度带 TTL 缓存** —— 账户余额在挂载、切换会话、点刷新按钮时拉取。宿主 15 秒内复用同一份快照(手动刷新带 `force` 绕过),单次请求 5 秒超时;浏览器保留最后一次已落定值,切会话时立即渲染。仍然**没有轮询、不会自动重拉**。
20
+ - **刷新保留旧值** —— 刷新在途时旧值留在屏幕上,刷新失败保留上一次有效值,不清空。刷新按钮调用 `billing/getBalance(true)`、`billing/getTodaySpend(true)` 与 `billing/getTodaySessionsSpend(true)`,绕过宿主侧缓存;回合结束触发的重算与挂载/切换会话的读取走缓存路径。
20
21
 
21
22
  ## 模型体验
22
23
 
@@ -31,4 +32,4 @@ Web 额度特性的归属方:向 `conversation.session.header.utilities` 贡
31
32
  - **只取首条余额线** —— 余额读取主要(`balance_infos[0]`)币种行;其它币种行不显示。
32
33
  - **排行上限 10** —— 面板最多显示 `SESSION_RANKING_LIMIT` 个会话,其余以「…还有 N 个会话」提示。
33
34
  - **本轮花费需要已定稿的收尾消息** —— 中断的回合没有操作行,不显示本轮花费。
34
- - **额度不自动跟随** —— 余额保持手动快照(无轮询):其他客户端产生的消耗不会让界面数值自动变化,需手动刷新或刷新浏览器。
35
+ - **额度最多旧 15 秒** —— 宿主 15 秒内复用同一份快照、浏览器保留最后一次已落定值:其他客户端产生的消耗不会让界面数值自动变化,需等 TTL 过期、手动刷新或刷新浏览器(仍无轮询)。
package/lib/client.js CHANGED
@@ -5,8 +5,8 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
- let react = require("react");
9
8
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ let react = require("react");
10
10
  //#region ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/core.js
11
11
  var _a$1;
12
12
  function $constructor(name, initializer, params) {
@@ -4094,6 +4094,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4094
4094
  }
4095
4095
  //#endregion
4096
4096
  //#region ../llm-billing/lib/typert.remote-client.js
4097
+ const _rayadesu_dsh_llm_billing_billing_getBalance_parameter_0$schema = union([
4098
+ _undefined(),
4099
+ literal(false),
4100
+ literal(true)
4101
+ ]);
4097
4102
  const _rayadesu_dsh_llm_billing_billing_getBalance_result$schema = object({
4098
4103
  "isAvailable": boolean(),
4099
4104
  "lines": array(object({
@@ -4120,6 +4125,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4120
4125
  "outputCost": number()
4121
4126
  }))
4122
4127
  });
4128
+ const _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema = intersection(string(), unknown());
4129
+ const _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_result$schema = object({ "turns": array(object({
4130
+ "messageId": string(),
4131
+ "total": number()
4132
+ })) });
4123
4133
  const _rayadesu_dsh_llm_billing_billing_getTodaySessionsSpend_parameter_0$schema = union([
4124
4134
  _undefined(),
4125
4135
  literal(false),
@@ -4163,7 +4173,17 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4163
4173
  namespace: "billing",
4164
4174
  method: "getBalance",
4165
4175
  invocation: { kind: "direct" },
4166
- parameters: [],
4176
+ parameters: [{
4177
+ name: "force",
4178
+ wire: "force",
4179
+ source: "json",
4180
+ acceptsUndefined: true,
4181
+ codec: {
4182
+ mode: "strict",
4183
+ typeSymbol: "@rayadesu/dsh-llm-billing#billing/getBalance:force",
4184
+ schema: _rayadesu_dsh_llm_billing_billing_getBalance_parameter_0$schema
4185
+ }
4186
+ }],
4167
4187
  result: {
4168
4188
  mode: "strict",
4169
4189
  typeSymbol: "@rayadesu/dsh-llm-billing/types#DeepSeekBalance",
@@ -4171,7 +4191,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4171
4191
  },
4172
4192
  sourceLocation: {
4173
4193
  "file": "packages/llm-billing/src/balance.ts",
4174
- "line": 154,
4194
+ "line": 165,
4175
4195
  "column": 3
4176
4196
  }
4177
4197
  },
@@ -4198,7 +4218,34 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4198
4218
  },
4199
4219
  sourceLocation: {
4200
4220
  "file": "packages/llm-billing/src/balance.ts",
4201
- "line": 165,
4221
+ "line": 176,
4222
+ "column": 3
4223
+ }
4224
+ },
4225
+ {
4226
+ id: "@rayadesu/dsh-llm-billing#billing/getSessionTurnSpends",
4227
+ service: "billing",
4228
+ namespace: "billing",
4229
+ method: "getSessionTurnSpends",
4230
+ invocation: { kind: "direct" },
4231
+ parameters: [{
4232
+ name: "sessionId",
4233
+ wire: "sessionId",
4234
+ source: "json",
4235
+ codec: {
4236
+ mode: "strict",
4237
+ typeSymbol: "@rayadesu/dsh-llm-billing#billing/getSessionTurnSpends:sessionId",
4238
+ schema: _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema
4239
+ }
4240
+ }],
4241
+ result: {
4242
+ mode: "strict",
4243
+ typeSymbol: "@rayadesu/dsh-llm-billing/types#DeepSeekSessionTurnSpends",
4244
+ schema: _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_result$schema
4245
+ },
4246
+ sourceLocation: {
4247
+ "file": "packages/llm-billing/src/balance.ts",
4248
+ "line": 227,
4202
4249
  "column": 3
4203
4250
  }
4204
4251
  },
@@ -4226,7 +4273,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4226
4273
  },
4227
4274
  sourceLocation: {
4228
4275
  "file": "packages/llm-billing/src/balance.ts",
4229
- "line": 192,
4276
+ "line": 202,
4230
4277
  "column": 3
4231
4278
  }
4232
4279
  },
@@ -4254,7 +4301,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4254
4301
  },
4255
4302
  sourceLocation: {
4256
4303
  "file": "packages/llm-billing/src/balance.ts",
4257
- "line": 178,
4304
+ "line": 189,
4258
4305
  "column": 3
4259
4306
  }
4260
4307
  },
@@ -4290,13 +4337,34 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4290
4337
  },
4291
4338
  sourceLocation: {
4292
4339
  "file": "packages/llm-billing/src/balance.ts",
4293
- "line": 205,
4340
+ "line": 215,
4294
4341
  "column": 3
4295
4342
  }
4296
4343
  }
4297
4344
  ]
4298
4345
  };
4299
4346
  //#endregion
4347
+ //#region lib/types/client/format.js
4348
+ /** Currency prefix for one balance line; unknown codes render as a literal prefix. */
4349
+ function currencySymbol(currency) {
4350
+ if (currency === "CNY") return "¥";
4351
+ if (currency === "USD") return "$";
4352
+ return `${currency} `;
4353
+ }
4354
+ /** The primary balance line, or undefined when the provider reports none. */
4355
+ function primaryLine(balance) {
4356
+ const line = balance.lines[0];
4357
+ if (line === void 0) return void 0;
4358
+ return {
4359
+ symbol: currencySymbol(line.currency),
4360
+ total: line.total
4361
+ };
4362
+ }
4363
+ /** CNY amount, up to four decimals with trailing zeros trimmed. */
4364
+ function formatSpend(amount) {
4365
+ return `¥${amount.toFixed(4).replace(/\.?0+$/, "")}`;
4366
+ }
4367
+ //#endregion
4300
4368
  //#region \0dsh-css:C:\Users\admin\Desktop\me\code\DeepSeek-Harness-chat-billing\packages\ui-billing\src\client\BalanceBadge.module.css.mjs
4301
4369
  const css$1 = "._4_Wqcq_root{position:relative}._4_Wqcq_trigger{background:var(--dsw-alias-fill-l2);min-height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;border:0;border-radius:8px;align-items:center;gap:4px;padding:4px 8px;display:inline-flex}._4_Wqcq_trigger:hover,._4_Wqcq_trigger:focus-visible{color:var(--dsw-alias-label-primary)}._4_Wqcq_trigger svg{transition:transform .12s}._4_Wqcq_chevronOpen{transform:rotate(180deg)}._4_Wqcq_triggerLines{text-align:left;flex-direction:column;align-items:flex-start;gap:0;display:flex}._4_Wqcq_linePrimary{font-variant-numeric:tabular-nums;font-size:12px;line-height:16px}._4_Wqcq_lineSecondary{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:11px;line-height:15px}._4_Wqcq_unavailable{color:var(--dsw-alias-label-tertiary)}._4_Wqcq_inlineIcon{color:var(--dsw-alias-label-tertiary);flex:none}._4_Wqcq_spinning{color:var(--dsw-alias-label-tertiary);flex:none;animation:.8s linear infinite _4_Wqcq_spin}@keyframes _4_Wqcq_spin{to{transform:rotate(360deg)}}._4_Wqcq_panel{z-index:100;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-specific-menu);width:336px;max-width:min(360px,100vw - 32px);box-shadow:var(--dsw-shadow-lv3);border-radius:12px;flex-direction:column;gap:2px;padding:6px;display:flex;position:absolute;top:calc(100% + 6px);right:0}._4_Wqcq_amountRow{border-radius:8px;justify-content:space-between;align-items:center;gap:8px;padding:6px 8px;display:flex}._4_Wqcq_amountLabel{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;font-size:13px;line-height:18px}._4_Wqcq_amountRow>._4_Wqcq_amountLabel{font-size:15px;line-height:22px}._4_Wqcq_spendRow{border-top:1px solid var(--dsw-alias-border-l2);border-radius:0;justify-content:space-between;align-items:center;gap:8px;padding:6px 8px;display:flex}._4_Wqcq_costRow{align-items:center;padding:2px 8px 6px;display:flex}._4_Wqcq_costBreakdown{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:11px;line-height:16px}._4_Wqcq_amountActions{align-items:center;gap:2px;display:inline-flex}._4_Wqcq_infoButton,._4_Wqcq_refreshButton{width:24px;height:24px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:6px;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}._4_Wqcq_infoButton:hover,._4_Wqcq_infoButton:focus-visible,._4_Wqcq_refreshButton:hover,._4_Wqcq_refreshButton:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2)}._4_Wqcq_modelRow{border-radius:8px;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 8px;display:flex}._4_Wqcq_modelName{color:var(--dsw-alias-label-secondary);flex:none;font-size:12px;line-height:18px}._4_Wqcq_tasks{text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;overflow-wrap:anywhere;font-size:12px;line-height:18px}._4_Wqcq_ranking{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:0;padding-top:2px;display:flex}._4_Wqcq_rankingTitle{color:var(--dsw-alias-label-secondary);padding:6px 8px 2px;font-size:12px;line-height:18px}._4_Wqcq_rankingRow{border-radius:8px;align-items:baseline;gap:0;min-width:0;padding:3px 8px;display:flex}._4_Wqcq_rankingRow:hover{background:var(--dsw-alias-fill-l2)}._4_Wqcq_rankingIndex{width:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;text-align:right;flex:none;font-size:11px;line-height:16px}._4_Wqcq_rankingDot{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:pre;flex:none;font-size:11px;line-height:16px}._4_Wqcq_rankingName{min-width:0;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;line-height:16px;overflow:hidden}._4_Wqcq_rankingAmount{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;flex:none;margin-left:8px;font-size:11px;line-height:16px}._4_Wqcq_rankingMore{color:var(--dsw-alias-label-tertiary);padding:2px 8px 4px;font-size:11px;line-height:16px}";
4302
4370
  const tagId$1 = "@rayadesu/dsh-client-ui-billing/BalanceBadge.module.css";
@@ -4339,28 +4407,147 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4339
4407
  "triggerLines": "_4_Wqcq_triggerLines",
4340
4408
  "unavailable": "_4_Wqcq_unavailable"
4341
4409
  };
4342
- //#endregion
4343
- //#region lib/types/client/BalanceBadge.js
4344
- /** Session-header billing badge: balance plus the current conversation's billed spend. */
4345
- /** Currency prefix for one balance line; unknown codes render as a literal prefix. */
4346
- function currencySymbol(currency) {
4347
- if (currency === "CNY") return "¥";
4348
- if (currency === "USD") return "$";
4349
- return `${currency} `;
4350
- }
4351
- /** The primary balance line, or undefined when the provider reports none. */
4352
- function primaryLine(balance) {
4353
- const line = balance.lines[0];
4354
- if (line === void 0) return void 0;
4355
- return {
4356
- symbol: currencySymbol(line.currency),
4357
- total: line.total
4358
- };
4410
+ /** The detail box opened from the badge trigger. */
4411
+ function BalancePanel({ amount, spend, todaySpend, sessionsSpend, refreshing, onRefresh, t }) {
4412
+ return (0, react_jsx_runtime.jsxs)("div", {
4413
+ className: BalanceBadge_module_css_default.panel,
4414
+ role: "dialog",
4415
+ "aria-label": t("panel.aria"),
4416
+ children: [
4417
+ (0, react_jsx_runtime.jsxs)("div", {
4418
+ className: BalanceBadge_module_css_default.amountRow,
4419
+ children: [(0, react_jsx_runtime.jsx)("span", {
4420
+ className: BalanceBadge_module_css_default.amountLabel,
4421
+ children: t("label.amount", { amount })
4422
+ }), (0, react_jsx_runtime.jsxs)("span", {
4423
+ className: BalanceBadge_module_css_default.amountActions,
4424
+ children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
4425
+ label: t("info.hint"),
4426
+ delayMs: 200,
4427
+ maxWidth: 340,
4428
+ children: (0, react_jsx_runtime.jsx)("button", {
4429
+ type: "button",
4430
+ className: BalanceBadge_module_css_default.infoButton,
4431
+ "aria-label": t("info.aria"),
4432
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconQuestionOutline14, { className: BalanceBadge_module_css_default.inlineIcon })
4433
+ })
4434
+ }), (0, react_jsx_runtime.jsx)("button", {
4435
+ type: "button",
4436
+ className: BalanceBadge_module_css_default.refreshButton,
4437
+ onClick: onRefresh,
4438
+ "aria-label": t("action.refresh"),
4439
+ "data-refreshing": refreshing || void 0,
4440
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, { className: refreshing ? BalanceBadge_module_css_default.spinning : BalanceBadge_module_css_default.inlineIcon })
4441
+ })]
4442
+ })]
4443
+ }),
4444
+ (0, react_jsx_runtime.jsxs)("div", {
4445
+ className: BalanceBadge_module_css_default.spendRow,
4446
+ children: [(0, react_jsx_runtime.jsx)("span", {
4447
+ className: BalanceBadge_module_css_default.amountLabel,
4448
+ children: t("label.sessionSpend", { amount: spend === null ? "—" : spend.models.length === 0 ? t("stat.none") : formatSpend(spend.total) })
4449
+ }), (0, react_jsx_runtime.jsx)("span", {
4450
+ className: BalanceBadge_module_css_default.amountLabel,
4451
+ children: t("label.todaySpend", { amount: todaySpend === null ? "—" : todaySpend.models.length === 0 ? t("stat.none") : formatSpend(todaySpend.total) })
4452
+ })]
4453
+ }),
4454
+ spend?.models.map((model) => (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [(0, react_jsx_runtime.jsxs)("div", {
4455
+ className: BalanceBadge_module_css_default.modelRow,
4456
+ children: [(0, react_jsx_runtime.jsx)("span", {
4457
+ className: BalanceBadge_module_css_default.modelName,
4458
+ children: model.displayName
4459
+ }), (0, react_jsx_runtime.jsx)("span", {
4460
+ className: BalanceBadge_module_css_default.tasks,
4461
+ children: formatSpend(model.cost)
4462
+ })]
4463
+ }), (0, react_jsx_runtime.jsx)("div", {
4464
+ className: BalanceBadge_module_css_default.costRow,
4465
+ children: (0, react_jsx_runtime.jsxs)("span", {
4466
+ className: BalanceBadge_module_css_default.costBreakdown,
4467
+ children: [
4468
+ t("label.cost.hit", { amount: formatSpend(model.cacheHitInputCost) }),
4469
+ " · ",
4470
+ t("label.cost.input", { amount: formatSpend(model.cacheMissInputCost) }),
4471
+ " · ",
4472
+ t("label.cost.output", { amount: formatSpend(model.outputCost) })
4473
+ ]
4474
+ })
4475
+ })] }, model.model)),
4476
+ sessionsSpend !== null && sessionsSpend.sessions.length > 0 && (0, react_jsx_runtime.jsxs)("div", {
4477
+ className: BalanceBadge_module_css_default.ranking,
4478
+ children: [
4479
+ (0, react_jsx_runtime.jsx)("div", {
4480
+ className: BalanceBadge_module_css_default.rankingTitle,
4481
+ children: t("label.sessionRanking")
4482
+ }),
4483
+ sessionsSpend.sessions.slice(0, 10).map((row, index) => (0, react_jsx_runtime.jsxs)("div", {
4484
+ className: BalanceBadge_module_css_default.rankingRow,
4485
+ children: [
4486
+ (0, react_jsx_runtime.jsx)("span", {
4487
+ className: BalanceBadge_module_css_default.rankingIndex,
4488
+ children: index + 1
4489
+ }),
4490
+ (0, react_jsx_runtime.jsx)("span", {
4491
+ className: BalanceBadge_module_css_default.rankingDot,
4492
+ "aria-hidden": true,
4493
+ children: " · "
4494
+ }),
4495
+ (0, react_jsx_runtime.jsx)("span", {
4496
+ className: BalanceBadge_module_css_default.rankingName,
4497
+ title: row.title ?? void 0,
4498
+ children: row.title ?? t("stat.untitled")
4499
+ }),
4500
+ (0, react_jsx_runtime.jsx)("span", {
4501
+ className: BalanceBadge_module_css_default.rankingAmount,
4502
+ children: formatSpend(row.total)
4503
+ })
4504
+ ]
4505
+ }, row.sessionId)),
4506
+ sessionsSpend.sessions.length > 10 && (0, react_jsx_runtime.jsx)("div", {
4507
+ className: BalanceBadge_module_css_default.rankingMore,
4508
+ children: t("label.sessionRanking.more", { count: sessionsSpend.sessions.length - 10 })
4509
+ })
4510
+ ]
4511
+ })
4512
+ ]
4513
+ });
4359
4514
  }
4360
- /** CNY amount, up to four decimals with trailing zeros trimmed. */
4361
- function formatSpend$1(amount) {
4362
- return `¥${amount.toFixed(4).replace(/\.?0+$/, "")}`;
4515
+ //#endregion
4516
+ //#region lib/types/client/BalanceTrigger.js
4517
+ /**
4518
+ * Session-header billing trigger: the balance plus the conversation-spend
4519
+ * line. Pure view over the data hook's values — no state, no effects.
4520
+ */
4521
+ /** The badge button: balance line, spend line, and the open-state chevron. */
4522
+ function BalanceTrigger({ amount, spendLine, open, onToggle, t }) {
4523
+ return (0, react_jsx_runtime.jsxs)("button", {
4524
+ type: "button",
4525
+ className: BalanceBadge_module_css_default.trigger,
4526
+ "aria-expanded": open,
4527
+ "aria-label": t("badge.aria", { amount }),
4528
+ onClick: onToggle,
4529
+ children: [(0, react_jsx_runtime.jsxs)("span", {
4530
+ className: BalanceBadge_module_css_default.triggerLines,
4531
+ children: [(0, react_jsx_runtime.jsx)("span", {
4532
+ className: BalanceBadge_module_css_default.linePrimary,
4533
+ children: t("trigger.balance", { amount })
4534
+ }), spendLine !== void 0 && (0, react_jsx_runtime.jsx)("span", {
4535
+ className: BalanceBadge_module_css_default.lineSecondary,
4536
+ children: spendLine
4537
+ })]
4538
+ }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: open ? BalanceBadge_module_css_default.chevronOpen : void 0 })]
4539
+ });
4363
4540
  }
4541
+ //#endregion
4542
+ //#region lib/types/client/useBillingData.js
4543
+ /**
4544
+ * Billing badge data hook: owns the balance/session/today states, the
4545
+ * mount+refresh fetch, the turn-settled recompute, and the click-outside
4546
+ * close. The trigger and the panel are pure views over the returned values,
4547
+ * so the concurrency/race handling lives in exactly one file.
4548
+ */
4549
+ /** Debounce for the turn-settled recompute: a burst of turns prices once. */
4550
+ const TURN_SETTLE_DEBOUNCE_MS = 2e3;
4364
4551
  /**
4365
4552
  * Run one fetch line: the fetch is deferred to a microtask so the effect's
4366
4553
  * render commits before any state update lands; a settled value is stored
@@ -4376,23 +4563,16 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4376
4563
  });
4377
4564
  }
4378
4565
  /**
4379
- * Render the billing badge in the session-header utilities row. The trigger
4380
- * shows the remaining balance and this conversation's billed spend and opens
4381
- * a label box with the amount, this session's spend with its cache-hit /
4382
- * cache-miss-input / output cost breakdown per model, today's spend across
4383
- * every session, a refresh action, and a spend disclaimer. Refreshing keeps
4384
- * the last values visible rather than blanking them.
4385
- *
4386
- * The spend follows the conversation: when a prompt turn settles in the
4387
- * current session, the badge recomputes only the (local, network-free)
4388
- * session spend and today's spend through `getSessionSpend` /
4389
- * `getTodaySpend`; the balance stays a manual-refresh snapshot and is never
4390
- * refetched on its own.
4391
- * @param props - Remote face, locale, and the standard session-header runtime share.
4392
- * @returns the badge, or null until the first balance fetch settles.
4566
+ * Start the badge's data lifecycle for one session. The spend follows the
4567
+ * conversation: the host-pushed `billingTodaySpend` projection drives the
4568
+ * session line live (zero Remote calls), with `getSessionSpend` as the
4569
+ * bootstrap/fallback when the projection key is absent; today's spend is
4570
+ * recomputed through `getTodaySpend` when a turn settles; the balance stays a
4571
+ * manual-refresh snapshot and is never refetched on its own.
4572
+ * @param props - the badge's injected face and session runtime share.
4393
4573
  */
4394
- function BalanceBadge({ getBalance, getSessionSpend, getTodaySpend, getTodaySessionsSpend, sessionId, useSession, t }) {
4395
- const [balance, setBalance] = (0, react.useState)(null);
4574
+ function useBillingData({ getBalance, getCachedBalance, getSessionSpend, getTodaySpend, getTodaySessionsSpend, sessionId, useSession, useProjection }) {
4575
+ const [balance, setBalance] = (0, react.useState)(getCachedBalance);
4396
4576
  const [spend, setSpend] = (0, react.useState)(null);
4397
4577
  const [todaySpend, setTodaySpend] = (0, react.useState)(null);
4398
4578
  const [sessionsSpend, setSessionsSpend] = (0, react.useState)(null);
@@ -4401,22 +4581,27 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4401
4581
  const [open, setOpen] = (0, react.useState)(false);
4402
4582
  const [request, setRequest] = (0, react.useState)(0);
4403
4583
  const rootRef = (0, react.useRef)(null);
4584
+ const projected = useProjection("billingTodaySpend");
4404
4585
  const running = useSession((snapshot) => snapshot.running);
4405
4586
  const pricedRunningRef = (0, react.useRef)(running);
4587
+ const balanceRef = (0, react.useRef)(balance);
4588
+ balanceRef.current = balance;
4589
+ const openRef = (0, react.useRef)(open);
4590
+ openRef.current = open;
4406
4591
  (0, react.useEffect)(() => {
4407
4592
  let current = true;
4408
4593
  const isCurrent = () => current;
4409
- setRefreshing(balance !== null);
4594
+ setRefreshing(balanceRef.current !== null);
4410
4595
  Promise.resolve().then(() => {
4411
- const balanceRequest = fetchLine(isCurrent, getBalance, (value) => {
4596
+ const balanceRequest = fetchLine(isCurrent, () => getBalance(request > 0), (value) => {
4412
4597
  setBalance(value);
4413
4598
  setError(null);
4414
4599
  }, (reason) => {
4415
- if (balance === null) setError(reason instanceof Error ? reason.message : String(reason));
4600
+ if (balanceRef.current === null) setError(reason instanceof Error ? reason.message : String(reason));
4416
4601
  });
4417
4602
  const sessionSpendRequest = fetchLine(isCurrent, () => getSessionSpend(sessionId), setSpend);
4418
4603
  const todaySpendRequest = fetchLine(isCurrent, () => getTodaySpend(request > 0), setTodaySpend);
4419
- const sessionsSpendRequest = fetchLine(isCurrent, () => getTodaySessionsSpend(request > 0), setSessionsSpend);
4604
+ const sessionsSpendRequest = openRef.current ? fetchLine(isCurrent, () => getTodaySessionsSpend(request > 0), setSessionsSpend) : Promise.resolve();
4420
4605
  Promise.allSettled([
4421
4606
  balanceRequest,
4422
4607
  sessionSpendRequest,
@@ -4437,6 +4622,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4437
4622
  sessionId,
4438
4623
  request
4439
4624
  ]);
4625
+ (0, react.useEffect)(() => {
4626
+ if (!open) return;
4627
+ let current = true;
4628
+ fetchLine(() => current, () => getTodaySessionsSpend(), setSessionsSpend);
4629
+ return () => {
4630
+ current = false;
4631
+ };
4632
+ }, [getTodaySessionsSpend, open]);
4440
4633
  (0, react.useEffect)(() => {
4441
4634
  if (running === pricedRunningRef.current) return;
4442
4635
  pricedRunningRef.current = running;
@@ -4446,8 +4639,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4446
4639
  const timer = setTimeout(() => {
4447
4640
  fetchLine(isCurrent, () => getSessionSpend(sessionId), setSpend);
4448
4641
  fetchLine(isCurrent, () => getTodaySpend(), setTodaySpend);
4449
- fetchLine(isCurrent, () => getTodaySessionsSpend(), setSessionsSpend);
4450
- }, 2e3);
4642
+ if (openRef.current) fetchLine(isCurrent, () => getTodaySessionsSpend(), setSessionsSpend);
4643
+ }, TURN_SETTLE_DEBOUNCE_MS);
4451
4644
  return () => {
4452
4645
  clearTimeout(timer);
4453
4646
  current = false;
@@ -4469,6 +4662,44 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4469
4662
  document.removeEventListener("pointerdown", closeOutside);
4470
4663
  };
4471
4664
  }, [open]);
4665
+ return {
4666
+ balance,
4667
+ spend: projected === void 0 ? spend : projected.session,
4668
+ todaySpend,
4669
+ sessionsSpend,
4670
+ error,
4671
+ refreshing,
4672
+ open,
4673
+ rootRef,
4674
+ refresh: () => {
4675
+ setRequest((value) => value + 1);
4676
+ },
4677
+ toggleOpen: () => {
4678
+ setOpen((value) => !value);
4679
+ }
4680
+ };
4681
+ }
4682
+ //#endregion
4683
+ //#region lib/types/client/BalanceBadge.js
4684
+ /**
4685
+ * Render the billing badge in the session-header utilities row: the trigger
4686
+ * shows the remaining balance and this conversation's billed spend, and opens
4687
+ * the detail panel (amount, per-model spend breakdown, today's spend, ranking,
4688
+ * refresh, disclaimer).
4689
+ * @param props - Remote face, locale, and the standard session-header runtime share.
4690
+ * @returns the badge, or null until the first balance fetch settles.
4691
+ */
4692
+ function BalanceBadge({ getBalance, getCachedBalance, getSessionSpend, getTodaySpend, getTodaySessionsSpend, sessionId, useSession, useProjection, t }) {
4693
+ const { balance, spend, todaySpend, sessionsSpend, error, refreshing, open, rootRef, refresh, toggleOpen } = useBillingData({
4694
+ getBalance,
4695
+ getCachedBalance,
4696
+ getSessionSpend,
4697
+ getTodaySpend,
4698
+ getTodaySessionsSpend,
4699
+ sessionId,
4700
+ useSession,
4701
+ useProjection
4702
+ });
4472
4703
  if (balance === null) {
4473
4704
  if (error === null) return null;
4474
4705
  return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
@@ -4477,9 +4708,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4477
4708
  children: (0, react_jsx_runtime.jsxs)("button", {
4478
4709
  type: "button",
4479
4710
  className: BalanceBadge_module_css_default.trigger,
4480
- onClick: () => {
4481
- setRequest((value) => value + 1);
4482
- },
4711
+ onClick: refresh,
4483
4712
  "aria-label": t("action.refresh"),
4484
4713
  children: [(0, react_jsx_runtime.jsx)("span", {
4485
4714
  className: BalanceBadge_module_css_default.unavailable,
@@ -4488,134 +4717,26 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4488
4717
  })
4489
4718
  });
4490
4719
  }
4491
- const refresh = () => {
4492
- setRequest((value) => value + 1);
4493
- };
4494
4720
  const line = primaryLine(balance);
4495
4721
  const amount = line === void 0 ? "—" : `${line.symbol}${line.total}`;
4496
- const spendLine = spend !== null && spend.models.length > 0 ? t("trigger.conversationSpend", { amount: formatSpend$1(spend.total) }) : void 0;
4722
+ const spendLine = spend !== null && spend.models.length > 0 ? t("trigger.conversationSpend", { amount: formatSpend(spend.total) }) : void 0;
4497
4723
  return (0, react_jsx_runtime.jsxs)("div", {
4498
4724
  ref: rootRef,
4499
4725
  className: BalanceBadge_module_css_default.root,
4500
- children: [(0, react_jsx_runtime.jsxs)("button", {
4501
- type: "button",
4502
- className: BalanceBadge_module_css_default.trigger,
4503
- "aria-expanded": open,
4504
- "aria-label": t("badge.aria", { amount }),
4505
- onClick: () => {
4506
- setOpen((value) => !value);
4507
- },
4508
- children: [(0, react_jsx_runtime.jsxs)("span", {
4509
- className: BalanceBadge_module_css_default.triggerLines,
4510
- children: [(0, react_jsx_runtime.jsx)("span", {
4511
- className: BalanceBadge_module_css_default.linePrimary,
4512
- children: t("trigger.balance", { amount })
4513
- }), spendLine !== void 0 && (0, react_jsx_runtime.jsx)("span", {
4514
- className: BalanceBadge_module_css_default.lineSecondary,
4515
- children: spendLine
4516
- })]
4517
- }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: open ? BalanceBadge_module_css_default.chevronOpen : void 0 })]
4518
- }), open ? (0, react_jsx_runtime.jsxs)("div", {
4519
- className: BalanceBadge_module_css_default.panel,
4520
- role: "dialog",
4521
- "aria-label": t("panel.aria"),
4522
- children: [
4523
- (0, react_jsx_runtime.jsxs)("div", {
4524
- className: BalanceBadge_module_css_default.amountRow,
4525
- children: [(0, react_jsx_runtime.jsx)("span", {
4526
- className: BalanceBadge_module_css_default.amountLabel,
4527
- children: t("label.amount", { amount })
4528
- }), (0, react_jsx_runtime.jsxs)("span", {
4529
- className: BalanceBadge_module_css_default.amountActions,
4530
- children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
4531
- label: t("info.hint"),
4532
- delayMs: 200,
4533
- maxWidth: 340,
4534
- children: (0, react_jsx_runtime.jsx)("button", {
4535
- type: "button",
4536
- className: BalanceBadge_module_css_default.infoButton,
4537
- "aria-label": t("info.aria"),
4538
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconQuestionOutline14, { className: BalanceBadge_module_css_default.inlineIcon })
4539
- })
4540
- }), (0, react_jsx_runtime.jsx)("button", {
4541
- type: "button",
4542
- className: BalanceBadge_module_css_default.refreshButton,
4543
- onClick: refresh,
4544
- "aria-label": t("action.refresh"),
4545
- "data-refreshing": refreshing || void 0,
4546
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14, { className: refreshing ? BalanceBadge_module_css_default.spinning : BalanceBadge_module_css_default.inlineIcon })
4547
- })]
4548
- })]
4549
- }),
4550
- (0, react_jsx_runtime.jsxs)("div", {
4551
- className: BalanceBadge_module_css_default.spendRow,
4552
- children: [(0, react_jsx_runtime.jsx)("span", {
4553
- className: BalanceBadge_module_css_default.amountLabel,
4554
- children: t("label.sessionSpend", { amount: spend === null ? "—" : spend.models.length === 0 ? t("stat.none") : formatSpend$1(spend.total) })
4555
- }), (0, react_jsx_runtime.jsx)("span", {
4556
- className: BalanceBadge_module_css_default.amountLabel,
4557
- children: t("label.todaySpend", { amount: todaySpend === null ? "—" : todaySpend.models.length === 0 ? t("stat.none") : formatSpend$1(todaySpend.total) })
4558
- })]
4559
- }),
4560
- spend?.models.map((model) => (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [(0, react_jsx_runtime.jsxs)("div", {
4561
- className: BalanceBadge_module_css_default.modelRow,
4562
- children: [(0, react_jsx_runtime.jsx)("span", {
4563
- className: BalanceBadge_module_css_default.modelName,
4564
- children: model.displayName
4565
- }), (0, react_jsx_runtime.jsx)("span", {
4566
- className: BalanceBadge_module_css_default.tasks,
4567
- children: formatSpend$1(model.cost)
4568
- })]
4569
- }), (0, react_jsx_runtime.jsx)("div", {
4570
- className: BalanceBadge_module_css_default.costRow,
4571
- children: (0, react_jsx_runtime.jsxs)("span", {
4572
- className: BalanceBadge_module_css_default.costBreakdown,
4573
- children: [
4574
- t("label.cost.hit", { amount: formatSpend$1(model.cacheHitInputCost) }),
4575
- " · ",
4576
- t("label.cost.input", { amount: formatSpend$1(model.cacheMissInputCost) }),
4577
- " · ",
4578
- t("label.cost.output", { amount: formatSpend$1(model.outputCost) })
4579
- ]
4580
- })
4581
- })] }, model.model)),
4582
- sessionsSpend !== null && sessionsSpend.sessions.length > 0 && (0, react_jsx_runtime.jsxs)("div", {
4583
- className: BalanceBadge_module_css_default.ranking,
4584
- children: [
4585
- (0, react_jsx_runtime.jsx)("div", {
4586
- className: BalanceBadge_module_css_default.rankingTitle,
4587
- children: t("label.sessionRanking")
4588
- }),
4589
- sessionsSpend.sessions.slice(0, 10).map((row, index) => (0, react_jsx_runtime.jsxs)("div", {
4590
- className: BalanceBadge_module_css_default.rankingRow,
4591
- children: [
4592
- (0, react_jsx_runtime.jsx)("span", {
4593
- className: BalanceBadge_module_css_default.rankingIndex,
4594
- children: index + 1
4595
- }),
4596
- (0, react_jsx_runtime.jsx)("span", {
4597
- className: BalanceBadge_module_css_default.rankingDot,
4598
- "aria-hidden": true,
4599
- children: " · "
4600
- }),
4601
- (0, react_jsx_runtime.jsx)("span", {
4602
- className: BalanceBadge_module_css_default.rankingName,
4603
- title: row.title ?? void 0,
4604
- children: row.title ?? t("stat.untitled")
4605
- }),
4606
- (0, react_jsx_runtime.jsx)("span", {
4607
- className: BalanceBadge_module_css_default.rankingAmount,
4608
- children: formatSpend$1(row.total)
4609
- })
4610
- ]
4611
- }, row.sessionId)),
4612
- sessionsSpend.sessions.length > 10 && (0, react_jsx_runtime.jsx)("div", {
4613
- className: BalanceBadge_module_css_default.rankingMore,
4614
- children: t("label.sessionRanking.more", { count: sessionsSpend.sessions.length - 10 })
4615
- })
4616
- ]
4617
- })
4618
- ]
4726
+ children: [(0, react_jsx_runtime.jsx)(BalanceTrigger, {
4727
+ amount,
4728
+ spendLine,
4729
+ open,
4730
+ onToggle: toggleOpen,
4731
+ t
4732
+ }), open ? (0, react_jsx_runtime.jsx)(BalancePanel, {
4733
+ amount,
4734
+ spend,
4735
+ todaySpend,
4736
+ sessionsSpend,
4737
+ refreshing,
4738
+ onRefresh: refresh,
4739
+ t
4619
4740
  }) : null]
4620
4741
  });
4621
4742
  }
@@ -4634,21 +4755,6 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4634
4755
  //#endregion
4635
4756
  //#region lib/types/client/TurnCostAction.js
4636
4757
  /** Per-turn billed-cost label in the closing assistant message's actions row. */
4637
- /** CNY amount, up to four decimals with trailing zeros trimmed. */
4638
- function formatSpend(amount) {
4639
- return `¥${amount.toFixed(4).replace(/\.?0+$/, "")}`;
4640
- }
4641
- const turnCostCache = /* @__PURE__ */ new Map();
4642
- const TURN_COST_CACHE_LIMIT = 1024;
4643
- async function cachedTurnCost(sessionId, messageId, getTurnSpend) {
4644
- const key = `${sessionId}\0${messageId}`;
4645
- const cached = turnCostCache.get(key);
4646
- if (cached !== void 0) return cached;
4647
- const spend = await getTurnSpend(sessionId, messageId);
4648
- if (turnCostCache.size >= TURN_COST_CACHE_LIMIT) turnCostCache.clear();
4649
- turnCostCache.set(key, spend);
4650
- return spend;
4651
- }
4652
4758
  /**
4653
4759
  * Render one Turn's billed cost as a plain, non-interactive `¥金额` at the
4654
4760
  * end of the closing message's actions row. The DOM stays inside the
@@ -4658,18 +4764,18 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4658
4764
  * row's 8px gap spaces it from the clock, so the amount reads as one trailing
4659
4765
  * meta line with the time; no icon, label or hover behavior of its own (the
4660
4766
  * row's hover reveal shows it with the clock). It appears only after the
4661
- * Remote settles and hides again when the Turn priced to zero (no DeepSeek
4767
+ * shared map resolves and hides again when the Turn priced to zero (no DeepSeek
4662
4768
  * usage); a failed fetch stays hidden so a Remote outage never clutters the
4663
4769
  * row.
4664
4770
  * @param props - the closing message id, the session runtime share, and the injected cost reader.
4665
4771
  * @returns the cost text, or null while loading, on failure, or for zero cost.
4666
4772
  */
4667
- function TurnCostAction({ messageId, sessionId, getTurnSpend }) {
4773
+ function TurnCostAction({ messageId, sessionId, getTurnCost }) {
4668
4774
  const [cost, setCost] = (0, react.useState)(null);
4669
4775
  (0, react.useEffect)(() => {
4670
4776
  let current = true;
4671
- Promise.resolve().then(() => cachedTurnCost(sessionId, messageId, getTurnSpend)).then((value) => {
4672
- if (current) setCost(value);
4777
+ Promise.resolve().then(() => getTurnCost(sessionId, messageId)).then((value) => {
4778
+ if (current) setCost(value ?? null);
4673
4779
  }, () => {
4674
4780
  if (current) setCost(null);
4675
4781
  });
@@ -4677,18 +4783,72 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4677
4783
  current = false;
4678
4784
  };
4679
4785
  }, [
4680
- getTurnSpend,
4786
+ getTurnCost,
4681
4787
  messageId,
4682
4788
  sessionId
4683
4789
  ]);
4684
- if (cost === null || cost.total <= 0) return null;
4790
+ if (cost === null || cost <= 0) return null;
4685
4791
  return (0, react_jsx_runtime.jsx)("span", {
4686
4792
  className: TurnCostAction_module_css_default.cost,
4687
4793
  "data-turn-cost": true,
4688
- children: formatSpend(cost.total)
4794
+ children: formatSpend(cost)
4689
4795
  });
4690
4796
  }
4691
4797
  //#endregion
4798
+ //#region lib/types/client/turnCostStore.js
4799
+ /** Max sessions whose maps are kept before the oldest is evicted. */
4800
+ const TURN_COST_STORE_LIMIT = 32;
4801
+ /**
4802
+ * Build the shared store over one batch fetcher.
4803
+ * @param fetch - the `getSessionTurnSpends` call.
4804
+ * @returns the store.
4805
+ */
4806
+ function createTurnCostStore(fetch) {
4807
+ const entries = /* @__PURE__ */ new Map();
4808
+ const entryFor = (sessionId) => {
4809
+ let entry = entries.get(sessionId);
4810
+ if (entry === void 0) {
4811
+ entry = {
4812
+ turns: /* @__PURE__ */ new Map(),
4813
+ inflight: void 0
4814
+ };
4815
+ entries.set(sessionId, entry);
4816
+ while (entries.size > 32) {
4817
+ const oldest = entries.keys().next().value;
4818
+ if (oldest === void 0) break;
4819
+ entries.delete(oldest);
4820
+ }
4821
+ }
4822
+ return entry;
4823
+ };
4824
+ const refresh = (sessionId, entry) => {
4825
+ if (entry.inflight !== void 0) return entry.inflight;
4826
+ const run = (async () => {
4827
+ try {
4828
+ const result = await fetch(sessionId);
4829
+ entry.turns = new Map(result.turns.map((row) => [row.messageId, row.total]));
4830
+ } finally {
4831
+ entry.inflight = void 0;
4832
+ }
4833
+ })();
4834
+ entry.inflight = run;
4835
+ return run;
4836
+ };
4837
+ return {
4838
+ async get(sessionId, messageId) {
4839
+ const entry = entryFor(sessionId);
4840
+ const cached = entry.turns.get(messageId);
4841
+ if (cached !== void 0) return cached;
4842
+ await refresh(sessionId, entry).catch(() => {});
4843
+ return entry.turns.get(messageId);
4844
+ },
4845
+ invalidate(sessionId) {
4846
+ if (sessionId === void 0) entries.clear();
4847
+ else entries.delete(sessionId);
4848
+ }
4849
+ };
4850
+ }
4851
+ //#endregion
4692
4852
  //#region lib/types/client/locales.js
4693
4853
  /** `billing` namespace dictionaries. */
4694
4854
  /** Dictionary namespace owned by this plugin. */
@@ -4762,12 +4922,17 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4762
4922
  zh,
4763
4923
  en
4764
4924
  }), "ui-billing: dictionaries");
4925
+ let lastBalance = null;
4765
4926
  const injected = {
4766
- getBalance: async () => unwrap("billing.getBalance", await billing.getBalance()),
4927
+ getBalance: async (force) => {
4928
+ const value = unwrap("billing.getBalance", await billing.getBalance(force));
4929
+ lastBalance = value;
4930
+ return value;
4931
+ },
4932
+ getCachedBalance: () => lastBalance,
4767
4933
  getSessionSpend: async (sessionId) => unwrap("billing.getSessionSpend", await billing.getSessionSpend(sessionId)),
4768
4934
  getTodaySpend: async (force) => unwrap("billing.getTodaySpend", await billing.getTodaySpend(force)),
4769
- getTodaySessionsSpend: async (force) => unwrap("billing.getTodaySessionsSpend", await billing.getTodaySessionsSpend(force)),
4770
- getTurnSpend: async (sessionId, messageId) => unwrap("billing.getTurnSpend", await billing.getTurnSpend(sessionId, messageId))
4935
+ getTodaySessionsSpend: async (force) => unwrap("billing.getTodaySessionsSpend", await billing.getTodaySessionsSpend(force))
4771
4936
  };
4772
4937
  ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
4773
4938
  name: "conversation.session.header.utilities",
@@ -4776,7 +4941,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4776
4941
  locale: NS,
4777
4942
  inject: () => injected
4778
4943
  }, BalanceBadge));
4779
- const turnCostInjected = { getTurnSpend: injected.getTurnSpend };
4944
+ const turnCosts = createTurnCostStore(async (sessionId) => unwrap("billing.getSessionTurnSpends", await billing.getSessionTurnSpends(sessionId)));
4945
+ const turnCostInjected = { getTurnCost: (sessionId, messageId) => turnCosts.get(sessionId, messageId) };
4780
4946
  ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
4781
4947
  name: "conversation.chat.assistant-actions",
4782
4948
  id: "billing-turn-cost",
@@ -4786,7 +4952,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4786
4952
  }, TurnCostAction));
4787
4953
  }
4788
4954
  //#endregion
4955
+ exports.TURN_COST_STORE_LIMIT = TURN_COST_STORE_LIMIT;
4789
4956
  exports.apply = apply;
4957
+ exports.createTurnCostStore = createTurnCostStore;
4790
4958
  exports.inject = inject;
4791
4959
  return module.exports;
4792
4960
  }
@@ -1,12 +1,31 @@
1
- /** Session-header billing badge: balance plus the current conversation's billed spend. */
2
- import type { DeepSeekBalance, DeepSeekSessionSpend, DeepSeekTodaySessionsSpend, DeepSeekTodaySpend, DeepSeekTurnSpend } from '@rayadesu/dsh-llm-billing/types';
1
+ /**
2
+ * Session-header billing badge: balance plus the current conversation's billed
3
+ * spend. Composition root: the data lifecycle lives in {@link useBillingData},
4
+ * and the trigger / detail panel are pure views. The badge renders null until
5
+ * the first balance fetch settles, and a refresh keeps the last values
6
+ * visible rather than blanking them.
7
+ */
8
+ import type { DeepSeekBalance, DeepSeekSessionSpend, DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from '@rayadesu/dsh-llm-billing/types';
3
9
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
4
10
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
5
11
  import { NS } from './locales.ts';
12
+ export { SESSION_RANKING_LIMIT } from './BalancePanel.tsx';
13
+ export { TURN_SETTLE_DEBOUNCE_MS } from './useBillingData.ts';
6
14
  /** Registration-side Remote face used by the header badge. */
7
15
  export interface BalanceBadgeInjected {
8
- /** Read the account balance; rejects with the Remote error message. */
9
- getBalance: () => Promise<DeepSeekBalance>;
16
+ /**
17
+ * Read the account balance; rejects with the Remote error message. `force`
18
+ * bypasses the host-side TTL — the manual refresh passes it, a mount does
19
+ * not (a snapshot younger than the TTL is reused, so switching sessions
20
+ * costs no provider call).
21
+ */
22
+ getBalance: (force?: boolean) => Promise<DeepSeekBalance>;
23
+ /**
24
+ * The last balance this browser half settled, or `null` before the first
25
+ * one. Synchronous: a badge mount renders the amount immediately and
26
+ * revalidates in the background.
27
+ */
28
+ getCachedBalance: () => DeepSeekBalance | null;
10
29
  /** Read one session's billed spend; rejects with the Remote error message. */
11
30
  getSessionSpend: (sessionId: SessionId) => Promise<DeepSeekSessionSpend>;
12
31
  /**
@@ -20,28 +39,16 @@ export interface BalanceBadgeInjected {
20
39
  * with the Remote error message. `force` behaves as in {@link getTodaySpend}.
21
40
  */
22
41
  getTodaySessionsSpend: (force?: boolean) => Promise<DeepSeekTodaySessionsSpend>;
23
- /** Read one completed Turn's billed cost, located by its closing message id. */
24
- getTurnSpend: (sessionId: SessionId, messageId: string) => Promise<DeepSeekTurnSpend>;
25
42
  }
26
43
  /** Full props assembled by the header utilities slot renderer. */
27
44
  export type BalanceBadgeProps = PropsRuntime<'conversation.session.header.utilities'> & InjectFace<BalanceBadgeInjected> & PropsLocale<typeof NS>;
28
- /** How many per-session ranking rows the panel shows before the overflow hint. */
29
- export declare const SESSION_RANKING_LIMIT = 10;
30
45
  /**
31
- * Render the billing badge in the session-header utilities row. The trigger
32
- * shows the remaining balance and this conversation's billed spend and opens
33
- * a label box with the amount, this session's spend with its cache-hit /
34
- * cache-miss-input / output cost breakdown per model, today's spend across
35
- * every session, a refresh action, and a spend disclaimer. Refreshing keeps
36
- * the last values visible rather than blanking them.
37
- *
38
- * The spend follows the conversation: when a prompt turn settles in the
39
- * current session, the badge recomputes only the (local, network-free)
40
- * session spend and today's spend through `getSessionSpend` /
41
- * `getTodaySpend`; the balance stays a manual-refresh snapshot and is never
42
- * refetched on its own.
46
+ * Render the billing badge in the session-header utilities row: the trigger
47
+ * shows the remaining balance and this conversation's billed spend, and opens
48
+ * the detail panel (amount, per-model spend breakdown, today's spend, ranking,
49
+ * refresh, disclaimer).
43
50
  * @param props - Remote face, locale, and the standard session-header runtime share.
44
51
  * @returns the badge, or null until the first balance fetch settles.
45
52
  */
46
- export declare function BalanceBadge({ getBalance, getSessionSpend, getTodaySpend, getTodaySessionsSpend, sessionId, useSession, t }: BalanceBadgeProps): import("react").JSX.Element | null;
53
+ export declare function BalanceBadge({ getBalance, getCachedBalance, getSessionSpend, getTodaySpend, getTodaySessionsSpend, sessionId, useSession, useProjection, t }: BalanceBadgeProps): import("react").JSX.Element | null;
47
54
  //# sourceMappingURL=BalanceBadge.d.ts.map
@@ -0,0 +1,19 @@
1
+ import type { DeepSeekSessionSpend, DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from '@rayadesu/dsh-llm-billing/types';
2
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
3
+ import { NS } from './locales.ts';
4
+ /** How many per-session ranking rows the panel shows before the overflow hint. */
5
+ export declare const SESSION_RANKING_LIMIT = 10;
6
+ /** Panel props: precomputed amount plus the same spend values the badge holds. */
7
+ export interface BalancePanelProps {
8
+ /** Primary balance line, e.g. `¥123.45`; `—` when the provider reports none. */
9
+ amount: string;
10
+ spend: DeepSeekSessionSpend | null;
11
+ todaySpend: DeepSeekTodaySpend | null;
12
+ sessionsSpend: DeepSeekTodaySessionsSpend | null;
13
+ refreshing: boolean;
14
+ onRefresh: () => void;
15
+ t: PropsLocale<typeof NS>['t'];
16
+ }
17
+ /** The detail box opened from the badge trigger. */
18
+ export declare function BalancePanel({ amount, spend, todaySpend, sessionsSpend, refreshing, onRefresh, t }: BalancePanelProps): import("react").JSX.Element;
19
+ //# sourceMappingURL=BalancePanel.d.ts.map
@@ -0,0 +1,17 @@
1
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import { NS } from './locales.ts';
3
+ /** Trigger props: the rendered strings come precomputed from the parent. */
4
+ export interface BalanceTriggerProps {
5
+ /** Primary line amount, e.g. `¥123.45`; `—` when the provider reports none. */
6
+ amount: string;
7
+ /** Secondary conversation-spend line; absent when the session has no priced usage. */
8
+ spendLine: string | undefined;
9
+ /** Whether the detail panel is open (chevron + aria-expanded). */
10
+ open: boolean;
11
+ /** Toggle the panel. */
12
+ onToggle: () => void;
13
+ t: PropsLocale<typeof NS>['t'];
14
+ }
15
+ /** The badge button: balance line, spend line, and the open-state chevron. */
16
+ export declare function BalanceTrigger({ amount, spendLine, open, onToggle, t }: BalanceTriggerProps): import("react").JSX.Element;
17
+ //# sourceMappingURL=BalanceTrigger.d.ts.map
@@ -1,12 +1,15 @@
1
1
  /** Per-turn billed-cost label in the closing assistant message's actions row. */
2
- import type { DeepSeekTurnSpend } from '@rayadesu/dsh-llm-billing/types';
3
2
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
4
3
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
5
4
  import { NS } from './locales.ts';
6
- /** Injected face: one Turn's billed cost through the billing Remote. */
5
+ /** Injected face: one message's Turn cost, served from a shared per-session map. */
7
6
  export interface TurnCostActionInjected {
8
- /** Read one completed Turn's billed cost, located by its closing message id. */
9
- getTurnSpend: (sessionId: SessionId, messageId: string) => Promise<DeepSeekTurnSpend>;
7
+ /**
8
+ * Resolve one completed Turn's billed cost by its message id. The underlying
9
+ * `getSessionTurnSpends` fetch happens once per session (shared by every row
10
+ * and coalesced across concurrent mounts), not once per rendered message.
11
+ */
12
+ getTurnCost: (sessionId: SessionId, messageId: string) => Promise<number | undefined>;
10
13
  }
11
14
  /**
12
15
  * Full props of the per-turn cost entry. The slot's owner share
@@ -24,11 +27,11 @@ export type TurnCostActionProps = PropsRuntime<'conversation.chat.assistant-acti
24
27
  * row's 8px gap spaces it from the clock, so the amount reads as one trailing
25
28
  * meta line with the time; no icon, label or hover behavior of its own (the
26
29
  * row's hover reveal shows it with the clock). It appears only after the
27
- * Remote settles and hides again when the Turn priced to zero (no DeepSeek
30
+ * shared map resolves and hides again when the Turn priced to zero (no DeepSeek
28
31
  * usage); a failed fetch stays hidden so a Remote outage never clutters the
29
32
  * row.
30
33
  * @param props - the closing message id, the session runtime share, and the injected cost reader.
31
34
  * @returns the cost text, or null while loading, on failure, or for zero cost.
32
35
  */
33
- export declare function TurnCostAction({ messageId, sessionId, getTurnSpend }: TurnCostActionProps): import("react").JSX.Element | null;
36
+ export declare function TurnCostAction({ messageId, sessionId, getTurnCost }: TurnCostActionProps): import("react").JSX.Element | null;
34
37
  //# sourceMappingURL=TurnCostAction.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared display formatting for the billing badges: the balance-line currency
3
+ * prefix and the CNY spend amount renderer. Both the header badge and the
4
+ * per-turn cost label format through these, so the two entries cannot drift
5
+ * apart.
6
+ */
7
+ import type { DeepSeekBalance } from '@rayadesu/dsh-llm-billing/types';
8
+ /** Currency prefix for one balance line; unknown codes render as a literal prefix. */
9
+ export declare function currencySymbol(currency: string): string;
10
+ /** The primary balance line, or undefined when the provider reports none. */
11
+ export declare function primaryLine(balance: DeepSeekBalance): {
12
+ symbol: string;
13
+ total: string;
14
+ } | undefined;
15
+ /** CNY amount, up to four decimals with trailing zeros trimmed. */
16
+ export declare function formatSpend(amount: number): string;
17
+ //# sourceMappingURL=format.d.ts.map
@@ -3,6 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis';
3
3
  import { type BillingKey } from './locales.ts';
4
4
  export type { BalanceBadgeInjected, BalanceBadgeProps } from './BalanceBadge.tsx';
5
5
  export type { TurnCostActionInjected, TurnCostActionProps } from './TurnCostAction.tsx';
6
+ export { createTurnCostStore, TURN_COST_STORE_LIMIT, type TurnCostStore } from './turnCostStore.ts';
6
7
  export type { BillingKey } from './locales.ts';
7
8
  declare module '@deepseek-ai/dsh-client-ui-slots' {
8
9
  interface LocaleNamespaceMap {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared per-session Turn-cost map: one `getSessionTurnSpends` fetch serves
3
+ * every cost row of a session, instead of one Remote call per rendered
4
+ * message. Concurrent row mounts join the in-flight fetch, and a message id
5
+ * the map does not know triggers exactly one refetch (a newly completed Turn),
6
+ * so a transcript of N rows costs O(1) calls plus one per new Turn.
7
+ */
8
+ import type { SessionId } from '@deepseek-ai/dsh-session/types';
9
+ import type { DeepSeekSessionTurnSpends } from '@rayadesu/dsh-llm-billing/types';
10
+ /** Max sessions whose maps are kept before the oldest is evicted. */
11
+ export declare const TURN_COST_STORE_LIMIT = 32;
12
+ /** The shared store surface the cost row consumes. */
13
+ export interface TurnCostStore {
14
+ /**
15
+ * Resolve one message's Turn cost, fetching the session's map on a miss.
16
+ * @param sessionId - the session owning the message.
17
+ * @param messageId - the assistant message id.
18
+ * @returns the Turn cost in CNY, or `undefined` when the message is not in a completed Turn.
19
+ */
20
+ get(sessionId: SessionId, messageId: string): Promise<number | undefined>;
21
+ /** Drop one session's map (or every map) so the next read refetches. */
22
+ invalidate(sessionId?: SessionId): void;
23
+ }
24
+ /**
25
+ * Build the shared store over one batch fetcher.
26
+ * @param fetch - the `getSessionTurnSpends` call.
27
+ * @returns the store.
28
+ */
29
+ export declare function createTurnCostStore(fetch: (sessionId: SessionId) => Promise<DeepSeekSessionTurnSpends>): TurnCostStore;
30
+ //# sourceMappingURL=turnCostStore.d.ts.map
@@ -0,0 +1,30 @@
1
+ import type { RefObject } from 'react';
2
+ import type { DeepSeekBalance, DeepSeekSessionSpend, DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from '@rayadesu/dsh-llm-billing/types';
3
+ import type { BalanceBadgeProps } from './BalanceBadge.tsx';
4
+ /** Debounce for the turn-settled recompute: a burst of turns prices once. */
5
+ export declare const TURN_SETTLE_DEBOUNCE_MS = 2000;
6
+ /** The data surface the trigger and the panel render from. */
7
+ export interface BillingData {
8
+ balance: DeepSeekBalance | null;
9
+ spend: DeepSeekSessionSpend | null;
10
+ todaySpend: DeepSeekTodaySpend | null;
11
+ sessionsSpend: DeepSeekTodaySessionsSpend | null;
12
+ /** Balance fetch failure while no value is present yet. */
13
+ error: string | null;
14
+ refreshing: boolean;
15
+ open: boolean;
16
+ rootRef: RefObject<HTMLDivElement>;
17
+ refresh: () => void;
18
+ toggleOpen: () => void;
19
+ }
20
+ /**
21
+ * Start the badge's data lifecycle for one session. The spend follows the
22
+ * conversation: the host-pushed `billingTodaySpend` projection drives the
23
+ * session line live (zero Remote calls), with `getSessionSpend` as the
24
+ * bootstrap/fallback when the projection key is absent; today's spend is
25
+ * recomputed through `getTodaySpend` when a turn settles; the balance stays a
26
+ * manual-refresh snapshot and is never refetched on its own.
27
+ * @param props - the badge's injected face and session runtime share.
28
+ */
29
+ export declare function useBillingData({ getBalance, getCachedBalance, getSessionSpend, getTodaySpend, getTodaySessionsSpend, sessionId, useSession, useProjection, }: Pick<BalanceBadgeProps, 'getBalance' | 'getCachedBalance' | 'getSessionSpend' | 'getTodaySpend' | 'getTodaySessionsSpend' | 'sessionId' | 'useSession' | 'useProjection'>): BillingData;
30
+ //# sourceMappingURL=useBillingData.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rayadesu/dsh-client-ui-billing",
3
3
  "description": "Session-header DeepSeek account-balance and conversation-spend badge over the billing Remote",
4
- "version": "0.3.8",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -47,7 +47,7 @@
47
47
  "access": "public"
48
48
  },
49
49
  "peerDependencies": {
50
- "@rayadesu/dsh-llm-billing": "^0.3.8",
50
+ "@rayadesu/dsh-llm-billing": "^0.3.9",
51
51
  "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.5",
52
52
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.5",
53
53
  "@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.5",
@@ -58,7 +58,7 @@
58
58
  "@deepseek-ai/dsh-session": "^0.1.2-alpha.5"
59
59
  },
60
60
  "devDependencies": {
61
- "@rayadesu/dsh-llm-billing": "^0.3.8",
61
+ "@rayadesu/dsh-llm-billing": "^0.3.9",
62
62
  "@deepseek-ai/dsh-api-gateway": "^0.1.2-alpha.5",
63
63
  "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.5",
64
64
  "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.5",