dsh-plugin-opencode-usage 0.1.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 +159 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +601 -0
- package/lib/index.js +214 -0
- package/package.json +58 -0
- package/screenshot.png +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-plugin-opencode-usage contributors
|
|
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,159 @@
|
|
|
1
|
+
# dsh-plugin-opencode-usage
|
|
2
|
+
|
|
3
|
+
> OpenCode Go 订阅用量悬浮面板:在 DeepSeek Harness Web GUI **会话窗口左下角**显示订阅额度使用情况。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
- 在会话窗口左下角注册独立悬浮入口,不占用侧边栏入口,也不放入输入框,避免与其他插件冲突
|
|
12
|
+
- 点击弹出悬浮面板,展示 OpenCode Go 订阅的三个窗口:滚动用量、每周用量、每月用量
|
|
13
|
+
- 每张卡片显示:**已用百分比 / 剩余百分比 / 重置时间**
|
|
14
|
+
- 不展示具体美元金额,避免不同模型额度金额不同造成误导
|
|
15
|
+
- 面板以入口按钮为锚点:左边缘对齐按钮,底部在按钮上方 12px,随会话窗口/侧边栏变化自动调整
|
|
16
|
+
- 打开期间每 60s 自动刷新;支持 `Esc` 与点击面板外部关闭
|
|
17
|
+
- API key 只在 Harness host 进程内解析,不会下发浏览器
|
|
18
|
+
|
|
19
|
+
## 截图
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## 工作原理
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
DSH Web 浏览器
|
|
27
|
+
│ 1. 点击会话窗口左下角按钮
|
|
28
|
+
▼
|
|
29
|
+
GET /plugins/opencode-usage/stats
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
DSH Host 插件
|
|
33
|
+
│ 2. ctx.credentials.resolve("OPENCODE_GO_API_KEY")
|
|
34
|
+
│ 3. GET https://opencode.ai/zen/go/v1/usage
|
|
35
|
+
│ Authorization: Bearer <api-key>
|
|
36
|
+
│ 4. 透出 percent / remainingPercent / resetsAt
|
|
37
|
+
▼
|
|
38
|
+
返回 JSON,由 React 面板渲染
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
官方接口只返回 `percent` 与 `resetsAt`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"usage": {
|
|
46
|
+
"rolling": { "status": "ok", "percent": 30, "resetsAt": "..." },
|
|
47
|
+
"weekly": { "status": "ok", "percent": 19, "resetsAt": "..." },
|
|
48
|
+
"monthly": { "status": "ok", "percent": 9, "resetsAt": "..." }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
插件直接展示官方 API 返回的百分比,不做金额换算。
|
|
54
|
+
|
|
55
|
+
## 目录结构
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
dsh-plugin-opencode-usage/
|
|
59
|
+
├── cordis.patch.yml # bundle patch: 把插件插入 profile host 组合
|
|
60
|
+
├── lib/
|
|
61
|
+
│ ├── index.js # Host 半: 订阅额度 API 查询 + HTTP 路由
|
|
62
|
+
│ └── client.js # Client 半: 会话窗口左下角悬浮按钮 + 面板
|
|
63
|
+
├── LICENSE
|
|
64
|
+
├── package.json # dsh.bundle / dsh.client 元数据
|
|
65
|
+
└── README.md
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 额度口径(背景说明,不用于显示)
|
|
69
|
+
|
|
70
|
+
不同模型的额度金额不同(例如 V4 Pro 月度 $15、V4 Flash 月度 $30,
|
|
71
|
+
整体月上限 $60),因此插件只展示官方 API 返回的百分比,不做金额换算。
|
|
72
|
+
|
|
73
|
+
| 窗口 | 官方接口字段 | 说明 |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| 滚动用量 | `usage.rolling` | 5 小时滚动窗口百分比 |
|
|
76
|
+
| 每周用量 | `usage.weekly` | 每周窗口百分比 |
|
|
77
|
+
| 每月用量 | `usage.monthly` | 月度窗口百分比 |
|
|
78
|
+
|
|
79
|
+
官方 API 只返回 `percent` 与 `resetsAt`;插件同时给出 `remainingPercent = 100 - percent`。
|
|
80
|
+
|
|
81
|
+
## 安装
|
|
82
|
+
|
|
83
|
+
从 npm 安装(推荐):
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
dsh plugin --profile web add dsh-plugin-opencode-usage
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
也可以从 GitHub 仓库安装(无构建步骤,`lib/` 即源码,可直接引用):
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
dsh plugin --profile web add github:jiekesu967/dsh-plugin-opencode-usage
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
或者从本地目录安装:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
dsh plugin --profile web add ./dsh-plugin-opencode-usage
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
安装后 `dsh plugin` 会自动写入 web profile 的 `dependencies`,并把
|
|
102
|
+
`dsh-plugin-opencode-usage` 追加到 `dsh.profile.bundles`。重启 `dsh web`
|
|
103
|
+
并刷新页面后,会话窗口左下角会出现独立的 “OpenCode Go 用量” 悬浮入口。
|
|
104
|
+
|
|
105
|
+
## 配置
|
|
106
|
+
|
|
107
|
+
在 profile 的 `cordis.patch.yml` 覆盖 `opencode-usage` 条目:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
- id: opencode-usage
|
|
111
|
+
config:
|
|
112
|
+
# Harness credentials 文件中保存 API key 的变量名
|
|
113
|
+
credentialRef: OPENCODE_GO_API_KEY
|
|
114
|
+
|
|
115
|
+
# OpenCode Go 订阅接口
|
|
116
|
+
apiBaseUrl: https://opencode.ai/zen/go/v1
|
|
117
|
+
|
|
118
|
+
# host 成功结果缓存时间
|
|
119
|
+
cacheMs: 30000
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 凭据
|
|
123
|
+
|
|
124
|
+
在 `~/.dsh/.credentials.yaml` 中确认存在:
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
OPENCODE_GO_API_KEY: sk-...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
也可以通过 DSH Web 的 Models / Credentials 设置页写入。
|
|
131
|
+
|
|
132
|
+
## 入口与面板定位规则
|
|
133
|
+
|
|
134
|
+
入口注册在 `shell.overlay`,绝对定位于 AppFrame 的**会话中心列左下角**:
|
|
135
|
+
|
|
136
|
+
1. 按钮:会话中心列左侧内边距 `12px`、底部 `16px`
|
|
137
|
+
2. 面板:左边缘与按钮对齐,底部位于按钮上方 `12px`
|
|
138
|
+
3. 面板宽度最大 `392px`,右侧/顶部超出视口时自动回退
|
|
139
|
+
4. `ResizeObserver` 监听会话中心列,侧边栏展开/收起、详情面板开关、窗口缩放时实时跟随
|
|
140
|
+
|
|
141
|
+
## 已知限制
|
|
142
|
+
|
|
143
|
+
- 官方 usage 接口只返回百分比,不返回绝对金额;插件也只展示百分比
|
|
144
|
+
- 不同模型额度金额不同,因此不提供金额换算,避免误导
|
|
145
|
+
- 仅适用于 `web` profile;`headless` 下不注册路由
|
|
146
|
+
- `GET /plugins/opencode-usage/stats` **不做鉴权**,它继承 webserver 的绑定地址。
|
|
147
|
+
默认 profile 绑定 `127.0.0.1`,只有本机可访问;若你把 profile 的
|
|
148
|
+
`webServer.config.host` 改成 `0.0.0.0`,同一局域网内的任何人都能读到用量百分比。
|
|
149
|
+
API key 本身不会离开 host 进程(只作为请求头上游使用),所以泄露的是用量信息而非凭据。
|
|
150
|
+
介意的话请保持回环绑定,或在反向代理层限制该路径。
|
|
151
|
+
|
|
152
|
+
## 致谢 / Credits
|
|
153
|
+
|
|
154
|
+
- [DeepSeek Harness](https://github.com/deepseek-harness) — 本插件运行所基于的 Harness 框架
|
|
155
|
+
- [jiekesu967](https://github.com/jiekesu967) — 插件开发者
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-plugin-opencode-usage bundle patch:把插件挂进 profile 的 host 组合。
|
|
2
|
+
# Host 半解析 credentials(OPENCODE_GO_API_KEY),查询
|
|
3
|
+
# https://opencode.ai/zen/go/v1/usage,并把百分比换算为套餐总额/已用/剩余;
|
|
4
|
+
# 提供 /plugins/opencode-usage/stats 路由供浏览器面板读取。
|
|
5
|
+
- insert:
|
|
6
|
+
- id: opencode-usage
|
|
7
|
+
name: dsh-plugin-opencode-usage
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-opencode-usage:OpenCode Go 用量悬浮面板(Client 半,浏览器 bundle)。
|
|
3
|
+
*
|
|
4
|
+
* 注册 `shell.overlay` 列表槽位(id="opencode-usage"),入口按钮悬浮在
|
|
5
|
+
* 整个会话窗口的左下角(会话中心列底部),与输入框、侧边栏插件入口完全分离。
|
|
6
|
+
* 点击后弹出固定悬浮面板,展示订阅套餐的滚动 / 每周 / 每月:总额度、已用、
|
|
7
|
+
* 剩余、使用百分比与重置时间;面板打开期间每 60s 自动刷新。
|
|
8
|
+
*
|
|
9
|
+
* 纯 JS + React.createElement,与 team-studio 的 client bundle 同构,无构建步骤。
|
|
10
|
+
* @module dsh-plugin-opencode-usage/client
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: "dsh-plugin-opencode-usage",
|
|
15
|
+
factory: (require) => {
|
|
16
|
+
var module = { exports: {} };
|
|
17
|
+
var exports = module.exports;
|
|
18
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
19
|
+
var React = require("react");
|
|
20
|
+
|
|
21
|
+
var NS = "opencodeUsage";
|
|
22
|
+
var inject = ["slots", "locale"];
|
|
23
|
+
|
|
24
|
+
var REFRESH_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
var CSS = [
|
|
27
|
+
".ou_layer{z-index:2;pointer-events:auto;align-items:center;min-width:0;display:inline-flex;position:absolute;bottom:16px;left:12px}",
|
|
28
|
+
".ou_footerButtons{align-items:center;min-width:0;display:inline-flex}",
|
|
29
|
+
".ou_badge{height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:var(--dsw-alias-button-ghost-active-fill);border:none;border-radius:999px;align-items:center;gap:6px;padding:0 10px;font-family:inherit;font-size:13px;font-weight:500;line-height:20px;display:inline-flex;overflow:hidden}",
|
|
30
|
+
".ou_badge:hover{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-label-primary)}",
|
|
31
|
+
".ou_badge[data-open=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
32
|
+
".ou_badgeIcon{color:var(--dsw-alias-state-business-primary);flex:none;justify-content:center;align-items:center;display:inline-flex}",
|
|
33
|
+
".ou_badgeLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}",
|
|
34
|
+
".ou_badgeMeta{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;flex:none;margin-left:2px;font-size:11px;line-height:16px}",
|
|
35
|
+
".ou_panel{z-index:35;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base);width:min(392px,calc(100vw - 24px));max-height:min(74vh,640px);box-shadow:var(--dsw-shadow-lv2);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border-radius:14px;flex-direction:column;display:flex;position:fixed;bottom:128px;left:12px;overflow:hidden;transition:left var(--ds-transition-duration-slow) var(--ds-ease-in-out),bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out),width var(--ds-transition-duration-slow) var(--ds-ease-in-out)}",
|
|
36
|
+
".ou_header{box-sizing:border-box;border-bottom:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex:none;justify-content:space-between;align-items:center;min-height:46px;padding:10px 12px;display:flex}",
|
|
37
|
+
".ou_title{min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}",
|
|
38
|
+
".ou_subtitle{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:11px;line-height:16px}",
|
|
39
|
+
".ou_close{width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex}",
|
|
40
|
+
".ou_close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
|
|
41
|
+
".ou_body{flex:1;min-height:0;padding:12px;overflow-y:auto}",
|
|
42
|
+
".ou_cards{flex-direction:column;gap:10px;display:flex}",
|
|
43
|
+
".ou_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:12px}",
|
|
44
|
+
".ou_cardHead{justify-content:space-between;align-items:baseline;gap:10px;display:flex}",
|
|
45
|
+
".ou_cardTitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}",
|
|
46
|
+
".ou_cardWindow{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
|
|
47
|
+
".ou_remaining{color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code);font-variant-numeric:tabular-nums;font-size:26px;font-weight:600;line-height:34px;margin-top:6px}",
|
|
48
|
+
".ou_remainingUnit{color:var(--dsw-alias-label-tertiary);margin-left:6px;font-size:12px;font-weight:400}",
|
|
49
|
+
".ou_usedLine{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:12px;line-height:18px;margin-top:2px}",
|
|
50
|
+
".ou_progress{background:var(--dsw-alias-bg-layer-1);height:8px;border-radius:999px;margin-top:10px;overflow:hidden}",
|
|
51
|
+
".ou_progressFill{height:100%;width:0;background:var(--dsw-alias-state-business-primary);border-radius:999px;transition:width .2s var(--ds-ease-in-out)}",
|
|
52
|
+
".ou_progressFill[data-level=warn]{background:var(--dsw-alias-state-warn-primary)}",
|
|
53
|
+
".ou_progressFill[data-level=danger]{background:var(--dsw-alias-state-error-primary)}",
|
|
54
|
+
".ou_reset{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:11px;line-height:16px;margin-top:8px}",
|
|
55
|
+
".ou_limited{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);border-radius:6px;align-self:flex-start;padding:1px 7px;font-size:11px;line-height:18px;margin-top:8px}",
|
|
56
|
+
".ou_status{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:20px}",
|
|
57
|
+
".ou_failure{color:var(--dsw-alias-state-error-primary);flex-direction:column;align-items:flex-start;gap:8px;display:flex}",
|
|
58
|
+
".ou_failure p{margin:0;font-size:13px;line-height:20px}",
|
|
59
|
+
".ou_failure button{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:6px;padding:4px 10px}",
|
|
60
|
+
"@media (prefers-reduced-motion:no-preference){.ou_progressFill{transition:width .2s var(--ds-ease-in-out)}}",
|
|
61
|
+
].join("");
|
|
62
|
+
|
|
63
|
+
var TAG_ID = "dsh-plugin-opencode-usage/OpenCodeUsagePanel.css";
|
|
64
|
+
if (
|
|
65
|
+
typeof document !== "undefined" &&
|
|
66
|
+
document.querySelector('style[data-plugin-css="' + TAG_ID + '"]') === null
|
|
67
|
+
) {
|
|
68
|
+
var styleTag = document.createElement("style");
|
|
69
|
+
styleTag.dataset.plugin = "dsh-plugin-opencode-usage";
|
|
70
|
+
styleTag.dataset.pluginCss = TAG_ID;
|
|
71
|
+
styleTag.textContent = CSS;
|
|
72
|
+
document.head.appendChild(styleTag);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var numberFormat = new Intl.NumberFormat(undefined, {
|
|
76
|
+
maximumFractionDigits: 1,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function formatPercent(value) {
|
|
80
|
+
return numberFormat.format(Number(value) || 0) + "%";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatTime(value) {
|
|
84
|
+
var date = new Date(value);
|
|
85
|
+
// A malformed resetsAt would otherwise render "NaN-NaN NaN:NaN".
|
|
86
|
+
if (Number.isNaN(date.getTime())) return "—";
|
|
87
|
+
return (
|
|
88
|
+
String(date.getMonth() + 1).padStart(2, "0") +
|
|
89
|
+
"-" +
|
|
90
|
+
String(date.getDate()).padStart(2, "0") +
|
|
91
|
+
" " +
|
|
92
|
+
String(date.getHours()).padStart(2, "0") +
|
|
93
|
+
":" +
|
|
94
|
+
String(date.getMinutes()).padStart(2, "0")
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function progressLevel(period) {
|
|
99
|
+
if (period.percent >= 100) return "danger";
|
|
100
|
+
if (period.percent >= 80) return "warn";
|
|
101
|
+
return "ok";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function UsageIcon() {
|
|
105
|
+
return React.createElement(
|
|
106
|
+
"svg",
|
|
107
|
+
{
|
|
108
|
+
viewBox: "0 0 16 16",
|
|
109
|
+
width: 16,
|
|
110
|
+
height: 16,
|
|
111
|
+
fill: "none",
|
|
112
|
+
stroke: "currentColor",
|
|
113
|
+
strokeWidth: 1.5,
|
|
114
|
+
"aria-hidden": "true",
|
|
115
|
+
},
|
|
116
|
+
React.createElement("path", {
|
|
117
|
+
d: "M2.5 13.5v-3M6.5 13.5v-6M10.5 13.5v-8M14 13.5v-4",
|
|
118
|
+
strokeLinecap: "round",
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function CloseIcon() {
|
|
124
|
+
return React.createElement(
|
|
125
|
+
"svg",
|
|
126
|
+
{
|
|
127
|
+
viewBox: "0 0 16 16",
|
|
128
|
+
width: 14,
|
|
129
|
+
height: 14,
|
|
130
|
+
fill: "none",
|
|
131
|
+
stroke: "currentColor",
|
|
132
|
+
strokeWidth: 1.5,
|
|
133
|
+
"aria-hidden": "true",
|
|
134
|
+
},
|
|
135
|
+
React.createElement("path", { d: "M4 4l8 8M12 4l-8 8", strokeLinecap: "round" }),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function PeriodCard(props) {
|
|
140
|
+
var period = props.period;
|
|
141
|
+
var t = props.t;
|
|
142
|
+
var level = progressLevel(period);
|
|
143
|
+
return React.createElement(
|
|
144
|
+
"section",
|
|
145
|
+
{ className: "ou_card", "data-period": period.key },
|
|
146
|
+
React.createElement(
|
|
147
|
+
"div",
|
|
148
|
+
{ className: "ou_cardHead" },
|
|
149
|
+
React.createElement("span", { className: "ou_cardTitle" }, t(period.key + "Title")),
|
|
150
|
+
React.createElement(
|
|
151
|
+
"span",
|
|
152
|
+
{ className: "ou_cardWindow" },
|
|
153
|
+
t(period.key + "Window"),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
React.createElement(
|
|
157
|
+
"div",
|
|
158
|
+
{ className: "ou_remaining" },
|
|
159
|
+
formatPercent(period.percent),
|
|
160
|
+
React.createElement("span", { className: "ou_remainingUnit" }, t("usedPercent")),
|
|
161
|
+
),
|
|
162
|
+
React.createElement(
|
|
163
|
+
"div",
|
|
164
|
+
{ className: "ou_usedLine" },
|
|
165
|
+
t("usedRemaining", {
|
|
166
|
+
used: formatPercent(period.percent),
|
|
167
|
+
remaining: formatPercent(period.remainingPercent),
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
React.createElement(
|
|
171
|
+
"div",
|
|
172
|
+
{
|
|
173
|
+
className: "ou_progress",
|
|
174
|
+
role: "progressbar",
|
|
175
|
+
"aria-label": t(period.key + "Title"),
|
|
176
|
+
"aria-valuenow": String(Math.max(0, Math.min(100, period.percent))),
|
|
177
|
+
"aria-valuemin": "0",
|
|
178
|
+
"aria-valuemax": "100",
|
|
179
|
+
},
|
|
180
|
+
React.createElement("div", {
|
|
181
|
+
className: "ou_progressFill",
|
|
182
|
+
"data-level": level,
|
|
183
|
+
style: { width: String(Math.max(0, Math.min(100, period.percent))) + "%" },
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
period.resetsAt
|
|
187
|
+
? React.createElement(
|
|
188
|
+
"div",
|
|
189
|
+
{ className: "ou_reset" },
|
|
190
|
+
t("resetsAt", { time: formatTime(period.resetsAt) }),
|
|
191
|
+
)
|
|
192
|
+
: null,
|
|
193
|
+
period.percent >= 100
|
|
194
|
+
? React.createElement("div", { className: "ou_limited" }, t("limitReached"))
|
|
195
|
+
: null,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function OpenCodeUsagePanel(props) {
|
|
200
|
+
var t = props.t;
|
|
201
|
+
var panelId = React.useId();
|
|
202
|
+
var rootRef = React.useRef(null);
|
|
203
|
+
var triggerRef = React.useRef(null);
|
|
204
|
+
var closeRef = React.useRef(null);
|
|
205
|
+
var wasOpenRef = React.useRef(false);
|
|
206
|
+
var openState = React.useState(false);
|
|
207
|
+
var open = openState[0];
|
|
208
|
+
var setOpen = openState[1];
|
|
209
|
+
var requestState = React.useState(0);
|
|
210
|
+
var request = requestState[0];
|
|
211
|
+
var setRequest = requestState[1];
|
|
212
|
+
var stateState = React.useState({ status: "idle" });
|
|
213
|
+
var state = stateState[0];
|
|
214
|
+
var setState = stateState[1];
|
|
215
|
+
var placementState = React.useState(null);
|
|
216
|
+
var placement = placementState[0];
|
|
217
|
+
var setPlacement = placementState[1];
|
|
218
|
+
|
|
219
|
+
var PANEL_MAX_WIDTH = 392;
|
|
220
|
+
var VIEWPORT_GAP = 12;
|
|
221
|
+
var BUTTON_BOTTOM = 16;
|
|
222
|
+
var BUTTON_HEIGHT = 32;
|
|
223
|
+
var BUTTON_GAP = 12;
|
|
224
|
+
|
|
225
|
+
/** slot outlet 是 display:contents 包装,沿祖先找到 AppFrame 的 shell.overlay 层。 */
|
|
226
|
+
function findOverlayLayer() {
|
|
227
|
+
var node = rootRef.current ? rootRef.current.parentElement : null;
|
|
228
|
+
while (node && node !== document.documentElement) {
|
|
229
|
+
if (node.getAttribute && node.getAttribute("data-shell-overlay") !== null) return node;
|
|
230
|
+
node = node.parentElement;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** shell.overlay 的父节点是 AppFrame,其第 2 个子元素是会话中心列。 */
|
|
236
|
+
function findCenterColumn() {
|
|
237
|
+
var layer = findOverlayLayer();
|
|
238
|
+
var frame = layer ? layer.parentElement : null;
|
|
239
|
+
if (!frame || !frame.children || frame.children.length < 2) return null;
|
|
240
|
+
return frame.children[1];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 按钮绝对定位在会话中心列左下角;面板以按钮为锚点向上弹出。
|
|
245
|
+
* 返回按钮相对 overlay 的 left,以及面板的 viewport left/bottom/width。
|
|
246
|
+
*/
|
|
247
|
+
function computePlacement() {
|
|
248
|
+
var center = findCenterColumn();
|
|
249
|
+
var layer = findOverlayLayer();
|
|
250
|
+
var centerRect = center ? center.getBoundingClientRect() : null;
|
|
251
|
+
var layerRect = layer ? layer.getBoundingClientRect() : null;
|
|
252
|
+
var viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
253
|
+
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
254
|
+
|
|
255
|
+
var buttonLeft = VIEWPORT_GAP;
|
|
256
|
+
if (centerRect && layerRect) {
|
|
257
|
+
buttonLeft = centerRect.left - layerRect.left + VIEWPORT_GAP;
|
|
258
|
+
}
|
|
259
|
+
buttonLeft = Math.max(VIEWPORT_GAP, buttonLeft);
|
|
260
|
+
|
|
261
|
+
var anchorLeft = centerRect ? centerRect.left + VIEWPORT_GAP : VIEWPORT_GAP;
|
|
262
|
+
var anchorTop = centerRect
|
|
263
|
+
? centerRect.bottom - BUTTON_BOTTOM - BUTTON_HEIGHT
|
|
264
|
+
: viewportHeight - 160;
|
|
265
|
+
|
|
266
|
+
var panelWidth = Math.min(PANEL_MAX_WIDTH, Math.max(0, viewportWidth - VIEWPORT_GAP * 2));
|
|
267
|
+
var panelLeft = Math.max(VIEWPORT_GAP, Math.min(anchorLeft, viewportWidth - panelWidth - VIEWPORT_GAP));
|
|
268
|
+
var panelBottom = Math.max(
|
|
269
|
+
VIEWPORT_GAP,
|
|
270
|
+
Math.min(viewportHeight - VIEWPORT_GAP, viewportHeight - anchorTop + BUTTON_GAP),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
buttonLeft: Math.round(buttonLeft),
|
|
275
|
+
panelLeft: Math.round(panelLeft),
|
|
276
|
+
panelBottom: Math.round(panelBottom),
|
|
277
|
+
panelWidth: Math.round(panelWidth),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function updatePlacement() {
|
|
282
|
+
setPlacement(computePlacement());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function toggleOpen() {
|
|
286
|
+
var next = !open;
|
|
287
|
+
if (next) updatePlacement();
|
|
288
|
+
setOpen(next);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The panel renders before the trigger in DOM order, so opening it leaves
|
|
292
|
+
// focus behind: a keyboard user would have to shift-Tab backwards to reach
|
|
293
|
+
// it. Move focus in on open and hand it back to the trigger on close.
|
|
294
|
+
React.useEffect(
|
|
295
|
+
function () {
|
|
296
|
+
if (open) {
|
|
297
|
+
if (closeRef.current) closeRef.current.focus();
|
|
298
|
+
} else if (wasOpenRef.current) {
|
|
299
|
+
if (triggerRef.current) triggerRef.current.focus();
|
|
300
|
+
}
|
|
301
|
+
wasOpenRef.current = open;
|
|
302
|
+
},
|
|
303
|
+
[open],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
React.useEffect(
|
|
307
|
+
function () {
|
|
308
|
+
if (!open) return;
|
|
309
|
+
var current = true;
|
|
310
|
+
setState({ status: "loading" });
|
|
311
|
+
Promise.resolve()
|
|
312
|
+
.then(function () {
|
|
313
|
+
return fetch("/plugins/opencode-usage/stats", { cache: "no-store" });
|
|
314
|
+
})
|
|
315
|
+
.then(function (res) {
|
|
316
|
+
if (!res.ok) throw new Error("opencode-usage stats failed: " + res.status);
|
|
317
|
+
return res.json();
|
|
318
|
+
})
|
|
319
|
+
.then(
|
|
320
|
+
function (snapshot) {
|
|
321
|
+
if (!current) return;
|
|
322
|
+
if (!snapshot || snapshot.ok !== true) {
|
|
323
|
+
throw new Error(snapshot && snapshot.error ? snapshot.error : "invalid snapshot");
|
|
324
|
+
}
|
|
325
|
+
setState({ status: "ready", snapshot: snapshot });
|
|
326
|
+
},
|
|
327
|
+
function (error) {
|
|
328
|
+
if (current) setState({ status: "error", message: String(error?.message ?? error) });
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
return function () {
|
|
332
|
+
current = false;
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
[open, request],
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
React.useEffect(
|
|
339
|
+
function () {
|
|
340
|
+
if (!open) return;
|
|
341
|
+
var timer = window.setInterval(function () {
|
|
342
|
+
setRequest(function (value) {
|
|
343
|
+
return value + 1;
|
|
344
|
+
});
|
|
345
|
+
}, REFRESH_MS);
|
|
346
|
+
return function () {
|
|
347
|
+
window.clearInterval(timer);
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
[open],
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
React.useLayoutEffect(
|
|
354
|
+
function () {
|
|
355
|
+
updatePlacement();
|
|
356
|
+
var center = findCenterColumn();
|
|
357
|
+
var observer =
|
|
358
|
+
typeof ResizeObserver !== "undefined" && center
|
|
359
|
+
? new ResizeObserver(updatePlacement)
|
|
360
|
+
: null;
|
|
361
|
+
if (observer) observer.observe(center);
|
|
362
|
+
window.addEventListener("resize", updatePlacement);
|
|
363
|
+
return function () {
|
|
364
|
+
if (observer) observer.disconnect();
|
|
365
|
+
window.removeEventListener("resize", updatePlacement);
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
[],
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
React.useEffect(
|
|
372
|
+
function () {
|
|
373
|
+
if (!open) return;
|
|
374
|
+
function onKey(event) {
|
|
375
|
+
if (event.key === "Escape") setOpen(false);
|
|
376
|
+
}
|
|
377
|
+
function onPointer(event) {
|
|
378
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
|
|
379
|
+
}
|
|
380
|
+
document.addEventListener("keydown", onKey);
|
|
381
|
+
document.addEventListener("pointerdown", onPointer);
|
|
382
|
+
return function () {
|
|
383
|
+
document.removeEventListener("keydown", onKey);
|
|
384
|
+
document.removeEventListener("pointerdown", onPointer);
|
|
385
|
+
};
|
|
386
|
+
},
|
|
387
|
+
[open],
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
function retry() {
|
|
391
|
+
setState({ status: "loading" });
|
|
392
|
+
setRequest(function (value) {
|
|
393
|
+
return value + 1;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
var body = [];
|
|
398
|
+
if (state.status === "idle" || state.status === "loading") {
|
|
399
|
+
body.push(
|
|
400
|
+
React.createElement("p", { className: "ou_status", key: "loading" }, t("loading")),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (state.status === "error") {
|
|
404
|
+
body.push(
|
|
405
|
+
React.createElement(
|
|
406
|
+
"div",
|
|
407
|
+
{ className: "ou_failure", key: "error" },
|
|
408
|
+
React.createElement("p", { role: "alert" }, t("error") + (state.message ? ": " + state.message : "")),
|
|
409
|
+
React.createElement("button", { type: "button", onClick: retry }, t("retry")),
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (state.status === "ready") {
|
|
414
|
+
var periods = state.snapshot.periods;
|
|
415
|
+
var weeklyPeriod = periods.weekly || periods.week;
|
|
416
|
+
var monthlyPeriod = periods.monthly || periods.month;
|
|
417
|
+
body.push(
|
|
418
|
+
React.createElement(
|
|
419
|
+
"div",
|
|
420
|
+
{ className: "ou_cards", key: "cards" },
|
|
421
|
+
periods.rolling
|
|
422
|
+
? React.createElement(PeriodCard, { key: "rolling", period: periods.rolling, t: t })
|
|
423
|
+
: null,
|
|
424
|
+
weeklyPeriod
|
|
425
|
+
? React.createElement(PeriodCard, { key: "weekly", period: weeklyPeriod, t: t })
|
|
426
|
+
: null,
|
|
427
|
+
monthlyPeriod
|
|
428
|
+
? React.createElement(PeriodCard, { key: "monthly", period: monthlyPeriod, t: t })
|
|
429
|
+
: null,
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
var rolling = state.status === "ready" ? state.snapshot.periods.rolling : null;
|
|
435
|
+
|
|
436
|
+
return React.createElement(
|
|
437
|
+
"div",
|
|
438
|
+
{
|
|
439
|
+
ref: rootRef,
|
|
440
|
+
className: "ou_layer",
|
|
441
|
+
style: placement
|
|
442
|
+
? { left: placement.buttonLeft + "px" }
|
|
443
|
+
: undefined,
|
|
444
|
+
},
|
|
445
|
+
open
|
|
446
|
+
? React.createElement(
|
|
447
|
+
"section",
|
|
448
|
+
{
|
|
449
|
+
className: "ou_panel",
|
|
450
|
+
id: panelId,
|
|
451
|
+
role: "dialog",
|
|
452
|
+
"aria-label": t("panelTitle"),
|
|
453
|
+
style: placement
|
|
454
|
+
? {
|
|
455
|
+
left: placement.panelLeft + "px",
|
|
456
|
+
bottom: placement.panelBottom + "px",
|
|
457
|
+
width: placement.panelWidth + "px",
|
|
458
|
+
}
|
|
459
|
+
: undefined,
|
|
460
|
+
},
|
|
461
|
+
React.createElement(
|
|
462
|
+
"header",
|
|
463
|
+
{ className: "ou_header" },
|
|
464
|
+
React.createElement(
|
|
465
|
+
"div",
|
|
466
|
+
null,
|
|
467
|
+
React.createElement("div", { className: "ou_title" }, t("panelTitle")),
|
|
468
|
+
state.status === "ready"
|
|
469
|
+
? React.createElement(
|
|
470
|
+
"div",
|
|
471
|
+
{ className: "ou_subtitle" },
|
|
472
|
+
t("subtitle", {
|
|
473
|
+
provider: state.snapshot.provider,
|
|
474
|
+
time: formatTime(state.snapshot.generatedAt),
|
|
475
|
+
}),
|
|
476
|
+
)
|
|
477
|
+
: React.createElement(
|
|
478
|
+
"div",
|
|
479
|
+
{ className: "ou_subtitle" },
|
|
480
|
+
t("panelSubtitle"),
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
React.createElement(
|
|
484
|
+
"button",
|
|
485
|
+
{
|
|
486
|
+
type: "button",
|
|
487
|
+
className: "ou_close",
|
|
488
|
+
ref: closeRef,
|
|
489
|
+
"aria-label": t("close"),
|
|
490
|
+
onClick: function () {
|
|
491
|
+
setOpen(false);
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
React.createElement(CloseIcon),
|
|
495
|
+
),
|
|
496
|
+
),
|
|
497
|
+
React.createElement("div", { className: "ou_body" }, body),
|
|
498
|
+
)
|
|
499
|
+
: null,
|
|
500
|
+
React.createElement(
|
|
501
|
+
"div",
|
|
502
|
+
{ className: "ou_footerButtons" },
|
|
503
|
+
React.createElement(
|
|
504
|
+
"button",
|
|
505
|
+
{
|
|
506
|
+
type: "button",
|
|
507
|
+
className: "ou_badge",
|
|
508
|
+
ref: triggerRef,
|
|
509
|
+
"data-open": open ? "true" : "false",
|
|
510
|
+
"aria-label": t("trigger"),
|
|
511
|
+
"aria-expanded": open,
|
|
512
|
+
"aria-controls": open ? panelId : undefined,
|
|
513
|
+
onClick: toggleOpen,
|
|
514
|
+
},
|
|
515
|
+
React.createElement("span", { className: "ou_badgeIcon" }, React.createElement(UsageIcon)),
|
|
516
|
+
React.createElement("span", { className: "ou_badgeLabel" }, t("trigger")),
|
|
517
|
+
rolling
|
|
518
|
+
? React.createElement(
|
|
519
|
+
"span",
|
|
520
|
+
{ className: "ou_badgeMeta" },
|
|
521
|
+
t("triggerMeta", {
|
|
522
|
+
percent: formatPercent(rolling.percent),
|
|
523
|
+
}),
|
|
524
|
+
)
|
|
525
|
+
: null,
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
var zh = {
|
|
532
|
+
trigger: "OpenCode Go 用量",
|
|
533
|
+
triggerMeta: "滚动已用 {percent}",
|
|
534
|
+
panelTitle: "OpenCode Go 订阅用量",
|
|
535
|
+
panelSubtitle: "总额度 / 已用 / 剩余",
|
|
536
|
+
subtitle: "{provider} · 更新于 {time}",
|
|
537
|
+
loading: "正在查询订阅用量…",
|
|
538
|
+
error: "暂时无法查询用量",
|
|
539
|
+
retry: "重试",
|
|
540
|
+
close: "关闭",
|
|
541
|
+
rollingTitle: "滚动用量",
|
|
542
|
+
weeklyTitle: "每周用量",
|
|
543
|
+
monthlyTitle: "每月用量",
|
|
544
|
+
rollingWindow: "5 小时滚动窗口",
|
|
545
|
+
weeklyWindow: "自然周窗口",
|
|
546
|
+
monthlyWindow: "自然月窗口",
|
|
547
|
+
usedPercent: "已用",
|
|
548
|
+
usedRemaining: "已用 {used} · 剩余 {remaining}",
|
|
549
|
+
resetsAt: "额度重置:{time}",
|
|
550
|
+
limitReached: "已达额度上限",
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
var en = {
|
|
554
|
+
trigger: "OpenCode Go usage",
|
|
555
|
+
triggerMeta: "Rolling used {percent}",
|
|
556
|
+
panelTitle: "OpenCode Go subscription usage",
|
|
557
|
+
panelSubtitle: "Limit / used / remaining",
|
|
558
|
+
subtitle: "{provider} · updated {time}",
|
|
559
|
+
loading: "Reading subscription usage…",
|
|
560
|
+
error: "Usage is temporarily unavailable",
|
|
561
|
+
retry: "Retry",
|
|
562
|
+
close: "Close",
|
|
563
|
+
rollingTitle: "Rolling usage",
|
|
564
|
+
weeklyTitle: "Weekly usage",
|
|
565
|
+
monthlyTitle: "Monthly usage",
|
|
566
|
+
rollingWindow: "5-hour rolling window",
|
|
567
|
+
weeklyWindow: "Weekly window",
|
|
568
|
+
monthlyWindow: "Monthly window",
|
|
569
|
+
usedPercent: "used",
|
|
570
|
+
usedRemaining: "Used {used} · Remaining {remaining}",
|
|
571
|
+
resetsAt: "Resets at {time}",
|
|
572
|
+
limitReached: "Limit reached",
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
function apply(ctx) {
|
|
576
|
+
ctx.effect(
|
|
577
|
+
function () {
|
|
578
|
+
return ctx.locale.register(NS, { zh: zh, en: en });
|
|
579
|
+
},
|
|
580
|
+
"opencode-usage: dictionaries",
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
ctx.slots.inject("shell.overlay", function () {
|
|
584
|
+
return ctx.slots.register(
|
|
585
|
+
{
|
|
586
|
+
name: "shell.overlay",
|
|
587
|
+
id: "opencode-usage",
|
|
588
|
+
order: 100,
|
|
589
|
+
locale: NS,
|
|
590
|
+
},
|
|
591
|
+
OpenCodeUsagePanel,
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
exports.NS = NS;
|
|
597
|
+
exports.apply = apply;
|
|
598
|
+
exports.inject = inject;
|
|
599
|
+
return module.exports;
|
|
600
|
+
},
|
|
601
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-opencode-usage:OpenCode Go 用量悬浮面板(Host 半)。
|
|
3
|
+
*
|
|
4
|
+
* 查询 OpenCode Zen Go 订阅用量接口:
|
|
5
|
+
*
|
|
6
|
+
* GET https://opencode.ai/zen/go/v1/usage
|
|
7
|
+
* Authorization: Bearer <OPENCODE_GO_API_KEY>
|
|
8
|
+
*
|
|
9
|
+
* 官方接口按 rolling / weekly / monthly 返回已用百分比与重置时间。由于
|
|
10
|
+
* OpenCode Go 不同模型有不同额度金额,插件只展示百分比,不展示具体美元
|
|
11
|
+
* 金额,避免因模型差异造成误导。
|
|
12
|
+
*
|
|
13
|
+
* API key 通过 Harness credentials seam 解析 `credentialRef`(默认
|
|
14
|
+
* OPENCODE_GO_API_KEY),不经过浏览器。结果经 GET
|
|
15
|
+
* /plugins/opencode-usage/stats 返回,成功结果缓存 30s,失败不缓存。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-plugin-opencode-usage
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const name = "opencode-usage";
|
|
21
|
+
export const inject = ["credentials"];
|
|
22
|
+
|
|
23
|
+
/** 与 team-studio 一致的 Web 服务懒发现键。
|
|
24
|
+
* 注:当前 DSH 只提供 `webServer`;`httpServer` 保留为历史别名,不存在时静默跳过。 */
|
|
25
|
+
const WEB_SERVER_KEYS = ["webServer", "httpServer"];
|
|
26
|
+
|
|
27
|
+
const DEFAULT_API_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
28
|
+
const DEFAULT_CREDENTIAL_REF = "OPENCODE_GO_API_KEY";
|
|
29
|
+
const DEFAULT_CACHE_MS = 30_000;
|
|
30
|
+
const REQUEST_TIMEOUT_MS = 12_000;
|
|
31
|
+
|
|
32
|
+
function resolveConfig(raw) {
|
|
33
|
+
const config = raw && typeof raw === "object" ? raw : {};
|
|
34
|
+
return {
|
|
35
|
+
apiBaseUrl:
|
|
36
|
+
typeof config.apiBaseUrl === "string" && config.apiBaseUrl.trim()
|
|
37
|
+
? config.apiBaseUrl.replace(/\/+$/, "")
|
|
38
|
+
: DEFAULT_API_BASE_URL,
|
|
39
|
+
credentialRef:
|
|
40
|
+
typeof config.credentialRef === "string" && config.credentialRef.trim()
|
|
41
|
+
? config.credentialRef.trim()
|
|
42
|
+
: DEFAULT_CREDENTIAL_REF,
|
|
43
|
+
cacheMs:
|
|
44
|
+
Number.isFinite(config.cacheMs) && config.cacheMs >= 1000
|
|
45
|
+
? config.cacheMs
|
|
46
|
+
: DEFAULT_CACHE_MS,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function resolveApiKey(ctx, ref) {
|
|
51
|
+
const resolved = await ctx.credentials.resolve(ref);
|
|
52
|
+
if (!resolved || typeof resolved.value !== "string" || resolved.value.length === 0) {
|
|
53
|
+
throw new Error(`opencode-usage: credential "${ref}" is not configured`);
|
|
54
|
+
}
|
|
55
|
+
return resolved.value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function truncateErrorBody(body) {
|
|
59
|
+
const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
60
|
+
if (!text) return "";
|
|
61
|
+
return text.length > 240 ? `${text.slice(0, 240)}…` : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function fetchUsage(apiBaseUrl, apiKey) {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(`${apiBaseUrl}/usage`, {
|
|
69
|
+
method: "GET",
|
|
70
|
+
headers: {
|
|
71
|
+
authorization: `Bearer ${apiKey}`,
|
|
72
|
+
accept: "application/json",
|
|
73
|
+
},
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
cache: "no-store",
|
|
76
|
+
});
|
|
77
|
+
const body = await response.text();
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`opencode-usage: usage API returned HTTP ${response.status}: ${truncateErrorBody(body)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(body);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error("opencode-usage: usage API returned invalid JSON");
|
|
88
|
+
}
|
|
89
|
+
const usage = payload?.usage;
|
|
90
|
+
if (!usage || typeof usage !== "object") {
|
|
91
|
+
throw new Error('opencode-usage: usage API response is missing "usage"');
|
|
92
|
+
}
|
|
93
|
+
return usage;
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function projectPeriod(key, raw) {
|
|
100
|
+
const entry = raw && typeof raw === "object" ? raw : {};
|
|
101
|
+
const percent =
|
|
102
|
+
typeof entry.percent === "number" && Number.isFinite(entry.percent)
|
|
103
|
+
? Math.min(100, Math.max(0, entry.percent))
|
|
104
|
+
: null;
|
|
105
|
+
if (percent === null) {
|
|
106
|
+
throw new Error(`opencode-usage: usage.${key}.percent is missing or invalid`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
key,
|
|
110
|
+
status: typeof entry.status === "string" ? entry.status : "unknown",
|
|
111
|
+
percent,
|
|
112
|
+
remainingPercent: Math.max(0, Math.min(100, 100 - percent)),
|
|
113
|
+
resetsAt: typeof entry.resetsAt === "string" ? entry.resetsAt : null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const USAGE_PERIOD_KEYS = ["rolling", "weekly", "monthly"];
|
|
118
|
+
|
|
119
|
+
async function collectUsage(ctx, config) {
|
|
120
|
+
const apiKey = await resolveApiKey(ctx, config.credentialRef);
|
|
121
|
+
const usage = await fetchUsage(config.apiBaseUrl, apiKey);
|
|
122
|
+
const periods = {};
|
|
123
|
+
for (const key of USAGE_PERIOD_KEYS) {
|
|
124
|
+
periods[key] = projectPeriod(key, usage[key]);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
provider: "opencode-go",
|
|
129
|
+
generatedAt: Date.now(),
|
|
130
|
+
periods,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function collectCached(ctx, config, cache) {
|
|
135
|
+
const hit = cache.value;
|
|
136
|
+
if (hit && Date.now() - cache.at < config.cacheMs) return hit;
|
|
137
|
+
const value = await collectUsage(ctx, config);
|
|
138
|
+
// Written only on success, so a failed query is never cached.
|
|
139
|
+
cache.at = Date.now();
|
|
140
|
+
cache.value = value;
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function apply(ctx, rawConfig) {
|
|
145
|
+
const config = resolveConfig(rawConfig);
|
|
146
|
+
// The cache belongs to this fiber, not the module. A module-level cache would
|
|
147
|
+
// outlive reload/uninject — serving a snapshot fetched by the previous
|
|
148
|
+
// generation, and letting two rows with different config share one entry.
|
|
149
|
+
const cache = { at: 0, value: null };
|
|
150
|
+
let webRegistered = false;
|
|
151
|
+
|
|
152
|
+
const registerWebSurface = () => {
|
|
153
|
+
if (webRegistered) return;
|
|
154
|
+
const webServer = ctx.get(WEB_SERVER_KEYS[0]) ?? ctx.get(WEB_SERVER_KEYS[1]);
|
|
155
|
+
if (webServer === void 0) return;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
ctx.effect(
|
|
159
|
+
() =>
|
|
160
|
+
webServer.register({
|
|
161
|
+
kind: "exact",
|
|
162
|
+
path: "/plugins/opencode-usage/stats",
|
|
163
|
+
handler: async (req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
const snapshot = await collectCached(ctx, config, cache);
|
|
166
|
+
const body = JSON.stringify(snapshot);
|
|
167
|
+
res.writeHead(200, {
|
|
168
|
+
"content-type": "application/json; charset=utf-8",
|
|
169
|
+
"cache-control": "no-store",
|
|
170
|
+
});
|
|
171
|
+
res.end(body);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// Keep the upstream detail host-side. This route is reachable
|
|
174
|
+
// without authentication, so the response carries a fixed
|
|
175
|
+
// message rather than whatever the upstream API replied.
|
|
176
|
+
ctx.logger.warn(
|
|
177
|
+
`opencode-usage: usage query failed: ${String(error?.message ?? error)}`,
|
|
178
|
+
);
|
|
179
|
+
const body = JSON.stringify({
|
|
180
|
+
ok: false,
|
|
181
|
+
provider: "opencode-go",
|
|
182
|
+
generatedAt: Date.now(),
|
|
183
|
+
error: "usage query failed",
|
|
184
|
+
});
|
|
185
|
+
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
|
|
186
|
+
res.end(body);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
"opencode-usage: usage route",
|
|
191
|
+
);
|
|
192
|
+
// Only after register() returned: it throws on a duplicate (kind, path),
|
|
193
|
+
// and a failed attempt must leave the internal/service retry path alive.
|
|
194
|
+
webRegistered = true;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
ctx.logger.warn(
|
|
197
|
+
`opencode-usage: could not register the usage route (another instance may own it): ${String(error?.message ?? error)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
registerWebSurface();
|
|
203
|
+
ctx.on("internal/service", (serviceName) => {
|
|
204
|
+
if (WEB_SERVER_KEYS.includes(serviceName)) registerWebSurface();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 测试钩子:单测直接驱动换算逻辑。 */
|
|
209
|
+
export const _internals = {
|
|
210
|
+
collectUsage,
|
|
211
|
+
fetchUsage,
|
|
212
|
+
projectPeriod,
|
|
213
|
+
resolveConfig,
|
|
214
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-opencode-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode Go 用量百分比悬浮面板:在 DeepSeek Harness Web 会话窗口左下角显示滚动/每周/每月用量百分比与重置时间。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"dsh",
|
|
16
|
+
"dsh-plugin",
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"cordis",
|
|
19
|
+
"opencode",
|
|
20
|
+
"usage",
|
|
21
|
+
"quota",
|
|
22
|
+
"web-ui"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/jiekesu967/dsh-plugin-opencode-usage.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/jiekesu967/dsh-plugin-opencode-usage#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/jiekesu967/dsh-plugin-opencode-usage/issues"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"README.md",
|
|
39
|
+
"screenshot.png",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"client": {
|
|
47
|
+
"inject": [
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
49
|
+
"@deepseek-ai/dsh-client-locale"
|
|
50
|
+
],
|
|
51
|
+
"platform": "web"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/react": "~18.3.1",
|
|
56
|
+
"react": "^18.2.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/screenshot.png
ADDED
|
Binary file
|