balance-dsh 1.0.0
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/LICENSE +21 -0
- package/README.md +108 -0
- package/cordis.patch.yml +33 -0
- package/lib/client.js +615 -0
- package/lib/index.js +463 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rannist
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# balance-dsh
|
|
2
|
+
|
|
3
|
+
DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 token/金额消耗。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。悬停显示明细(总余额、充值余额),点击金额跳转 DeepSeek 用量页。
|
|
8
|
+
- **收起态 FAB**:侧边栏收起时,余额显示为左下角悬浮胶囊。
|
|
9
|
+
- **自动刷新**:每 60 秒自动刷新一次;每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
|
|
10
|
+
- **高峰/空闲计费**:官方高峰窗口为**周一至周五 09:00-12:00、14:00-18:00(北京时间)**,周末全天空闲。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
|
|
11
|
+
- **会话消耗行**:对话框下方实时显示当前会话消耗(新增 tokens + 估算金额),悬停查看完整明细(模型、时段拆分、计费口径)。
|
|
12
|
+
- **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
前提:已安装 DSH Desktop,且知道自己的 profile 目录(通常是 `C:\Users\<你的用户名>\.dsh\profiles\desktop\`)。
|
|
17
|
+
|
|
18
|
+
### 方式一:通过 npm 安装(推荐)
|
|
19
|
+
|
|
20
|
+
```powershell
|
|
21
|
+
cd C:\Users\<你的用户名>\.dsh\profiles\desktop
|
|
22
|
+
pnpm add balance-dsh
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 方式二:从 tarball 安装
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
cd C:\Users\<你的用户名>\.dsh\profiles\desktop
|
|
29
|
+
pnpm add "file:C:\path\to\balance-dsh-1.0.0.tgz"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 方式三:从 GitHub 安装
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
cd C:\Users\<你的用户名>\.dsh\profiles\desktop
|
|
36
|
+
dsh plugin --profile desktop add github:Rannist/balance-dsh
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> 用 npm 安装后,插件自带 `dsh.bundle.patch`(`cordis.patch.yml`),安装即自动注册,**无需手动改 profile 的 cordis.patch.yml**。只有用旧版 tarball/手动方式时才需要手动注册:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# profile 目录的 cordis.patch.yml(只有手动安装时才需要)
|
|
43
|
+
- insert:
|
|
44
|
+
- id: balance
|
|
45
|
+
name: balance-dsh
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 设置 DeepSeek API Key(三选一)
|
|
49
|
+
|
|
50
|
+
1. 环境变量 `DEEPSEEK_API_KEY`,或
|
|
51
|
+
2. 在注册项的 `config.apiKey` 中设置,或
|
|
52
|
+
3. 使用 DSH 凭据存储中的 `DEEPSEEK_API_KEY`
|
|
53
|
+
|
|
54
|
+
### 重启
|
|
55
|
+
|
|
56
|
+
服务端界面改动需**重启 DSH Desktop** 后生效。
|
|
57
|
+
|
|
58
|
+
## 配置
|
|
59
|
+
|
|
60
|
+
在插件注册项(或 `cordis.patch.yml`)中覆盖:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- id: balance
|
|
64
|
+
name: balance-dsh
|
|
65
|
+
config:
|
|
66
|
+
apiKey: sk-xxxx # 可选;缺省用 DEEPSEEK_API_KEY 环境变量/凭据
|
|
67
|
+
peakWindows: # 可选;默认周一至周五,如下
|
|
68
|
+
- {start: 9, end: 12}
|
|
69
|
+
- {start: 14, end: 18}
|
|
70
|
+
peakWeekdays: [1, 2, 3, 4, 5] # 可选;1=周一 … 5=周五,0/6=周末
|
|
71
|
+
price: # 可选;逐项覆盖对应模型价格(元/百万 tokens)
|
|
72
|
+
inputCnyPerMillion: 1.5
|
|
73
|
+
cacheReadCnyPerMillion: 0.05
|
|
74
|
+
cacheWriteCnyPerMillion: 0
|
|
75
|
+
outputCnyPerMillion: 4.5
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> 注意:插件已自动按高峰/空闲时段计费(高峰价 = 目录价 × 2),**不要再设置 `config.peak: true`**,否则会重复翻倍。
|
|
79
|
+
|
|
80
|
+
## 卸载
|
|
81
|
+
|
|
82
|
+
```powershell
|
|
83
|
+
cd C:\Users\<你的用户名>\.dsh\profiles\desktop
|
|
84
|
+
pnpm remove balance-dsh
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
如需删除手动注册行(若你手动加过),从 profile 的 `cordis.patch.yml` 移除对应的 `balance` 条目。
|
|
88
|
+
|
|
89
|
+
## 权限 / 数据
|
|
90
|
+
|
|
91
|
+
- 仅在你**主动刷新**或对话完成时,会向 `api.deepseek.com` 的 `/user/balance` 端点发起一次余额查询。
|
|
92
|
+
- 插件需要你的 DeepSeek API Key(仅用于余额查询),不收集、不上传任何会话内容到第三方。
|
|
93
|
+
- 消费统计完全基于本地会话事件(`request/header`、`assistant/message` 的 token usage)计算,不向 DeepSeek 之外的服务发送数据。
|
|
94
|
+
|
|
95
|
+
## 价格表(2026-08-17 生效的 V4 峰谷定价·空闲时段价,元/百万 tokens)
|
|
96
|
+
|
|
97
|
+
| 模型 | 输入(缓存未命中) | 输入(缓存命中) | 输出 |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| deepseek-v4-flash | 1.5 | 0.05 | 4.5 |
|
|
100
|
+
| deepseek-v4-pro | 4.5 | 0.15 | 13.5 |
|
|
101
|
+
| deepseek-chat(旧) | 2 | 0.5 | 3 |
|
|
102
|
+
| deepseek-reasoner(旧) | 4 | 1 | 16 |
|
|
103
|
+
|
|
104
|
+
未识别模型按 deepseek-v4-flash 兜底;DeepSeek 调价后需同步更新 `lib/index.js` 的 `MODEL_PRICING` 与 `lib/client.js` 的 `DEFAULT_PRICING`。
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# balance-dsh bundle patch
|
|
2
|
+
#
|
|
3
|
+
# This is the `dsh.bundle.patch` layer of the published npm package. When the
|
|
4
|
+
# plugin is installed through the official CLI:
|
|
5
|
+
#
|
|
6
|
+
# dsh plugin --profile <name> add balance-dsh@<version>
|
|
7
|
+
#
|
|
8
|
+
# the command reconciles the profile bundle stack against installed packages
|
|
9
|
+
# and, seeing this declaration, appends `balance-dsh` to the bundle stack.
|
|
10
|
+
# The profile boot then merges this patch (a single `insert` of the plugin row)
|
|
11
|
+
# exactly like a manual cordis.patch.yml mount line.
|
|
12
|
+
#
|
|
13
|
+
# NOTE: if a profile already carries the old manual mount line in its own
|
|
14
|
+
# cordis.patch.yml (e.g. `- id: balance / name: balance-dsh`), remove it before
|
|
15
|
+
# switching to the bundle channel to avoid double-mounting.
|
|
16
|
+
#
|
|
17
|
+
# To override DeepSeek API key / prices, add a `config` block to the inserted
|
|
18
|
+
# row. All fields are optional; the plugin falls back to the DEEPSEEK_API_KEY
|
|
19
|
+
# environment variable / credential store when `config.apiKey` is omitted.
|
|
20
|
+
- insert:
|
|
21
|
+
- id: balance
|
|
22
|
+
name: balance-dsh
|
|
23
|
+
# config:
|
|
24
|
+
# apiKey: sk-xxxx # optional; else DEEPSEEK_API_KEY env/credential
|
|
25
|
+
# peakWindows: # optional; default Mon-Fri 09-12 / 14-18 Beijing
|
|
26
|
+
# - {start: 9, end: 12}
|
|
27
|
+
# - {start: 14, end: 18}
|
|
28
|
+
# peakWeekdays: [1, 2, 3, 4, 5] # optional; 1=Mon ... 5=Fri
|
|
29
|
+
# price: # optional per-model overrides (CNY / 1M tokens)
|
|
30
|
+
# inputCnyPerMillion: 1.5
|
|
31
|
+
# cacheReadCnyPerMillion: 0.05
|
|
32
|
+
# cacheWriteCnyPerMillion: 0
|
|
33
|
+
# outputCnyPerMillion: 4.5
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "balance-dsh",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let jsx = require("react/jsx-runtime");
|
|
9
|
+
let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
10
|
+
|
|
11
|
+
// ─── 样式 ────────────────────────────────────────────────────────────────
|
|
12
|
+
const css = `
|
|
13
|
+
/* 兜底定义主题过渡变量:上游 deepsuite theme/global.css 未加载时,
|
|
14
|
+
恢复侧边栏/面板的滑动动画(AppFrame 宽度过渡依赖 --ds-transition-duration-slow) */
|
|
15
|
+
:root{--ds-transition-duration-slow:.3s;--ds-transition-duration:.2s;--ds-transition-duration-fast:.1s;--ds-ease-in-out:cubic-bezier(.4,0,.2,1)}
|
|
16
|
+
/* ── 基础元素(自定义类,稳定;纯文字样式,不用胶囊) ── */
|
|
17
|
+
/* 完全复刻设置按钮(.VOzbGW_trigger):width calc(100%+4px)、height 42px、margin 4px -2px、
|
|
18
|
+
padding 0 10px 0 8px、gap 8px、font 14px/22px、color label-primary;
|
|
19
|
+
左缘与设置按钮严格对齐,高度/颜色一致(图标与"余额"文字也统一 label-primary,金额 600 加粗) */
|
|
20
|
+
.balance-footer{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:calc(100% + 4px);min-width:0;height:42px;margin:0 -2px;padding:0 10px 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:hidden;-webkit-user-select:none;user-select:none}
|
|
21
|
+
.balance-footer__icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-primary)}
|
|
22
|
+
.balance-footer__label{white-space:nowrap;color:var(--dsw-alias-label-primary)}
|
|
23
|
+
.balance-footer__value{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap;padding:2px 8px;border-radius:999px;margin-left:auto;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s)}
|
|
24
|
+
.balance-footer__value:hover{filter:brightness(.85);transform:scale(1.06)}
|
|
25
|
+
.balance-footer__value--healthy{color:var(--dsw-alias-state-success-primary);background:rgba(34,197,94,.15)}
|
|
26
|
+
.balance-footer__value--low{color:#d97706;background:rgba(217,119,6,.15)}
|
|
27
|
+
.balance-footer__value--empty{color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.15)}
|
|
28
|
+
.balance-footer__topup{margin-left:auto;flex:none;box-sizing:border-box;padding:2px 10px;border-radius:999px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-state-error-primary,#e5484d);text-decoration:none;background:rgba(229,72,77,.15);transition:transform .15s var(--ds-ease-in-out,.2s)}
|
|
29
|
+
.balance-footer__topup:hover{filter:brightness(.85);transform:scale(1.06)}
|
|
30
|
+
.balance-footer__period-inline{font-weight:500;white-space:nowrap}
|
|
31
|
+
.balance-footer__period-inline--peak{color:var(--dsw-alias-state-error-primary,#e5484d)}
|
|
32
|
+
.balance-footer__period-inline--offpeak{color:var(--dsw-alias-state-success-primary)}
|
|
33
|
+
.balance-footer__period-inline--refreshing{color:var(--dsw-alias-label-secondary)}
|
|
34
|
+
.balance-footer__error{color:var(--dsw-alias-state-error-primary)}
|
|
35
|
+
.balance-topup{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:calc(100% + 4px);min-width:0;height:42px;margin:0 -2px;padding:0 10px 0 8px;color:var(--dsw-alias-state-error-primary,#e5484d);font-size:14px;font-weight:600;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:hidden}
|
|
36
|
+
.balance-topup:hover{text-decoration:underline}
|
|
37
|
+
/* ── 收起态:悬浮 FAB(保持原样) ── */
|
|
38
|
+
.balance-fab{box-sizing:border-box;position:fixed;left:68px;bottom:12px;z-index:40;height:32px;padding:0 14px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base));border:1px solid var(--dsw-alias-border-l2);border-radius:999px;cursor:pointer;font-size:12px;font-weight:600;line-height:1;text-decoration:none;white-space:nowrap;pointer-events:auto;box-shadow:0 4px 12px rgba(0,0,0,.10)}
|
|
39
|
+
.balance-fab:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
40
|
+
.balance-fab-dark{color:#fff;background:var(--dsw-alias-state-error-primary,#e5484d);border-color:var(--dsw-alias-state-error-primary,#e5484d)}
|
|
41
|
+
.balance-fab-dark:hover{background:#cf3a3f}
|
|
42
|
+
.balance-usage-line{box-sizing:border-box;display:flex;justify-content:center;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
43
|
+
.balance-usage-line strong{color:var(--dsw-alias-label-primary);font-weight:600}
|
|
44
|
+
/* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
|
|
45
|
+
全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
|
|
46
|
+
- wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
|
|
47
|
+
- footArea 改 row;footerActions 占满并把余额推向右端;settingsArea order:-1 排到最左
|
|
48
|
+
- [data-slot] = slot 出口契约(display:contents) */
|
|
49
|
+
:has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup){flex-direction:row;align-items:center;gap:4px}
|
|
50
|
+
:has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) > :has(> [data-slot="sidebar.footer.action"]){flex:1 1 0;min-width:0;width:auto;flex-direction:row;justify-content:flex-end;align-items:center;gap:4px}
|
|
51
|
+
:has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) > :has(> [data-slot="sidebar.settings"]){order:-1;flex:0 0 auto;width:32px;min-width:0}
|
|
52
|
+
:has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) [data-slot="sidebar.settings"] button{width:32px;height:32px;margin:0;padding:0;justify-content:center;border-radius:50%;gap:0}
|
|
53
|
+
:has(> :has(> [data-slot="sidebar.footer.action"])):has(.balance-footer, .balance-topup) [data-slot="sidebar.settings"] button span{display:none}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const tagId = "balance/balance-widget.css";
|
|
57
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
58
|
+
const tag = document.createElement("style");
|
|
59
|
+
tag.dataset.plugin = "balance";
|
|
60
|
+
tag.dataset.pluginCss = tagId;
|
|
61
|
+
tag.textContent = css;
|
|
62
|
+
document.head.appendChild(tag);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── 价格(与服务端默认保持一致;服务端 /balance-api 返回后会覆盖) ───
|
|
66
|
+
// 2026-08-17 生效的 DeepSeek V4-Flash 空闲时段价(元/百万 tokens)
|
|
67
|
+
const DEFAULT_PRICING = {
|
|
68
|
+
inputCnyPerMillion: 1.5, // 输入(缓存未命中)
|
|
69
|
+
cacheReadCnyPerMillion: 0.05, // 输入(缓存命中)
|
|
70
|
+
cacheWriteCnyPerMillion: 0, // DeepSeek 无缓存写入计费
|
|
71
|
+
outputCnyPerMillion: 4.5, // 输出
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let pricing = DEFAULT_PRICING;
|
|
75
|
+
const pricingListeners = new Set();
|
|
76
|
+
function setPricing(next) {
|
|
77
|
+
if (!next) return;
|
|
78
|
+
pricing = {
|
|
79
|
+
inputCnyPerMillion: Number(next.inputCnyPerMillion ?? DEFAULT_PRICING.inputCnyPerMillion),
|
|
80
|
+
cacheReadCnyPerMillion: Number(next.cacheReadCnyPerMillion ?? DEFAULT_PRICING.cacheReadCnyPerMillion),
|
|
81
|
+
cacheWriteCnyPerMillion: Number(next.cacheWriteCnyPerMillion ?? DEFAULT_PRICING.cacheWriteCnyPerMillion),
|
|
82
|
+
outputCnyPerMillion: Number(next.outputCnyPerMillion ?? DEFAULT_PRICING.outputCnyPerMillion),
|
|
83
|
+
};
|
|
84
|
+
for (const listener of pricingListeners) listener();
|
|
85
|
+
}
|
|
86
|
+
function usePricing() {
|
|
87
|
+
const [, force] = react.useState(0);
|
|
88
|
+
react.useEffect(() => {
|
|
89
|
+
const listener = () => force((n) => n + 1);
|
|
90
|
+
pricingListeners.add(listener);
|
|
91
|
+
return () => pricingListeners.delete(listener);
|
|
92
|
+
}, []);
|
|
93
|
+
// 返回原始目录价(分时段计费时由 computeSplitCost 决定高峰×2)
|
|
94
|
+
return pricing;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function computeCost(usage, p) {
|
|
98
|
+
const input =
|
|
99
|
+
((usage.uncachedInputTokens ?? 0) * p.inputCnyPerMillion +
|
|
100
|
+
(usage.cacheReadTokens ?? 0) * p.cacheReadCnyPerMillion +
|
|
101
|
+
(usage.cacheWriteTokens ?? 0) * p.cacheWriteCnyPerMillion) /
|
|
102
|
+
1_000_000;
|
|
103
|
+
const output = ((usage.outputTokens ?? 0) * p.outputCnyPerMillion) / 1_000_000;
|
|
104
|
+
return input + output;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 分时段金额:高峰桶按目录价 ×2、空闲桶按目录价(与服务端 estimateCost 口径一致)。 */
|
|
108
|
+
function computeSplitCost(usage, p) {
|
|
109
|
+
const costOf = (u) =>
|
|
110
|
+
((u.inputTokens ?? 0) * p.inputCnyPerMillion +
|
|
111
|
+
(u.cacheReadTokens ?? 0) * p.cacheReadCnyPerMillion +
|
|
112
|
+
(u.cacheWriteTokens ?? 0) * p.cacheWriteCnyPerMillion) /
|
|
113
|
+
1_000_000 +
|
|
114
|
+
((u.outputTokens ?? 0) * p.outputCnyPerMillion) / 1_000_000;
|
|
115
|
+
return costOf(usage.peak) * 2 + costOf(usage.offpeak);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatTokens(n) {
|
|
119
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
|
|
120
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
|
|
121
|
+
return String(n);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── 当前会话模型:按模型取价(服务端 /balance-api 按 ?model= 返回对应价格) ───
|
|
125
|
+
let sessionsFace = void 0;
|
|
126
|
+
let currentModel = "";
|
|
127
|
+
const modelListeners = new Set();
|
|
128
|
+
const modelCache = new Map(); // sessionId -> model
|
|
129
|
+
function setCurrentModel(model) {
|
|
130
|
+
if (model === currentModel) return;
|
|
131
|
+
currentModel = model;
|
|
132
|
+
for (const listener of modelListeners) listener();
|
|
133
|
+
}
|
|
134
|
+
function useCurrentModel() {
|
|
135
|
+
const [model, force] = react.useState(currentModel);
|
|
136
|
+
react.useEffect(() => {
|
|
137
|
+
const listener = () => force(currentModel);
|
|
138
|
+
modelListeners.add(listener);
|
|
139
|
+
return () => modelListeners.delete(listener);
|
|
140
|
+
}, []);
|
|
141
|
+
return model;
|
|
142
|
+
}
|
|
143
|
+
/** 通过官方 sessions RPC 解析某会话使用的模型,缓存结果并广播;RPC 拿不到时回退到服务端 detectModel。
|
|
144
|
+
* 只缓存"取到模型"的结果;取到空串不缓存,以便会话后续可能产出模型时能重新解析。 */
|
|
145
|
+
async function resolveSessionModel(sessionId) {
|
|
146
|
+
if (typeof sessionId !== "string" || sessionId === "") return "";
|
|
147
|
+
if (modelCache.has(sessionId)) {
|
|
148
|
+
const cached = modelCache.get(sessionId);
|
|
149
|
+
if (cached !== "") setCurrentModel(cached);
|
|
150
|
+
return cached;
|
|
151
|
+
}
|
|
152
|
+
let model = "";
|
|
153
|
+
if (sessionsFace !== void 0) {
|
|
154
|
+
try {
|
|
155
|
+
const { result } = await sessionsFace.models({ sessionId });
|
|
156
|
+
model = result && result.ok ? (result.value?.current?.model ?? "") : "";
|
|
157
|
+
} catch {
|
|
158
|
+
model = "";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 回退:服务端 /balance-api?session= 已检测实际模型(遍历会话事件)
|
|
162
|
+
if (model === "") model = await fetchServerModel(sessionId);
|
|
163
|
+
if (model !== "") modelCache.set(sessionId, model);
|
|
164
|
+
if (model !== "") setCurrentModel(model);
|
|
165
|
+
return model;
|
|
166
|
+
}
|
|
167
|
+
/** 请求服务端按会话检测模型(等价于 /balance-api?session= 返回的 model 字段),失败返回 "". */
|
|
168
|
+
async function fetchServerModel(sessionId) {
|
|
169
|
+
if (typeof sessionId !== "string" || sessionId === "") return "";
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch("/balance-api?session=" + encodeURIComponent(sessionId));
|
|
172
|
+
if (!res.ok) return "";
|
|
173
|
+
const data = await res.json();
|
|
174
|
+
return data && data.ok && typeof data.model === "string" ? data.model : "";
|
|
175
|
+
} catch {
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function balanceApiUrl() {
|
|
180
|
+
return currentModel === "" ? "/balance-api" : "/balance-api?model=" + encodeURIComponent(currentModel);
|
|
181
|
+
}
|
|
182
|
+
/** 拉取余额 + 按当前模型计算的定价;成功后广播 pricing。 */
|
|
183
|
+
async function fetchBalanceData(controller) {
|
|
184
|
+
const res = await fetch(balanceApiUrl(), { signal: controller && controller.signal });
|
|
185
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
186
|
+
const data = await res.json();
|
|
187
|
+
if (data && data.ok) {
|
|
188
|
+
if (data.pricing) setPricing(data.pricing);
|
|
189
|
+
return data;
|
|
190
|
+
}
|
|
191
|
+
throw new Error((data && data.error) || "余额查询失败");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 拉取指定会话的分时段 usage 及子代理 usage(/balance-api?session=...),失败返回 null。 */
|
|
195
|
+
async function fetchSessionUsage(sessionId) {
|
|
196
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetch("/balance-api?session=" + encodeURIComponent(sessionId));
|
|
199
|
+
if (!res.ok) return null;
|
|
200
|
+
const data = await res.json();
|
|
201
|
+
if (!data || !data.ok) return null;
|
|
202
|
+
return {
|
|
203
|
+
usage: data.usage || null,
|
|
204
|
+
subagentUsage: data.subagentUsage || null,
|
|
205
|
+
};
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── 高峰/空闲时段(纯客户端判断,无需服务端) ────────────────────────
|
|
212
|
+
// V4 峰谷定价(2026-08-17 生效):高峰价 = 空闲价 × 2。
|
|
213
|
+
// 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
|
|
214
|
+
const PEAK_WINDOWS = [
|
|
215
|
+
{ start: 9, end: 12 },
|
|
216
|
+
{ start: 14, end: 18 },
|
|
217
|
+
];
|
|
218
|
+
const PEAK_WEEKDAYS = [1, 2, 3, 4, 5]; // 1=周一 … 5=周五,0/6=周末
|
|
219
|
+
function currentPeriod() {
|
|
220
|
+
const d = new Date(Date.now() + 8 * 60 * 60 * 1000); // 北京时间
|
|
221
|
+
const hour = d.getUTCHours();
|
|
222
|
+
const weekday = d.getUTCDay(); // 0=周日 … 6=周六
|
|
223
|
+
return PEAK_WEEKDAYS.includes(weekday) && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end)
|
|
224
|
+
? "peak"
|
|
225
|
+
: "offpeak";
|
|
226
|
+
}
|
|
227
|
+
/** 按当前时段调整价格:高峰 ×2、空闲原价。注意:若服务端已配 config.peak:true
|
|
228
|
+
* (强制高峰价翻倍),此处会再乘一次,两种方式不要同时使用。 */
|
|
229
|
+
function periodAdjustedPricing(p) {
|
|
230
|
+
if (currentPeriod() !== "peak") return p;
|
|
231
|
+
return {
|
|
232
|
+
inputCnyPerMillion: p.inputCnyPerMillion * 2,
|
|
233
|
+
cacheReadCnyPerMillion: p.cacheReadCnyPerMillion * 2,
|
|
234
|
+
cacheWriteCnyPerMillion: p.cacheWriteCnyPerMillion * 2,
|
|
235
|
+
outputCnyPerMillion: p.outputCnyPerMillion * 2,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── 余额数据:模块级共享缓存 + 60s 轮询 ──────────────────────────────
|
|
240
|
+
// 切换会话时 header 组件会重新挂载,若每个实例各自拉取会先消失再出现(闪烁);
|
|
241
|
+
// 缓存全局存一份,任何实例挂载都直接读缓存,无需重新请求。
|
|
242
|
+
let balanceCache = { status: "loading", balance: null, error: null, refreshing: false };
|
|
243
|
+
let balancePollTimer = null;
|
|
244
|
+
const balanceListeners = new Set();
|
|
245
|
+
|
|
246
|
+
function emitBalance() {
|
|
247
|
+
for (const listener of balanceListeners) listener();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 防抖/防并发:同一时刻只允许一个余额请求;两次刷新间隔不小于 800ms。
|
|
251
|
+
// 疯狂右键/轮询/对话刷新并发时自动合并,避免打爆 DeepSeek API。
|
|
252
|
+
let balanceFetchInFlight = false;
|
|
253
|
+
let lastBalanceFetchAt = 0;
|
|
254
|
+
const BALANCE_REFRESH_MIN_INTERVAL = 800;
|
|
255
|
+
|
|
256
|
+
async function fetchBalanceOnce() {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
if (balanceFetchInFlight || now - lastBalanceFetchAt < BALANCE_REFRESH_MIN_INTERVAL) return;
|
|
259
|
+
lastBalanceFetchAt = now;
|
|
260
|
+
balanceFetchInFlight = true;
|
|
261
|
+
try {
|
|
262
|
+
const data = await fetchBalanceData();
|
|
263
|
+
balanceCache = { status: "ready", balance: data.balance, error: null, refreshing: false };
|
|
264
|
+
} catch (error) {
|
|
265
|
+
balanceCache = { status: "error", balance: null, error: error && error.message ? error.message : String(error), refreshing: false };
|
|
266
|
+
} finally {
|
|
267
|
+
balanceFetchInFlight = false;
|
|
268
|
+
}
|
|
269
|
+
emitBalance();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ensureBalancePolling() {
|
|
273
|
+
if (balancePollTimer !== null) return;
|
|
274
|
+
fetchBalanceOnce();
|
|
275
|
+
balancePollTimer = setInterval(fetchBalanceOnce, 60_000);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function useBalance() {
|
|
279
|
+
const [, force] = react.useState(0);
|
|
280
|
+
react.useEffect(() => {
|
|
281
|
+
const listener = () => force((n) => n + 1);
|
|
282
|
+
balanceListeners.add(listener);
|
|
283
|
+
ensureBalancePolling();
|
|
284
|
+
return () => balanceListeners.delete(listener);
|
|
285
|
+
}, []);
|
|
286
|
+
return balanceCache;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 右键余额:阻止默认菜单、立即显示"刷新中"反馈(防止连按),并触发刷新(防抖合并)。
|
|
290
|
+
// 仅当本次点击真的会发起请求时才显示反馈,避免被防抖合并的点击留下悬挂的"刷新中"状态。
|
|
291
|
+
function onBalanceContextMenu(event) {
|
|
292
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
const willFetch = !balanceFetchInFlight && now - lastBalanceFetchAt >= BALANCE_REFRESH_MIN_INTERVAL;
|
|
295
|
+
if (willFetch && !balanceCache.refreshing) {
|
|
296
|
+
balanceCache = { ...balanceCache, refreshing: true };
|
|
297
|
+
emitBalance();
|
|
298
|
+
}
|
|
299
|
+
fetchBalanceOnce();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── 余额显示:展开态侧栏底部文字(¥ 图标+金额);收起态悬浮 FAB ──
|
|
303
|
+
function BalanceFooter({ wide }) {
|
|
304
|
+
const state = useBalance();
|
|
305
|
+
|
|
306
|
+
if (!wide) {
|
|
307
|
+
// 收起态:悬浮 FAB(原样保留)
|
|
308
|
+
if (state.status === "error" || !state.balance) return null;
|
|
309
|
+
const b = state.balance;
|
|
310
|
+
const total = Number.parseFloat(b.total_balance ?? "0");
|
|
311
|
+
const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
|
|
312
|
+
if (depleted) {
|
|
313
|
+
return jsx.jsx("a", {
|
|
314
|
+
className: "balance-fab balance-fab-dark",
|
|
315
|
+
href: "https://platform.deepseek.com/top_up",
|
|
316
|
+
target: "_blank",
|
|
317
|
+
rel: "noreferrer",
|
|
318
|
+
onContextMenu: onBalanceContextMenu,
|
|
319
|
+
children: "去充值",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const currency = b.currency || "CNY";
|
|
323
|
+
const detailText = "总余额 " + b.total_balance + " " + currency + " · 充值余额 " + b.topped_up_balance + " " + currency;
|
|
324
|
+
return jsx.jsx("a", {
|
|
325
|
+
className: "balance-fab",
|
|
326
|
+
href: "https://platform.deepseek.com/usage",
|
|
327
|
+
target: "_blank",
|
|
328
|
+
rel: "noreferrer",
|
|
329
|
+
onContextMenu: onBalanceContextMenu,
|
|
330
|
+
children: "¥" + b.total_balance,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 展开态:底部文字(加载中/失败/去充值/正常)
|
|
335
|
+
if (state.status === "error") {
|
|
336
|
+
return jsx.jsx("div", {
|
|
337
|
+
className: "balance-footer",
|
|
338
|
+
title: "余额查询失败:" + state.error,
|
|
339
|
+
onContextMenu: onBalanceContextMenu,
|
|
340
|
+
children: jsx.jsx("span", { className: "balance-footer__error", children: "余额查询失败" }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!state.balance) {
|
|
345
|
+
return jsx.jsx("div", {
|
|
346
|
+
className: "balance-footer",
|
|
347
|
+
onContextMenu: onBalanceContextMenu,
|
|
348
|
+
children: "余额加载中…",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const b = state.balance;
|
|
353
|
+
const total = Number.parseFloat(b.total_balance ?? "0");
|
|
354
|
+
const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
|
|
355
|
+
const currency = b.currency || "CNY";
|
|
356
|
+
const totalText = b.total_balance + " " + currency;
|
|
357
|
+
const detailText = "总余额 " + totalText + " · 充值余额 " + b.topped_up_balance + " " + currency;
|
|
358
|
+
const period = currentPeriod();
|
|
359
|
+
const low = total > 0 && total < 10;
|
|
360
|
+
|
|
361
|
+
// 金额胶囊:正常显示 金额•时段(语义色);没余额显示 0(红色),并追加"去充值"胶囊
|
|
362
|
+
// 没余额:保留图标+"余额"字样,去掉具体金额,右侧只显示"去充值"按钮
|
|
363
|
+
if (depleted) {
|
|
364
|
+
return jsx.jsx("div", {
|
|
365
|
+
className: "balance-footer",
|
|
366
|
+
onContextMenu: onBalanceContextMenu,
|
|
367
|
+
children: [
|
|
368
|
+
jsx.jsx("span", {
|
|
369
|
+
className: "balance-footer__icon",
|
|
370
|
+
children: jsx.jsxs("svg", {
|
|
371
|
+
width: 16,
|
|
372
|
+
height: 16,
|
|
373
|
+
viewBox: "0 0 24 24",
|
|
374
|
+
fill: "none",
|
|
375
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
376
|
+
stroke: "currentColor",
|
|
377
|
+
strokeWidth: 2,
|
|
378
|
+
strokeLinecap: "round",
|
|
379
|
+
strokeLinejoin: "round",
|
|
380
|
+
"aria-hidden": "true",
|
|
381
|
+
children: [
|
|
382
|
+
jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
|
|
383
|
+
jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
|
|
384
|
+
jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
|
|
385
|
+
],
|
|
386
|
+
}),
|
|
387
|
+
}),
|
|
388
|
+
jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
|
|
389
|
+
jsx.jsx("a", {
|
|
390
|
+
className: "balance-footer__topup",
|
|
391
|
+
href: "https://platform.deepseek.com/top_up",
|
|
392
|
+
target: "_blank",
|
|
393
|
+
rel: "noreferrer",
|
|
394
|
+
onContextMenu: onBalanceContextMenu,
|
|
395
|
+
children: "去充值",
|
|
396
|
+
}),
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const valueClass = low ? " balance-footer__value--low" : " balance-footer__value--healthy";
|
|
402
|
+
const valueChildren = [
|
|
403
|
+
totalText,
|
|
404
|
+
" • ",
|
|
405
|
+
state.refreshing
|
|
406
|
+
? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
|
|
407
|
+
: jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
|
|
408
|
+
];
|
|
409
|
+
const inner = [
|
|
410
|
+
jsx.jsx("span", {
|
|
411
|
+
className: "balance-footer__icon",
|
|
412
|
+
children: jsx.jsxs("svg", {
|
|
413
|
+
width: 16,
|
|
414
|
+
height: 16,
|
|
415
|
+
viewBox: "0 0 24 24",
|
|
416
|
+
fill: "none",
|
|
417
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
418
|
+
stroke: "currentColor",
|
|
419
|
+
strokeWidth: 2,
|
|
420
|
+
strokeLinecap: "round",
|
|
421
|
+
strokeLinejoin: "round",
|
|
422
|
+
"aria-hidden": "true",
|
|
423
|
+
children: [
|
|
424
|
+
jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
|
|
425
|
+
jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
|
|
426
|
+
jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
|
|
427
|
+
],
|
|
428
|
+
}),
|
|
429
|
+
}),
|
|
430
|
+
jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
|
|
431
|
+
jsx.jsx("a", {
|
|
432
|
+
className: "balance-footer__value" + valueClass,
|
|
433
|
+
href: "https://platform.deepseek.com/usage",
|
|
434
|
+
target: "_blank",
|
|
435
|
+
rel: "noreferrer",
|
|
436
|
+
onContextMenu: onBalanceContextMenu,
|
|
437
|
+
children: valueChildren,
|
|
438
|
+
}),
|
|
439
|
+
];
|
|
440
|
+
return jsx.jsx("div", {
|
|
441
|
+
className: "balance-footer",
|
|
442
|
+
onContextMenu: onBalanceContextMenu,
|
|
443
|
+
children: inner,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
|
|
448
|
+
// ─── 关闭按钮:侧边栏底部,设置上方 ───────────────────────────────────
|
|
449
|
+
function ShutdownButton({ wide }) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function UsageCostLine({ useProjection, sessionId }) {
|
|
454
|
+
const p = usePricing();
|
|
455
|
+
const model = useCurrentModel();
|
|
456
|
+
const usage = typeof useProjection === "function" ? useProjection("tokenUsage") : undefined;
|
|
457
|
+
const [splitUsage, setSplitUsage] = react.useState(null);
|
|
458
|
+
const [subagentUsage, setSubagentUsage] = react.useState(null);
|
|
459
|
+
const [splitReady, setSplitReady] = react.useState(false);
|
|
460
|
+
|
|
461
|
+
// 会话 token 总量(未缓存输入 + 缓存读/写 + 输出);为 0 时代表还没有消耗。
|
|
462
|
+
const usageKey = usage
|
|
463
|
+
? (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
464
|
+
: 0;
|
|
465
|
+
|
|
466
|
+
// 解析本会话使用的模型,并按模型刷新定价(/balance-api?model=...)
|
|
467
|
+
// 依赖 [sessionId, usageKey]:切对话或会话内容推进(新消息)都会触发;若还没解析到模型则总是重试。
|
|
468
|
+
react.useEffect(() => {
|
|
469
|
+
let disposed = false;
|
|
470
|
+
resolveSessionModel(sessionId).then((resolved) => {
|
|
471
|
+
if (disposed || resolved === "") return;
|
|
472
|
+
fetchBalanceData().catch(() => {});
|
|
473
|
+
});
|
|
474
|
+
return () => {
|
|
475
|
+
disposed = true;
|
|
476
|
+
};
|
|
477
|
+
}, [sessionId, usageKey]);
|
|
478
|
+
|
|
479
|
+
// 会话 token 总量变化(新消息/新消耗)时,拉取服务端分时段 usage,跨时段精确计费
|
|
480
|
+
// 同时"对话完成"即刷新一次余额(token 变化 = 一条新回复落库)
|
|
481
|
+
react.useEffect(() => {
|
|
482
|
+
if (usageKey === 0) return;
|
|
483
|
+
let disposed = false;
|
|
484
|
+
fetchSessionUsage(sessionId).then((u) => {
|
|
485
|
+
if (disposed) return;
|
|
486
|
+
if (u) {
|
|
487
|
+
setSplitUsage(u.usage);
|
|
488
|
+
setSubagentUsage(u.subagentUsage);
|
|
489
|
+
}
|
|
490
|
+
setSplitReady(true);
|
|
491
|
+
});
|
|
492
|
+
fetchBalanceData().catch(() => {});
|
|
493
|
+
return () => {
|
|
494
|
+
disposed = true;
|
|
495
|
+
};
|
|
496
|
+
}, [sessionId, usageKey]);
|
|
497
|
+
|
|
498
|
+
if (!usage) return null;
|
|
499
|
+
|
|
500
|
+
const uncachedInput = usage.uncachedInputTokens ?? 0;
|
|
501
|
+
const cacheRead = usage.cacheReadTokens ?? 0;
|
|
502
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
503
|
+
const output = usage.outputTokens ?? 0;
|
|
504
|
+
// 新增内容 = 未缓存输入 + 输出(不含缓存重读)
|
|
505
|
+
const newTokens = uncachedInput + output;
|
|
506
|
+
// 计费总量 = 未缓存输入 + 缓存重读/写入 + 输出,与 DeepSeek 账单口径一致
|
|
507
|
+
const billedTotal = uncachedInput + cacheRead + cacheWrite + output;
|
|
508
|
+
if (newTokens === 0 && billedTotal === 0) return null;
|
|
509
|
+
|
|
510
|
+
// 金额:优先用服务端分时段 usage(高峰桶×2+空闲桶);未就绪时按当前时段价估算
|
|
511
|
+
const split = splitReady && splitUsage ? splitUsage : null;
|
|
512
|
+
const cost = split ? computeSplitCost(split, p) : computeCost(usage, periodAdjustedPricing(p));
|
|
513
|
+
const peakTokens = split ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
|
|
514
|
+
const offpeakTokens = split ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
|
|
515
|
+
|
|
516
|
+
// 子代理消耗(与主会话分开,同样跨时段精确计费)
|
|
517
|
+
const hasSubagent = splitReady && subagentUsage && subagentUsage.totalTokens > 0;
|
|
518
|
+
const subCost = hasSubagent ? computeSplitCost(subagentUsage, p) : 0;
|
|
519
|
+
const subNewTokens = hasSubagent
|
|
520
|
+
? (subagentUsage.inputTokens ?? 0) + (subagentUsage.outputTokens ?? 0)
|
|
521
|
+
: 0;
|
|
522
|
+
const subCount = hasSubagent ? (subagentUsage.sessions ?? 0) : 0;
|
|
523
|
+
|
|
524
|
+
const detailLines = [
|
|
525
|
+
"模型:" + (model || "未知(按 deepseek-v4-flash 价估算)"),
|
|
526
|
+
"新增:" + formatTokens(newTokens) + " tokens(未缓存输入 " + formatTokens(uncachedInput) + " + 输出 " + formatTokens(output) + ")",
|
|
527
|
+
"计费:" + formatTokens(billedTotal) + " tokens(含缓存重读 " + formatTokens(cacheRead) + "、缓存写入 " + formatTokens(cacheWrite) + ")",
|
|
528
|
+
];
|
|
529
|
+
if (split) {
|
|
530
|
+
detailLines.push(
|
|
531
|
+
"时段拆分:高峰 " + formatTokens(peakTokens) + " tokens + 空闲 " + formatTokens(offpeakTokens) + " tokens",
|
|
532
|
+
"估算金额:¥" + cost.toFixed(4) + "(高峰桶按目录价×2、空闲桶按目录价)",
|
|
533
|
+
);
|
|
534
|
+
} else {
|
|
535
|
+
detailLines.push("估算金额:¥" + cost.toFixed(4) + "(按当前时段价估算)");
|
|
536
|
+
}
|
|
537
|
+
if (hasSubagent) {
|
|
538
|
+
const subPeak = subagentUsage.peak.inputTokens + subagentUsage.peak.cacheReadTokens + subagentUsage.peak.cacheWriteTokens + subagentUsage.peak.outputTokens;
|
|
539
|
+
const subOff = subagentUsage.offpeak.inputTokens + subagentUsage.offpeak.cacheReadTokens + subagentUsage.offpeak.cacheWriteTokens + subagentUsage.offpeak.outputTokens;
|
|
540
|
+
detailLines.push(
|
|
541
|
+
"子代理消耗:新增 " + formatTokens(subNewTokens) + " tokens、计费 " + formatTokens(subagentUsage.totalTokens) + " tokens(" + subCount + " 个子代理)",
|
|
542
|
+
"子代理金额:¥" + subCost.toFixed(4) + "(高峰桶×2、空闲桶按目录价)",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const children = [
|
|
546
|
+
jsx.jsx("span", { children: "本会话消耗" }),
|
|
547
|
+
jsx.jsx("span", { children: jsx.jsx("strong", { children: formatTokens(newTokens) + " tokens" }) }),
|
|
548
|
+
];
|
|
549
|
+
if (billedTotal > newTokens) {
|
|
550
|
+
children.push(jsx.jsx("span", { children: "计费 " + formatTokens(billedTotal) }));
|
|
551
|
+
}
|
|
552
|
+
children.push(jsx.jsxs("span", { children: ["≈ ", jsx.jsx("strong", { children: "¥" + cost.toFixed(4) })] }));
|
|
553
|
+
if (hasSubagent) {
|
|
554
|
+
children.push(jsx.jsx("span", { children: "子代理 +" + formatTokens(subNewTokens) + " ≈ ¥" + subCost.toFixed(4) }));
|
|
555
|
+
}
|
|
556
|
+
return jsx.jsx(primitives.Tooltip, {
|
|
557
|
+
label: detailLines.join("\n"),
|
|
558
|
+
side: "top",
|
|
559
|
+
delayMs: 300,
|
|
560
|
+
children: jsx.jsxs("div", {
|
|
561
|
+
className: "balance-usage-line",
|
|
562
|
+
children,
|
|
563
|
+
}),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─── 插件主体 ───────────────────────────────────────────────────────────
|
|
568
|
+
const inject = ["slots", "sessions"];
|
|
569
|
+
|
|
570
|
+
function apply(ctx) {
|
|
571
|
+
// 拿到 sessions 服务,用于解析当前会话使用的模型(按模型取价)
|
|
572
|
+
sessionsFace = ctx.sessions;
|
|
573
|
+
// 余额显示:展开态底部胶囊(order 20 排在市场 10 之后、紧挨设置);收起态悬浮 FAB
|
|
574
|
+
ctx.slots.inject("sidebar.footer.action", () =>
|
|
575
|
+
ctx.slots.register(
|
|
576
|
+
{
|
|
577
|
+
name: "sidebar.footer.action",
|
|
578
|
+
id: "balance",
|
|
579
|
+
order: 20,
|
|
580
|
+
},
|
|
581
|
+
BalanceFooter,
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
// 关闭按钮:显示在设置上方、余额右侧
|
|
587
|
+
ctx.slots.inject("sidebar.footer.action", () =>
|
|
588
|
+
ctx.slots.register(
|
|
589
|
+
{
|
|
590
|
+
name: "sidebar.footer.action",
|
|
591
|
+
id: "shutdown",
|
|
592
|
+
order: 10,
|
|
593
|
+
},
|
|
594
|
+
ShutdownButton,
|
|
595
|
+
),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
// 对话统计区(对话框下方)的会话消耗行
|
|
599
|
+
ctx.slots.inject("conversation.composer.dock", () =>
|
|
600
|
+
ctx.slots.register(
|
|
601
|
+
{
|
|
602
|
+
name: "conversation.composer.dock",
|
|
603
|
+
id: "balance-usage",
|
|
604
|
+
order: 30,
|
|
605
|
+
},
|
|
606
|
+
UsageCostLine,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
exports.apply = apply;
|
|
612
|
+
exports.inject = inject;
|
|
613
|
+
return module.exports;
|
|
614
|
+
},
|
|
615
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// dsh 插件:余额与当前会话消耗
|
|
2
|
+
// 服务端:注册 /balance 命令 + /balance-api HTTP 接口(供 Web UI 插件直接渲染)
|
|
3
|
+
import { inspectApiRemoteSession } from "@deepseek-ai/dsh-api-remotes";
|
|
4
|
+
|
|
5
|
+
export const name = "balance-dsh";
|
|
6
|
+
|
|
7
|
+
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
8
|
+
|
|
9
|
+
// DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
|
|
10
|
+
// V4 峰谷定价之空闲时段价;高峰时段(每日 9:00 / 14:00)价格为下表 2 倍,
|
|
11
|
+
// 可在 config.peak: true 时统一翻倍,或在 config.price 中逐项覆盖。
|
|
12
|
+
// V4-Flash 空闲:输入未命中 1.5 / 命中 0.05 / 输出 4.5
|
|
13
|
+
// V4-Pro 空闲:输入未命中 4.5 / 命中 0.15 / 输出 13.5
|
|
14
|
+
const MODEL_PRICING = {
|
|
15
|
+
"deepseek-v4-flash": { inputCnyPerMillion: 1.5, cacheReadCnyPerMillion: 0.05, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 4.5 },
|
|
16
|
+
"deepseek-v4-pro": { inputCnyPerMillion: 4.5, cacheReadCnyPerMillion: 0.15, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 13.5 },
|
|
17
|
+
// 旧模型兜底(V3.2 时代目录价)
|
|
18
|
+
"deepseek-chat": { inputCnyPerMillion: 2, cacheReadCnyPerMillion: 0.5, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 3 },
|
|
19
|
+
"deepseek-reasoner": { inputCnyPerMillion: 4, cacheReadCnyPerMillion: 1, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 16 },
|
|
20
|
+
};
|
|
21
|
+
const FALLBACK_PRICING = MODEL_PRICING["deepseek-v4-flash"];
|
|
22
|
+
|
|
23
|
+
// 高峰/空闲时段(V4 峰谷定价,2026-08-17 生效):高峰价 = 空闲价 × 2。
|
|
24
|
+
// 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
|
|
25
|
+
// 可用 config.peakWindows 覆盖小时窗口(如 [{start:9,end:12},{start:14,end:18}]),
|
|
26
|
+
// 可用 config.peakWeekdays 覆盖周几(1=周一 … 5=周五,默认 [1,2,3,4,5],0/6=周末)。
|
|
27
|
+
const DEFAULT_PEAK_WINDOWS = [
|
|
28
|
+
{ start: 9, end: 12 },
|
|
29
|
+
{ start: 14, end: 18 },
|
|
30
|
+
];
|
|
31
|
+
const DEFAULT_PEAK_WEEKDAYS = [1, 2, 3, 4, 5];
|
|
32
|
+
|
|
33
|
+
/** 判断某时间戳(毫秒)是否处于高峰时段(按北京时间 UTC+8 计算,含周几判断)。 */
|
|
34
|
+
function isPeakAt(timeMs, config) {
|
|
35
|
+
const windows = (Array.isArray(config?.peakWindows) && config.peakWindows.length > 0
|
|
36
|
+
? config.peakWindows
|
|
37
|
+
: DEFAULT_PEAK_WINDOWS
|
|
38
|
+
).map((w) => ({ start: Number(w.start), end: Number(w.end) }));
|
|
39
|
+
const weekdays = Array.isArray(config?.peakWeekdays) && config.peakWeekdays.length > 0
|
|
40
|
+
? config.peakWeekdays.map(Number)
|
|
41
|
+
: DEFAULT_PEAK_WEEKDAYS;
|
|
42
|
+
const d = new Date(Number(timeMs) + 8 * 60 * 60 * 1000); // 北京时间
|
|
43
|
+
const hour = d.getUTCHours();
|
|
44
|
+
const weekday = d.getUTCDay(); // 0=周日 … 6=周六
|
|
45
|
+
return weekdays.includes(weekday) && windows.some((w) => hour >= w.start && hour < w.end);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。 */
|
|
49
|
+
function modelKey(model) {
|
|
50
|
+
const m = String(model || "").toLowerCase();
|
|
51
|
+
if (m.includes("v4-pro") || m.includes("v4pro")) return "deepseek-v4-pro";
|
|
52
|
+
if (m.includes("v4-flash") || m.includes("v4flash")) return "deepseek-v4-flash";
|
|
53
|
+
if (m.includes("reasoner")) return "deepseek-reasoner";
|
|
54
|
+
if (m.includes("chat")) return "deepseek-chat";
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 解析价格:模型基价 + config.price 覆盖,config.peak: true 时按高峰价翻倍。 */
|
|
59
|
+
function resolvePricing(config, model = "") {
|
|
60
|
+
const base = MODEL_PRICING[modelKey(model)] ?? FALLBACK_PRICING;
|
|
61
|
+
const price = config?.price ?? {};
|
|
62
|
+
const multiplier = config?.peak === true ? 2 : 1;
|
|
63
|
+
const pick = (name, fallback) => Number(price[name] ?? fallback) * multiplier;
|
|
64
|
+
return {
|
|
65
|
+
inputCnyPerMillion: pick("inputCnyPerMillion", base.inputCnyPerMillion),
|
|
66
|
+
cacheReadCnyPerMillion: pick("cacheReadCnyPerMillion", base.cacheReadCnyPerMillion),
|
|
67
|
+
cacheWriteCnyPerMillion: pick("cacheWriteCnyPerMillion", base.cacheWriteCnyPerMillion),
|
|
68
|
+
outputCnyPerMillion: pick("outputCnyPerMillion", base.outputCnyPerMillion),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 从会话中识别实际使用的模型。
|
|
73
|
+
* 优先用 DSH 会话的 requestHeader()/requestContext() 折叠结果(即官方 session.models 取 current 的同一来源),
|
|
74
|
+
* 取不到再回退遍历 session.events 找 request/header / request/context 事件。 */
|
|
75
|
+
function detectModel(session) {
|
|
76
|
+
if (!session) return "";
|
|
77
|
+
// 1) DSH 官方折叠 API:requestHeader().config.model 或 requestContext().model
|
|
78
|
+
if (typeof session.requestHeader === "function") {
|
|
79
|
+
const header = session.requestHeader();
|
|
80
|
+
const m = header?.config?.model;
|
|
81
|
+
if (typeof m === "string" && m !== "") return m;
|
|
82
|
+
}
|
|
83
|
+
if (typeof session.requestContext === "function") {
|
|
84
|
+
const ctx = session.requestContext();
|
|
85
|
+
const m = ctx?.model;
|
|
86
|
+
if (typeof m === "string" && m !== "") return m;
|
|
87
|
+
}
|
|
88
|
+
// 2) 兜底:遍历事件
|
|
89
|
+
let model = "";
|
|
90
|
+
for (const event of session?.events ?? []) {
|
|
91
|
+
if (event.type === "request/header") {
|
|
92
|
+
const m = event.data?.header?.config?.model;
|
|
93
|
+
if (typeof m === "string" && m !== "") model = m;
|
|
94
|
+
} else if (event.type === "request/context") {
|
|
95
|
+
const m = event.data?.model;
|
|
96
|
+
if (typeof m === "string" && m !== "") model = m;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return model;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function resolveApiKey(ctx, config) {
|
|
103
|
+
if (typeof config?.apiKey === "string" && config.apiKey.trim() !== "") {
|
|
104
|
+
return config.apiKey.trim();
|
|
105
|
+
}
|
|
106
|
+
const credentials = ctx.get("credentials");
|
|
107
|
+
if (credentials !== void 0) {
|
|
108
|
+
const resolved = await credentials.resolve("DEEPSEEK_API_KEY");
|
|
109
|
+
if (resolved !== void 0 && resolved.value.length > 0) {
|
|
110
|
+
return resolved.value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (process.env.DEEPSEEK_API_KEY && process.env.DEEPSEEK_API_KEY.trim() !== "") {
|
|
114
|
+
return process.env.DEEPSEEK_API_KEY.trim();
|
|
115
|
+
}
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchBalance(apiKey, signal) {
|
|
120
|
+
const response = await fetch(DEEPSEEK_BALANCE_URL, {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${apiKey}`,
|
|
123
|
+
Accept: "application/json",
|
|
124
|
+
},
|
|
125
|
+
signal,
|
|
126
|
+
});
|
|
127
|
+
if (response.status === 401) {
|
|
128
|
+
throw new Error("DeepSeek API Key 无效或未授权");
|
|
129
|
+
}
|
|
130
|
+
if (response.status === 402) {
|
|
131
|
+
throw new Error("DeepSeek 账户余额不足或 API 未开通");
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`余额查询失败:HTTP ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
return response.json();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeBalance(data) {
|
|
140
|
+
if (data?.is_available === false) {
|
|
141
|
+
return {
|
|
142
|
+
is_available: false,
|
|
143
|
+
currency: "",
|
|
144
|
+
total_balance: "0.00",
|
|
145
|
+
granted_balance: "0.00",
|
|
146
|
+
topped_up_balance: "0.00",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const infos = Array.isArray(data?.balance_infos) ? data.balance_infos : [];
|
|
150
|
+
if (infos.length === 0) {
|
|
151
|
+
return {
|
|
152
|
+
is_available: data?.is_available !== false,
|
|
153
|
+
currency: "",
|
|
154
|
+
total_balance: "0.00",
|
|
155
|
+
granted_balance: "0.00",
|
|
156
|
+
topped_up_balance: "0.00",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const info = infos[0];
|
|
160
|
+
return {
|
|
161
|
+
is_available: data?.is_available !== false,
|
|
162
|
+
currency: info.currency || "",
|
|
163
|
+
total_balance: info.total_balance ?? "0.00",
|
|
164
|
+
granted_balance: info.granted_balance ?? "0.00",
|
|
165
|
+
topped_up_balance: info.topped_up_balance ?? "0.00",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatBalance(balance) {
|
|
170
|
+
if (!balance.is_available) {
|
|
171
|
+
return "余额不可用:账户当前没有可用余额。";
|
|
172
|
+
}
|
|
173
|
+
if (!balance.total_balance || balance.total_balance === "0.00") {
|
|
174
|
+
return "余额查询成功,但暂无余额明细。";
|
|
175
|
+
}
|
|
176
|
+
return `余额(${balance.currency || "CNY"}):${balance.total_balance}\n 充值余额:${balance.topped_up_balance}\n 赠送余额:${balance.granted_balance}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 从事件数组汇总 usage(跨时段分桶)。供 live 会话与持久化(子代理)会话复用。 */
|
|
180
|
+
function sumEventsUsage(events, config) {
|
|
181
|
+
const latestByStep = new Map();
|
|
182
|
+
for (const event of events) {
|
|
183
|
+
if (event.type !== "assistant/message") continue;
|
|
184
|
+
const usage = event.data?.usage;
|
|
185
|
+
if (usage === void 0 || usage === null) continue;
|
|
186
|
+
const turn = event.data?.turn;
|
|
187
|
+
const step = event.data?.step;
|
|
188
|
+
if (turn === void 0 || step === void 0) continue;
|
|
189
|
+
// 事件带毫秒时间戳(event.time),用于按高峰/空闲时段分桶
|
|
190
|
+
latestByStep.set(`${turn}/${step}`, { usage, time: event.time ?? Date.now() });
|
|
191
|
+
}
|
|
192
|
+
const add = (bucket, usage) => {
|
|
193
|
+
bucket.inputTokens += usage.inputTokens ?? 0;
|
|
194
|
+
bucket.outputTokens += usage.outputTokens ?? 0;
|
|
195
|
+
bucket.cacheReadTokens += usage.cacheReadTokens ?? 0;
|
|
196
|
+
bucket.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
|
|
197
|
+
};
|
|
198
|
+
const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
199
|
+
const peak = empty();
|
|
200
|
+
const offpeak = empty();
|
|
201
|
+
for (const { usage, time } of latestByStep.values()) {
|
|
202
|
+
add(isPeakAt(time, config) ? peak : offpeak, usage);
|
|
203
|
+
}
|
|
204
|
+
const inputTokens = peak.inputTokens + offpeak.inputTokens;
|
|
205
|
+
const outputTokens = peak.outputTokens + offpeak.outputTokens;
|
|
206
|
+
const cacheReadTokens = peak.cacheReadTokens + offpeak.cacheReadTokens;
|
|
207
|
+
const cacheWriteTokens = peak.cacheWriteTokens + offpeak.cacheWriteTokens;
|
|
208
|
+
const billedInputTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
209
|
+
return {
|
|
210
|
+
inputTokens,
|
|
211
|
+
outputTokens,
|
|
212
|
+
cacheReadTokens,
|
|
213
|
+
cacheWriteTokens,
|
|
214
|
+
billedInputTokens,
|
|
215
|
+
totalTokens: billedInputTokens + outputTokens,
|
|
216
|
+
peak,
|
|
217
|
+
offpeak,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sumSessionUsage(session, config) {
|
|
222
|
+
if (session == null) return null;
|
|
223
|
+
return sumEventsUsage(session.events ?? [], config);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function estimateCost(usage, pricing) {
|
|
227
|
+
const costOf = (u) =>
|
|
228
|
+
(u.inputTokens * pricing.inputCnyPerMillion +
|
|
229
|
+
u.cacheReadTokens * pricing.cacheReadCnyPerMillion +
|
|
230
|
+
u.cacheWriteTokens * pricing.cacheWriteCnyPerMillion) /
|
|
231
|
+
1_000_000 +
|
|
232
|
+
(u.outputTokens * pricing.outputCnyPerMillion) / 1_000_000;
|
|
233
|
+
// 跨时段精确计费:高峰桶按高峰价(= 目录价 × 2)、空闲桶按目录价。
|
|
234
|
+
// 注意:若 config.peak:true(resolvePricing 已统一翻倍),高峰桶会再乘一次,两种方式不要同时用。
|
|
235
|
+
return costOf(usage.peak) * 2 + costOf(usage.offpeak);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
|
|
239
|
+
* @param ctx DSH 上下文(用于 subagents 服务 + inspectApiRemoteSession)
|
|
240
|
+
* @param sessionId 父会话 id
|
|
241
|
+
* @param config 插件配置
|
|
242
|
+
* 说明:子代理会话结束后会被从 live 会话 store 移除,故不能用 store.list() 枚举;
|
|
243
|
+
* 这里用 subagents 服务的 listDescendants(含已结束/嵌套子代理)取后代 id,
|
|
244
|
+
* 再对每个子代理用 inspectApiRemoteSession 读取持久化事件计算 usage。无子代理返回 {usage:null,debug}。 */
|
|
245
|
+
async function sumSubagentUsage(ctx, sessionId, config) {
|
|
246
|
+
if (ctx == null || sessionId === "") return { usage: null };
|
|
247
|
+
// subagents 需通过 ctx.inject(["subagents"]) 注入后才可访问(见 apply 里的 webServer 注入列表)。
|
|
248
|
+
const subagentsSvc = ctx.subagents != null ? ctx.subagents : (typeof ctx.get === "function" ? ctx.get("subagents") : void 0);
|
|
249
|
+
const listDescendants = subagentsSvc != null && typeof subagentsSvc.listDescendants === "function" ? subagentsSvc.listDescendants.bind(subagentsSvc) : void 0;
|
|
250
|
+
// 1) 用 subagents.listDescendants 一次取完整后代树(含编号/层级/已结束)
|
|
251
|
+
let descendants = [];
|
|
252
|
+
if (listDescendants != null) {
|
|
253
|
+
try {
|
|
254
|
+
descendants = await listDescendants(sessionId);
|
|
255
|
+
} catch {
|
|
256
|
+
descendants = [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const childIds = new Set();
|
|
260
|
+
for (const e of descendants) {
|
|
261
|
+
if (e == null || typeof e !== "object") continue;
|
|
262
|
+
if (typeof e.id !== "string" || e.id === "") continue;
|
|
263
|
+
childIds.add(e.id);
|
|
264
|
+
}
|
|
265
|
+
if (childIds.size === 0) return { usage: null };
|
|
266
|
+
|
|
267
|
+
// 2) 对每个子代理读取持久化事件并求和
|
|
268
|
+
const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
269
|
+
const peak = empty();
|
|
270
|
+
const offpeak = empty();
|
|
271
|
+
let count = 0;
|
|
272
|
+
for (const id of childIds) {
|
|
273
|
+
try {
|
|
274
|
+
const inspected = await inspectApiRemoteSession(ctx, id);
|
|
275
|
+
const events = Array.isArray(inspected?.events) ? inspected.events : [];
|
|
276
|
+
const u = sumEventsUsage(events, config);
|
|
277
|
+
if (u === null || u === void 0) continue;
|
|
278
|
+
count++;
|
|
279
|
+
peak.inputTokens += u.peak.inputTokens;
|
|
280
|
+
peak.outputTokens += u.peak.outputTokens;
|
|
281
|
+
peak.cacheReadTokens += u.peak.cacheReadTokens;
|
|
282
|
+
peak.cacheWriteTokens += u.peak.cacheWriteTokens;
|
|
283
|
+
offpeak.inputTokens += u.offpeak.inputTokens;
|
|
284
|
+
offpeak.outputTokens += u.offpeak.outputTokens;
|
|
285
|
+
offpeak.cacheReadTokens += u.offpeak.cacheReadTokens;
|
|
286
|
+
offpeak.cacheWriteTokens += u.offpeak.cacheWriteTokens;
|
|
287
|
+
} catch {
|
|
288
|
+
// 该子代理读取失败则跳过
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (count === 0) return { usage: null };
|
|
292
|
+
const inputTokens = peak.inputTokens + offpeak.inputTokens;
|
|
293
|
+
const outputTokens = peak.outputTokens + offpeak.outputTokens;
|
|
294
|
+
const cacheReadTokens = peak.cacheReadTokens + offpeak.cacheReadTokens;
|
|
295
|
+
const cacheWriteTokens = peak.cacheWriteTokens + offpeak.cacheWriteTokens;
|
|
296
|
+
const billedInputTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
297
|
+
const usage = {
|
|
298
|
+
inputTokens,
|
|
299
|
+
outputTokens,
|
|
300
|
+
cacheReadTokens,
|
|
301
|
+
cacheWriteTokens,
|
|
302
|
+
billedInputTokens,
|
|
303
|
+
totalTokens: billedInputTokens + outputTokens,
|
|
304
|
+
peak,
|
|
305
|
+
offpeak,
|
|
306
|
+
sessions: count,
|
|
307
|
+
};
|
|
308
|
+
return { usage };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function formatUsage(usage, pricing, model = "") {
|
|
312
|
+
const cost = estimateCost(usage, pricing);
|
|
313
|
+
const newTokens = usage.inputTokens + usage.outputTokens;
|
|
314
|
+
const peakTokens = usage.peak.inputTokens + usage.peak.cacheReadTokens + usage.peak.cacheWriteTokens + usage.peak.outputTokens;
|
|
315
|
+
const offpeakTokens = usage.offpeak.inputTokens + usage.offpeak.cacheReadTokens + usage.offpeak.cacheWriteTokens + usage.offpeak.outputTokens;
|
|
316
|
+
const modelLine = model === "" ? "" : `(模型 ${model})`;
|
|
317
|
+
const lines = [
|
|
318
|
+
`当前会话消耗${modelLine}:`,
|
|
319
|
+
` 新增 tokens:${newTokens}(未缓存输入 ${usage.inputTokens} + 输出 ${usage.outputTokens})`,
|
|
320
|
+
` 计费 tokens:${usage.totalTokens}(含缓存重读 ${usage.cacheReadTokens}、缓存写入 ${usage.cacheWriteTokens})`,
|
|
321
|
+
` - 未缓存输入:${usage.inputTokens}`,
|
|
322
|
+
` - 缓存读取:${usage.cacheReadTokens}`,
|
|
323
|
+
` - 缓存写入:${usage.cacheWriteTokens}`,
|
|
324
|
+
` - 输出 tokens:${usage.outputTokens}`,
|
|
325
|
+
` 估算消耗:¥${cost.toFixed(4)}(按 ${modelKey(model) || "deepseek-v4-flash"} 目录价估算,实际以账单为准)`,
|
|
326
|
+
` 时段拆分:高峰 ${peakTokens} tokens、空闲 ${offpeakTokens} tokens(跨时段按各自时段价计费)`,
|
|
327
|
+
];
|
|
328
|
+
return lines.join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function writeJson(res, status, payload) {
|
|
332
|
+
const body = JSON.stringify(payload);
|
|
333
|
+
res.writeHead(status, {
|
|
334
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
335
|
+
"Cache-Control": "no-store",
|
|
336
|
+
});
|
|
337
|
+
res.end(body);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function apply(ctx, config = {}) {
|
|
341
|
+
// 1) /balance 命令:保留命令行查询入口
|
|
342
|
+
ctx.inject(["commands"], (commandCtx) => {
|
|
343
|
+
commandCtx.commands.register({
|
|
344
|
+
name: "balance",
|
|
345
|
+
description: "查询 DeepSeek 余额与当前会话消耗",
|
|
346
|
+
handler: async ({ agent, signal }) => {
|
|
347
|
+
const apiKey = await resolveApiKey(ctx, config);
|
|
348
|
+
if (apiKey === "") {
|
|
349
|
+
return {
|
|
350
|
+
kind: "error",
|
|
351
|
+
text: "未找到 DeepSeek API Key,请设置环境变量 DEEPSEEK_API_KEY 后重试。",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const usage = sumSessionUsage(agent.session, config);
|
|
356
|
+
const model = detectModel(agent.session);
|
|
357
|
+
const pricing = resolvePricing(config, model);
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const data = await fetchBalance(apiKey, signal);
|
|
361
|
+
const balanceText = formatBalance(normalizeBalance(data));
|
|
362
|
+
return {
|
|
363
|
+
kind: "success",
|
|
364
|
+
text: `${balanceText}\n\n${formatUsage(usage, pricing, model)}`,
|
|
365
|
+
};
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
368
|
+
return {
|
|
369
|
+
kind: "error",
|
|
370
|
+
text: `${message}\n\n${formatUsage(usage, pricing, model)}`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// 2) /balance-api HTTP 接口:供客户端插件直接渲染余额(不经过命令,不污染会话日志)
|
|
378
|
+
ctx.inject(["webServer", "subagents"], (httpCtx) => {
|
|
379
|
+
httpCtx.webServer.register({
|
|
380
|
+
kind: "exact",
|
|
381
|
+
path: "/balance-api",
|
|
382
|
+
handler: async (req, res) => {
|
|
383
|
+
if (req.method !== "GET") {
|
|
384
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// 客户端把当前会话使用的模型通过 ?model= 传入,服务端按模型取价;
|
|
388
|
+
// 可选 ?session= 传入会话 id,服务端返回该会话的分时段 usage(跨时段精确计费用)
|
|
389
|
+
let model = "";
|
|
390
|
+
let sessionParam = "";
|
|
391
|
+
try {
|
|
392
|
+
const search = new URL(req.url, "http://localhost").searchParams;
|
|
393
|
+
model = search.get("model") ?? "";
|
|
394
|
+
sessionParam = search.get("session") ?? "";
|
|
395
|
+
} catch {
|
|
396
|
+
model = "";
|
|
397
|
+
sessionParam = "";
|
|
398
|
+
}
|
|
399
|
+
const apiKey = await resolveApiKey(ctx, config);
|
|
400
|
+
if (apiKey === "") {
|
|
401
|
+
writeJson(res, 200, { ok: false, error: "未设置 DEEPSEEK_API_KEY" });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
|
|
405
|
+
let usage = null;
|
|
406
|
+
// 子代理后代会话的 usage 汇总(与主会话分开)
|
|
407
|
+
let subagentUsage = null;
|
|
408
|
+
// 从会话事件识别实际使用模型(客户端未传 ?model= 时兜底)
|
|
409
|
+
let detectedModel = "";
|
|
410
|
+
if (sessionParam !== "") {
|
|
411
|
+
try {
|
|
412
|
+
const sessions = ctx.get("sessions");
|
|
413
|
+
const session = sessions && typeof sessions.get === "function" ? sessions.get(sessionParam) : void 0;
|
|
414
|
+
if (session !== void 0) {
|
|
415
|
+
usage = sumSessionUsage(session, config);
|
|
416
|
+
detectedModel = detectModel(session);
|
|
417
|
+
}
|
|
418
|
+
// 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到
|
|
419
|
+
const subRes = await sumSubagentUsage(httpCtx, sessionParam, config);
|
|
420
|
+
subagentUsage = subRes.usage;
|
|
421
|
+
} catch {
|
|
422
|
+
usage = null;
|
|
423
|
+
subagentUsage = null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// 客户端优先用自己解析的 model;为空则回退到服务端 detectModel
|
|
427
|
+
const effectiveModel = model || detectedModel;
|
|
428
|
+
try {
|
|
429
|
+
const data = await fetchBalance(apiKey);
|
|
430
|
+
writeJson(res, 200, {
|
|
431
|
+
ok: true,
|
|
432
|
+
balance: normalizeBalance(data),
|
|
433
|
+
pricing: resolvePricing(config, effectiveModel),
|
|
434
|
+
model: effectiveModel,
|
|
435
|
+
usage,
|
|
436
|
+
subagentUsage,
|
|
437
|
+
});
|
|
438
|
+
} catch (error) {
|
|
439
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
440
|
+
writeJson(res, 200, { ok: false, error: message });
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
httpCtx.webServer.register({
|
|
447
|
+
kind: "exact",
|
|
448
|
+
path: "/shutdown-api",
|
|
449
|
+
handler: async (req, res) => {
|
|
450
|
+
if (req.method !== "POST") {
|
|
451
|
+
writeJson(res, 405, { ok: false, error: "method not allowed" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
writeJson(res, 200, { ok: true });
|
|
455
|
+
const exit = ctx.get("appExit");
|
|
456
|
+
setTimeout(() => {
|
|
457
|
+
if (typeof exit === "function") exit(0);
|
|
458
|
+
else process.exit(0);
|
|
459
|
+
}, 150);
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "balance-dsh",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
|
|
5
|
+
"author": "Rannist",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"dsh",
|
|
15
|
+
"dsh-plugin",
|
|
16
|
+
"deepseek",
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"balance",
|
|
19
|
+
"usage",
|
|
20
|
+
"cost",
|
|
21
|
+
"token",
|
|
22
|
+
"billing"
|
|
23
|
+
],
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"inject": [
|
|
31
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
32
|
+
],
|
|
33
|
+
"immediately": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/Rannist/balance-dsh"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/Rannist/balance-dsh#readme",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"files": [
|
|
49
|
+
"lib",
|
|
50
|
+
"cordis.patch.yml",
|
|
51
|
+
"README.md"
|
|
52
|
+
],
|
|
53
|
+
"dependencies": {}
|
|
54
|
+
}
|