dsh-ollama-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 +72 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +354 -0
- package/lib/index.js +385 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-ollama-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,72 @@
|
|
|
1
|
+
# dsh-ollama-usage
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 插件:可视化 **Ollama Pro** 账号的用量余量(每 5 小时会话用量 + 周用量),
|
|
4
|
+
支持侧边栏双横条、设置页完整面板、API Key 与用量快照持久化、自动刷新与登录引导。
|
|
5
|
+
|
|
6
|
+
A DeepSeek Harness plugin that visualizes your **Ollama Pro** usage / remaining quota
|
|
7
|
+
(5-hour session bucket + weekly bucket), with a minimalist sidebar double-bar,
|
|
8
|
+
a full settings panel, persisted API key and usage snapshots, auto-refresh,
|
|
9
|
+
and login guidance.
|
|
10
|
+
|
|
11
|
+
数据来源为 Ollama 官方接口 `GET https://ollama.com/api/usage`
|
|
12
|
+
(数据与 [ollama.com/settings](https://ollama.com/settings) 页面一致)。
|
|
13
|
+
|
|
14
|
+
## 功能
|
|
15
|
+
|
|
16
|
+
- **侧边栏底部双横条**(极简、透明背景):`会话5h` 淡紫条 + `周用量` 深紫条,
|
|
17
|
+
各带百分比数字;悬停显示详情;每 60 秒自动更新;窄栏(rail)自动隐藏。
|
|
18
|
+
- **设置页「Ollama 用量」完整面板**:会话(每 5 小时)/ 周用量进度条(已用 X% · 剩余 Y%)、
|
|
19
|
+
周期起止时间、近 4 周费用、模型请求数排行、最近 5 条会话用量历史、清除 Key。
|
|
20
|
+
- **持久化**:API Key 与用量快照写入 `$DSH_HOME/storages/ollama-usage/usage.json`
|
|
21
|
+
(权限 600,保留最近 24 条历史),跨对话 / 重启自动恢复。
|
|
22
|
+
- **自动刷新**:Host 每 10 分钟自动查询一次(浏览器页面关闭也持续);
|
|
23
|
+
Key 失效(401)自动清除并提示重新登录。
|
|
24
|
+
- **登录引导**:未登录时面板显示步骤指引,一键打开
|
|
25
|
+
[ollama.com/settings](https://ollama.com/settings) 登录、到
|
|
26
|
+
[settings/keys](https://ollama.com/settings/keys) 创建 API Key 并粘贴检测;
|
|
27
|
+
也兼容旧版 CLI 的 `~/.ollama/auth.json` 凭证。
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
dsh plugin --profile web add dsh-ollama-usage
|
|
33
|
+
dsh --profile web
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
或手动把包加入 profile 的 `package.json` 的 `dsh.profile.bundles` 后重启。
|
|
37
|
+
|
|
38
|
+
## 使用
|
|
39
|
+
|
|
40
|
+
1. 打开 设置 → **Ollama 用量**。
|
|
41
|
+
2. 若未登录:按面板指引打开 [ollama.com/settings](https://ollama.com/settings)
|
|
42
|
+
登录账号,在 [settings/keys](https://ollama.com/settings/keys) 创建 API Key
|
|
43
|
+
(`ollama-` 开头),粘贴到输入框点 **检测**;或在终端运行 `ollama signin` 后点
|
|
44
|
+
**重新检测本地凭证**。
|
|
45
|
+
3. 检测成功后 Key 与快照即持久化,之后全自动:展开侧边栏即可在底部看到双横条,
|
|
46
|
+
面板可随时查看详情与历史。
|
|
47
|
+
4. 不再需要时,在面板点 **清除已存 Key** 即可移除本地凭证(历史保留)。
|
|
48
|
+
|
|
49
|
+
## 数据与隐私
|
|
50
|
+
|
|
51
|
+
- 仅向 `https://ollama.com/api/usage` 发送带 Bearer 的查询请求;
|
|
52
|
+
- Key 以明文保存在本机 `$DSH_HOME/storages/ollama-usage/usage.json`(权限 600),
|
|
53
|
+
与 `~/.ollama/auth.json` 同等级信任;需要时可随时清除。
|
|
54
|
+
|
|
55
|
+
## 开发 / 发布
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install # 安装依赖(@deepseek-ai/dsh-home-paths)
|
|
59
|
+
npm pack # 本地打包检查
|
|
60
|
+
npm publish # 发布到 npm(需先 npm login)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- 宿主入口:`lib/index.js`(ESM,导出 `name` + 默认插件;在 webServer 注册
|
|
64
|
+
`/ollama-usage` 前缀路由,走 Connection RPC 信封协议)。
|
|
65
|
+
- 客户端入口:`lib/client.js`(`window.__ModuleLoader__.load` bundle;
|
|
66
|
+
经 `connection.rpc.call('/ollama-usage', endpoint, payload)` 调用宿主)。
|
|
67
|
+
- bundle 接线:`cordis.patch.yml`(Loader 行)+ `package.json` 的
|
|
68
|
+
`dsh.bundle.patch` / `dsh.client` 声明。
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-ollama-usage bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Adds the plugin's single Loader row. The row name resolves from the
|
|
4
|
+
# profile's own node_modules, where `dsh plugin --profile <name> add`
|
|
5
|
+
# installed this package.
|
|
6
|
+
#
|
|
7
|
+
# 安装: dsh plugin --profile web add dsh-ollama-usage
|
|
8
|
+
# 重启: dsh --profile web
|
|
9
|
+
- insert:
|
|
10
|
+
- id: ollama-usage
|
|
11
|
+
name: dsh-ollama-usage
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ollama-usage 浏览器端 bundle(经 __ModuleLoader__ 加载)。
|
|
3
|
+
*
|
|
4
|
+
* 提供三处界面:
|
|
5
|
+
* - settings.section「Ollama 用量」:完整面板(会话 5h / 周用量进度条、费用、
|
|
6
|
+
* 模型请求数、历史记录、API Key 配置、清除 Key);
|
|
7
|
+
* - sidebar.footer.action:极简双横条(会话 5h 淡紫 / 周用量深紫,透明背景);
|
|
8
|
+
* - tool.view.cordis:Run 卡片内的同款面板。
|
|
9
|
+
*
|
|
10
|
+
* 数据通道:Host 在 webServer 注册的 /ollama-usage 前缀路由,经 Connection
|
|
11
|
+
* RPC 信封协议调用(check / snapshot / forget)。样式为手写 <style> 注入,
|
|
12
|
+
* 全部使用 --dsw-* 主题变量(带回退值)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: 'dsh-ollama-usage',
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} }
|
|
19
|
+
var exports = module.exports
|
|
20
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
21
|
+
|
|
22
|
+
const React = require('react')
|
|
23
|
+
|
|
24
|
+
const RPC_CHANNEL = '/ollama-usage'
|
|
25
|
+
const CSS_TAG_ID = 'dsh-ollama-usage/client.css'
|
|
26
|
+
const SESSION_COLOR = '#b39df7'
|
|
27
|
+
const WEEKLY_COLOR = '#7c66e8'
|
|
28
|
+
|
|
29
|
+
const CSS = [
|
|
30
|
+
'.olusa-panel{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l1,#3a3a3a);border-radius:10px;background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.07));font-size:13px;line-height:1.55;color:var(--dsw-alias-label-primary,#e8e8e8)}',
|
|
31
|
+
'.olusa-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
|
|
32
|
+
'.olusa-title{font-weight:650;font-size:13.5px}',
|
|
33
|
+
'.olusa-btn{border:1px solid var(--dsw-alias-border-l2,#555);background:transparent;color:var(--dsw-alias-label-primary,#e8e8e8);border-radius:6px;padding:3px 10px;font-size:12px;cursor:pointer}',
|
|
34
|
+
'.olusa-btn:disabled{opacity:.55;cursor:default}',
|
|
35
|
+
'.olusa-btn-primary{border-color:var(--dsw-alias-brand-primary,#7aa2f7);color:var(--dsw-alias-brand-primary,#7aa2f7)}',
|
|
36
|
+
'.olusa-hint{color:var(--dsw-alias-label-secondary,#9a9a9a);font-size:12px}',
|
|
37
|
+
'.olusa-error{color:var(--dsw-alias-state-error-primary,#f7768e)}',
|
|
38
|
+
'.olusa-ok{color:var(--dsw-alias-state-success-primary,#9ece6a)}',
|
|
39
|
+
'.olusa-block{display:flex;flex-direction:column;gap:4px}',
|
|
40
|
+
'.olusa-barhead{display:flex;justify-content:space-between;align-items:baseline;gap:8px}',
|
|
41
|
+
'.olusa-barlabel{color:var(--dsw-alias-label-primary,#e8e8e8)}',
|
|
42
|
+
'.olusa-barval{color:var(--dsw-alias-label-secondary,#9a9a9a);font-size:12px;font-variant-numeric:tabular-nums}',
|
|
43
|
+
'.olusa-bar{height:8px;border-radius:4px;background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.18));overflow:hidden}',
|
|
44
|
+
'.olusa-barfill{height:100%;border-radius:4px;transition:width .3s ease}',
|
|
45
|
+
'.olusa-row{display:flex;justify-content:space-between;gap:8px}',
|
|
46
|
+
'.olusa-link{color:var(--dsw-alias-brand-primary,#7aa2f7);text-decoration:none}',
|
|
47
|
+
'.olusa-steps{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px}',
|
|
48
|
+
'.olusa-input{flex:1;min-width:0;background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.15));border:1px solid var(--dsw-alias-border-l2,#555);color:var(--dsw-alias-label-primary,#e8e8e8);border-radius:6px;padding:5px 8px;font-size:12px;font-family:monospace}',
|
|
49
|
+
'.olusa-login-title{font-weight:600}',
|
|
50
|
+
'.olusa-rowbtns{display:flex;gap:6px;margin-top:8px}',
|
|
51
|
+
'.olusa-models{display:flex;flex-direction:column;gap:3px}',
|
|
52
|
+
'.olusa-model{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#9a9a9a)}',
|
|
53
|
+
'.olusa-modelname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
54
|
+
'.olusa-modelcount{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#e8e8e8)}',
|
|
55
|
+
'.olusa-ver{color:var(--dsw-alias-label-secondary,#9a9a9a);font-size:11px;opacity:.8}',
|
|
56
|
+
'.olusa-hist{display:flex;flex-direction:column;gap:2px}',
|
|
57
|
+
'.olusa-histrow{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#9a9a9a);font-variant-numeric:tabular-nums}',
|
|
58
|
+
'.olusa-session{order:-1}',
|
|
59
|
+
'.olusa-bars{display:flex;flex-direction:column;gap:6px;padding:6px 8px;width:120px;min-width:120px;flex-shrink:0;box-sizing:border-box;background:transparent;border:none}',
|
|
60
|
+
'.olusa-barsrow{display:flex;flex-direction:column;gap:2px}',
|
|
61
|
+
'.olusa-barshead{display:flex;justify-content:space-between;align-items:baseline;gap:6px}',
|
|
62
|
+
'.olusa-barslabel{font-size:10px;color:var(--dsw-alias-label-secondary,#9a9a9a);white-space:nowrap}',
|
|
63
|
+
'.olusa-barsval{font-size:10px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#e8e8e8);white-space:nowrap}',
|
|
64
|
+
'.olusa-minibar{height:8px;border-radius:4px;background:rgba(128,128,128,.28);overflow:hidden}',
|
|
65
|
+
'.olusa-minibarfill{height:100%;border-radius:4px;transition:width .3s ease}',
|
|
66
|
+
].join('\n')
|
|
67
|
+
|
|
68
|
+
function injectCss() {
|
|
69
|
+
if (typeof document === 'undefined') return
|
|
70
|
+
if (document.querySelector('style[data-plugin-css="' + CSS_TAG_ID + '"]') !== null) return
|
|
71
|
+
const tag = document.createElement('style')
|
|
72
|
+
tag.dataset.pluginCss = CSS_TAG_ID
|
|
73
|
+
tag.textContent = CSS
|
|
74
|
+
;(document.head || document.documentElement).appendChild(tag)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pct = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 10) / 10 : null)
|
|
78
|
+
const colorFor = (p) => {
|
|
79
|
+
if (p === null) return 'var(--dsw-alias-label-secondary,#9a9a9a)'
|
|
80
|
+
if (p < 70) return 'var(--dsw-alias-state-success-primary,#9ece6a)'
|
|
81
|
+
if (p < 90) return 'var(--dsw-alias-state-warn-primary,#e0af68)'
|
|
82
|
+
return 'var(--dsw-alias-state-error-primary,#f7768e)'
|
|
83
|
+
}
|
|
84
|
+
const PERIOD_LABELS = { last_4_weeks: '近 4 周', weekly: '本周', session: '本次会话' }
|
|
85
|
+
const fmtTime = (iso) => {
|
|
86
|
+
try {
|
|
87
|
+
const d = new Date(iso)
|
|
88
|
+
const p2 = (n) => (n < 10 ? '0' + n : String(n))
|
|
89
|
+
return p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes())
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return String(iso || '')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function apply(ctx) {
|
|
96
|
+
injectCss()
|
|
97
|
+
|
|
98
|
+
const slots = ctx.get('slots')
|
|
99
|
+
if (slots === undefined) return
|
|
100
|
+
|
|
101
|
+
const connection = ctx.get('connection')
|
|
102
|
+
const rpc = (endpoint, payload) => {
|
|
103
|
+
if (connection === undefined || connection === null || typeof connection.rpc !== 'object' || typeof connection.rpc.call !== 'function') {
|
|
104
|
+
return Promise.resolve({ ok: false, error: { code: 'internal', message: 'connection 不可用' } })
|
|
105
|
+
}
|
|
106
|
+
return connection.rpc.call(RPC_CHANNEL, endpoint, payload).then((result) => {
|
|
107
|
+
if (result === null || typeof result !== 'object' || !('ok' in result)) {
|
|
108
|
+
return { ok: false, error: { code: 'internal', message: 'rpc 响应格式异常' } }
|
|
109
|
+
}
|
|
110
|
+
return result
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 面板组件 ──────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function BarRow(props) {
|
|
117
|
+
const p = pct(props.percent)
|
|
118
|
+
const width = p === null ? 0 : Math.min(100, Math.max(0, p))
|
|
119
|
+
const remaining = p === null ? null : Math.round((100 - p) * 10) / 10
|
|
120
|
+
return React.createElement('div', { className: 'olusa-block' + (props.em ? ' olusa-session' : '') },
|
|
121
|
+
React.createElement('div', { className: 'olusa-barhead' },
|
|
122
|
+
React.createElement('span', { className: 'olusa-barlabel' }, props.label),
|
|
123
|
+
React.createElement('span', { className: 'olusa-barval' }, p === null ? '暂无数据' : ('已用 ' + p + '% · 剩余 ' + remaining + '%'))
|
|
124
|
+
),
|
|
125
|
+
React.createElement('div', { className: 'olusa-bar' },
|
|
126
|
+
React.createElement('div', { className: 'olusa-barfill', style: { width: width + '%', background: colorFor(p) } })
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function LoginBody(props) {
|
|
132
|
+
return React.createElement('div', { className: 'olusa-block' },
|
|
133
|
+
React.createElement('div', { className: 'olusa-login-title' }, '尚未登录 Ollama 账号'),
|
|
134
|
+
React.createElement('ol', { className: 'olusa-steps' },
|
|
135
|
+
React.createElement('li', null,
|
|
136
|
+
React.createElement('a', { className: 'olusa-link', href: 'https://ollama.com/settings', target: '_blank', rel: 'noreferrer' }, '打开 ollama.com/settings'),
|
|
137
|
+
' 并登录(或注册)Ollama 账号'
|
|
138
|
+
),
|
|
139
|
+
React.createElement('li', null,
|
|
140
|
+
'在 API Keys 页 ',
|
|
141
|
+
React.createElement('a', { className: 'olusa-link', href: 'https://ollama.com/settings/keys', target: '_blank', rel: 'noreferrer' }, 'settings/keys'),
|
|
142
|
+
' 创建一个 API Key(以 ollama- 开头)'
|
|
143
|
+
),
|
|
144
|
+
React.createElement('li', null, '把 Key 粘贴到下方点击“检测”;或先在终端运行 ollama signin / ollama login,再点“重新检测本地凭证”')
|
|
145
|
+
),
|
|
146
|
+
React.createElement('input', { className: 'olusa-input', placeholder: '粘贴 API Key(ollama-…)', value: props.tokenInput, onChange: (e) => props.onInput(e.target.value), spellCheck: false }),
|
|
147
|
+
React.createElement('div', { className: 'olusa-rowbtns' },
|
|
148
|
+
React.createElement('button', { className: 'olusa-btn olusa-btn-primary', disabled: props.busy || !props.tokenInput.trim(), onClick: () => props.onCheck(props.tokenInput.trim()) }, props.busy ? '检测中…' : '检测'),
|
|
149
|
+
React.createElement('button', { className: 'olusa-btn', disabled: props.busy, onClick: () => props.onRetryLocal() }, '重新检测本地凭证')
|
|
150
|
+
),
|
|
151
|
+
React.createElement('div', { className: 'olusa-hint' }, '提示:检测成功后,Key 与用量快照会保存到 $DSH_HOME/storages/ollama-usage/usage.json(权限 600),跨对话/重启自动恢复并每 10 分钟自动刷新。')
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function UsageBody(props) {
|
|
156
|
+
const d = props.data
|
|
157
|
+
const periodLabel = PERIOD_LABELS[d.periodType] || (d.periodType ? String(d.periodType).toUpperCase() : '')
|
|
158
|
+
const periodText = periodLabel && d.periodStart && d.periodEnd
|
|
159
|
+
? (periodLabel + ' ' + d.periodStart + ' → ' + d.periodEnd)
|
|
160
|
+
: periodLabel
|
|
161
|
+
const costNum = d.cost !== null && d.cost !== undefined && d.cost !== '' ? Number(d.cost) : NaN
|
|
162
|
+
const costText = Number.isFinite(costNum) ? '$' + costNum.toFixed(2) : null
|
|
163
|
+
const models = Array.isArray(d.models) ? d.models.slice(0, 5) : []
|
|
164
|
+
const history = Array.isArray(props.history) ? props.history.slice(-5).reverse() : []
|
|
165
|
+
return React.createElement('div', { className: 'olusa-block' },
|
|
166
|
+
(periodText ? React.createElement('div', { className: 'olusa-hint' }, periodText) : null),
|
|
167
|
+
React.createElement(BarRow, { label: '会话用量(每 5 小时)', percent: d.sessionPercent, em: true }),
|
|
168
|
+
React.createElement(BarRow, { label: '本周用量(Weekly)', percent: d.weeklyPercent }),
|
|
169
|
+
(costText !== null
|
|
170
|
+
? React.createElement('div', { className: 'olusa-row' },
|
|
171
|
+
React.createElement('span', null, '近 4 周费用'),
|
|
172
|
+
React.createElement('span', null, costText))
|
|
173
|
+
: null),
|
|
174
|
+
(models.length > 0
|
|
175
|
+
? React.createElement('div', { className: 'olusa-models' },
|
|
176
|
+
React.createElement('div', { className: 'olusa-hint' }, '模型请求数(本周)'),
|
|
177
|
+
models.map((m) => React.createElement('div', { className: 'olusa-model', key: m.name },
|
|
178
|
+
React.createElement('span', { className: 'olusa-modelname' }, m.name),
|
|
179
|
+
React.createElement('span', { className: 'olusa-modelcount' }, String(m.requestCount) + ' 次')
|
|
180
|
+
)))
|
|
181
|
+
: null),
|
|
182
|
+
(history.length > 0
|
|
183
|
+
? React.createElement('div', { className: 'olusa-hist' },
|
|
184
|
+
React.createElement('div', { className: 'olusa-hint' }, '最近记录(会话用量)'),
|
|
185
|
+
history.map((h, i) => React.createElement('div', { className: 'olusa-histrow', key: String(i) },
|
|
186
|
+
React.createElement('span', null, fmtTime(h.at)),
|
|
187
|
+
React.createElement('span', null, (h.sessionPercent === null || h.sessionPercent === undefined) ? '—' : (h.sessionPercent + '%'))
|
|
188
|
+
)))
|
|
189
|
+
: null),
|
|
190
|
+
(props.updatedAt
|
|
191
|
+
? React.createElement('div', { className: 'olusa-hint' }, '上次更新:' + fmtTime(props.updatedAt) + '(每 10 分钟自动刷新)')
|
|
192
|
+
: null),
|
|
193
|
+
React.createElement('div', { className: props.persisted ? 'olusa-hint olusa-ok' : 'olusa-error' },
|
|
194
|
+
props.persisted ? '✓ 已持久化到本地($DSH_HOME/storages/ollama-usage/usage.json)' : '⚠ 未能写入本地文件(持久化失败,仅当前进程内存)')
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function OllamaUsagePanel() {
|
|
199
|
+
const [phase, setPhase] = React.useState('login')
|
|
200
|
+
const [data, setData] = React.useState(null)
|
|
201
|
+
const [history, setHistory] = React.useState(null)
|
|
202
|
+
const [updatedAt, setUpdatedAt] = React.useState(null)
|
|
203
|
+
const [persisted, setPersisted] = React.useState(null)
|
|
204
|
+
const [error, setError] = React.useState('')
|
|
205
|
+
const [busy, setBusy] = React.useState(false)
|
|
206
|
+
const [tokenInput, setTokenInput] = React.useState('')
|
|
207
|
+
|
|
208
|
+
const applyResult = (res) => {
|
|
209
|
+
if (res && res.ok) {
|
|
210
|
+
const v = res.value || {}
|
|
211
|
+
setData(v.usage || null)
|
|
212
|
+
setHistory(v.history || null)
|
|
213
|
+
setUpdatedAt(v.updatedAt || null)
|
|
214
|
+
if (typeof v.persisted === 'boolean') setPersisted(v.persisted)
|
|
215
|
+
setError('')
|
|
216
|
+
setPhase('usage')
|
|
217
|
+
} else if (res && res.error && (res.error.code === 'no-token' || res.error.code === 'no-data')) {
|
|
218
|
+
setError('')
|
|
219
|
+
setPhase('login')
|
|
220
|
+
} else if (data) {
|
|
221
|
+
setError((res && res.error && res.error.message) || '刷新失败')
|
|
222
|
+
} else {
|
|
223
|
+
setError((res && res.error && res.error.message) || '未知错误')
|
|
224
|
+
setPhase('error')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const doCheck = (token) => {
|
|
229
|
+
setBusy(true)
|
|
230
|
+
rpc('check', token ? { token: token } : {}).then(applyResult).catch((e) => {
|
|
231
|
+
if (data) setError('刷新失败:' + String((e && e.message) || e))
|
|
232
|
+
else { setError('调用失败:' + String((e && e.message) || e)); setPhase('error') }
|
|
233
|
+
}).then(() => setBusy(false))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const doForget = () => {
|
|
237
|
+
rpc('forget', {}).then(() => {
|
|
238
|
+
setData(null); setHistory(null); setUpdatedAt(null); setPersisted(null); setError(''); setPhase('login')
|
|
239
|
+
}).catch(() => {})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
React.useEffect(() => {
|
|
243
|
+
let disposed = false
|
|
244
|
+
const refresh = () => rpc('snapshot', {}).then((res) => {
|
|
245
|
+
if (disposed) return
|
|
246
|
+
if (res && res.ok && res.value && res.value.usage) {
|
|
247
|
+
setData(res.value.usage); setHistory(res.value.history || null); setUpdatedAt(res.value.updatedAt || null); setError(''); setPhase('usage')
|
|
248
|
+
}
|
|
249
|
+
}).catch(() => {})
|
|
250
|
+
refresh()
|
|
251
|
+
const dispose = ctx.interval(refresh, 60000)
|
|
252
|
+
return () => { disposed = true; dispose() }
|
|
253
|
+
}, [])
|
|
254
|
+
|
|
255
|
+
const body = phase === 'login'
|
|
256
|
+
? React.createElement(LoginBody, { tokenInput: tokenInput, onInput: setTokenInput, onCheck: doCheck, onRetryLocal: () => doCheck(''), busy: busy })
|
|
257
|
+
: phase === 'usage' && data
|
|
258
|
+
? React.createElement('div', { className: 'olusa-block' },
|
|
259
|
+
React.createElement(UsageBody, { data: data, history: history, updatedAt: updatedAt, persisted: persisted }),
|
|
260
|
+
(error ? React.createElement('div', { className: 'olusa-error' }, String(error)) : null),
|
|
261
|
+
React.createElement('div', { className: 'olusa-rowbtns' },
|
|
262
|
+
React.createElement('button', { className: 'olusa-btn', disabled: busy, onClick: () => doForget() }, '清除已存 Key')
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
: React.createElement('div', { className: 'olusa-block' },
|
|
266
|
+
React.createElement('div', { className: 'olusa-error' }, String(error || '查询失败')),
|
|
267
|
+
React.createElement('div', { className: 'olusa-hint' },
|
|
268
|
+
'请确认已登录:',
|
|
269
|
+
React.createElement('a', { className: 'olusa-link', href: 'https://ollama.com/settings', target: '_blank', rel: 'noreferrer' }, '打开 ollama.com/settings'),
|
|
270
|
+
',或粘贴 API Key 重试。'
|
|
271
|
+
),
|
|
272
|
+
React.createElement('input', { className: 'olusa-input', placeholder: '粘贴 API Key(ollama-…)', value: tokenInput, onChange: (e) => setTokenInput(e.target.value), spellCheck: false }),
|
|
273
|
+
React.createElement('div', { className: 'olusa-rowbtns' },
|
|
274
|
+
React.createElement('button', { className: 'olusa-btn olusa-btn-primary', disabled: busy || !tokenInput.trim(), onClick: () => doCheck(tokenInput.trim()) }, busy ? '检测中…' : '检测'),
|
|
275
|
+
React.createElement('button', { className: 'olusa-btn', disabled: busy, onClick: () => doCheck('') }, '重试')
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return React.createElement('div', { className: 'olusa-panel' },
|
|
280
|
+
React.createElement('div', { className: 'olusa-head' },
|
|
281
|
+
React.createElement('span', { className: 'olusa-title' }, '⚡ Ollama 用量余量'),
|
|
282
|
+
React.createElement('button', { className: 'olusa-btn', disabled: busy, onClick: () => doCheck('') }, busy ? '查询中…' : '刷新')
|
|
283
|
+
),
|
|
284
|
+
body,
|
|
285
|
+
React.createElement('div', { className: 'olusa-ver' }, 'dsh-ollama-usage · v0.1.0')
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── 侧边栏双横条 ──────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
function UsageBars(props) {
|
|
292
|
+
const [data, setData] = React.useState(null)
|
|
293
|
+
const [updatedAt, setUpdatedAt] = React.useState(null)
|
|
294
|
+
|
|
295
|
+
React.useEffect(() => {
|
|
296
|
+
let disposed = false
|
|
297
|
+
const refresh = () => rpc('snapshot', {}).then((res) => {
|
|
298
|
+
if (disposed) return
|
|
299
|
+
if (res && res.ok && res.value) { setData(res.value.usage || null); setUpdatedAt(res.value.updatedAt || null) }
|
|
300
|
+
else { setData(null); setUpdatedAt(null) }
|
|
301
|
+
}).catch(() => { if (!disposed) { setData(null); setUpdatedAt(null) } })
|
|
302
|
+
refresh()
|
|
303
|
+
const dispose = ctx.interval(refresh, 60000)
|
|
304
|
+
return () => { disposed = true; dispose() }
|
|
305
|
+
}, [])
|
|
306
|
+
|
|
307
|
+
// 防御性判断:仅显式 wide === false(窄栏)才隐藏
|
|
308
|
+
if (props.wide === false) return null
|
|
309
|
+
|
|
310
|
+
const sp = data && typeof data.sessionPercent === 'number' ? Math.min(100, Math.max(0, data.sessionPercent)) : null
|
|
311
|
+
const wp = data && typeof data.weeklyPercent === 'number' ? Math.min(100, Math.max(0, data.weeklyPercent)) : null
|
|
312
|
+
const tip = sp === null
|
|
313
|
+
? 'Ollama 用量:未登录(设置 → Ollama 用量 配置)'
|
|
314
|
+
: 'Ollama 会话用量(每 5 小时):' + (Math.round(sp * 10) / 10) + '% · 周用量:' + (Math.round(wp * 10) / 10) + '%' + (updatedAt ? ' · 更新于 ' + fmtTime(updatedAt) : '')
|
|
315
|
+
const rows = [
|
|
316
|
+
{ label: '会话5h', p: sp, color: SESSION_COLOR },
|
|
317
|
+
{ label: '周用量', p: wp, color: WEEKLY_COLOR },
|
|
318
|
+
]
|
|
319
|
+
return React.createElement('div', { className: 'olusa-bars', title: tip },
|
|
320
|
+
rows.map((b) => React.createElement('div', { className: 'olusa-barsrow', key: b.label },
|
|
321
|
+
React.createElement('div', { className: 'olusa-barshead' },
|
|
322
|
+
React.createElement('span', { className: 'olusa-barslabel' }, b.label),
|
|
323
|
+
React.createElement('span', { className: 'olusa-barsval' }, b.p === null ? '—' : (Math.round(b.p * 10) / 10) + '%')
|
|
324
|
+
),
|
|
325
|
+
React.createElement('div', { className: 'olusa-minibar' },
|
|
326
|
+
React.createElement('div', { className: 'olusa-minibarfill', style: { width: (b.p === null ? 0 : b.p) + '%', background: b.color } })
|
|
327
|
+
)
|
|
328
|
+
))
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── 槽位注册 ──────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
slots.inject('tool.view.cordis', () => slots.register(
|
|
335
|
+
{ name: 'tool.view.cordis', key: 'self' },
|
|
336
|
+
() => React.createElement(OllamaUsagePanel)
|
|
337
|
+
))
|
|
338
|
+
|
|
339
|
+
slots.inject('settings.section', () => slots.register(
|
|
340
|
+
{ name: 'settings.section', id: 'ollama-usage', order: 40, label: 'Ollama 用量' },
|
|
341
|
+
() => React.createElement(OllamaUsagePanel)
|
|
342
|
+
))
|
|
343
|
+
|
|
344
|
+
slots.inject('sidebar.footer.action', () => slots.register(
|
|
345
|
+
{ name: 'sidebar.footer.action', id: 'olusa-bars', order: -10, label: 'Ollama 用量' },
|
|
346
|
+
(props) => React.createElement(UsageBars, { wide: !!(props && props.wide) })
|
|
347
|
+
))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
exports.apply = apply
|
|
351
|
+
exports.inject = ['slots', 'connection', 'timer']
|
|
352
|
+
return module.exports
|
|
353
|
+
},
|
|
354
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ollama-usage 宿主插件。
|
|
3
|
+
*
|
|
4
|
+
* 单一 Loader 行(见 cordis.patch.yml)挂载本模块,职责:
|
|
5
|
+
* 1. 读取 Ollama 凭证:优先持久化的 API Key(settings 面板检测成功后写入),
|
|
6
|
+
* 兼容旧版 CLI 的 ~/.ollama/auth.json;
|
|
7
|
+
* 2. 请求 https://ollama.com/api/usage 获取用量(会话 5h 桶 + 周桶);
|
|
8
|
+
* 3. 把 Key / 用量快照 / 历史记录持久化到
|
|
9
|
+
* $DSH_HOME/storages/ollama-usage/usage.json(权限 0600),跨对话/重启恢复;
|
|
10
|
+
* 4. 每 10 分钟自动刷新一次(浏览器页面关闭也持续,Key 失效自动清除);
|
|
11
|
+
* 5. 在 webServer 上注册 /ollama-usage 前缀路由,与客户端走 Connection RPC
|
|
12
|
+
* 信封协议(check / snapshot / forget / auth-state 四个端点)。
|
|
13
|
+
*
|
|
14
|
+
* 纯 JavaScript(ESM),仅依赖 node: 内建能力(含全局 fetch,Node >= 18)与
|
|
15
|
+
* @deepseek-ai/dsh-home-paths。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
22
|
+
|
|
23
|
+
export const name = 'ollama-usage'
|
|
24
|
+
|
|
25
|
+
const RPC_CHANNEL = '/ollama-usage'
|
|
26
|
+
const USAGE_URL = 'https://ollama.com/api/usage'
|
|
27
|
+
const FETCH_TIMEOUT_MS = 20000
|
|
28
|
+
const AUTO_REFRESH_MS = 10 * 60 * 1000
|
|
29
|
+
const HISTORY_CAP = 24
|
|
30
|
+
const BODY_LIMIT = 64 * 1024
|
|
31
|
+
|
|
32
|
+
/* ------------------------------------------------------------------ *
|
|
33
|
+
* 持久化($DSH_HOME/storages/ollama-usage/usage.json)
|
|
34
|
+
* 文件结构:{ token, updatedAt, usage, history[] }
|
|
35
|
+
* ------------------------------------------------------------------ */
|
|
36
|
+
|
|
37
|
+
function filePath() {
|
|
38
|
+
return join(resolveDshHome(), 'storages', 'ollama-usage', 'usage.json')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readFile() {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(filePath(), 'utf8'))
|
|
44
|
+
if (parsed && typeof parsed === 'object') return parsed
|
|
45
|
+
} catch {
|
|
46
|
+
/* 文件不存在或损坏 */
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeFile(obj) {
|
|
52
|
+
try {
|
|
53
|
+
const dir = join(resolveDshHome(), 'storages', 'ollama-usage')
|
|
54
|
+
mkdirSync(dir, { recursive: true })
|
|
55
|
+
writeFileSync(filePath(), JSON.stringify(obj), { mode: 0o600 })
|
|
56
|
+
return true
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ------------------------------------------------------------------ *
|
|
63
|
+
* 凭证
|
|
64
|
+
* ------------------------------------------------------------------ */
|
|
65
|
+
|
|
66
|
+
/** 兼容旧版 ollama CLI:~/.ollama/auth.json 里的 { "token": "..." }。 */
|
|
67
|
+
function readLocalToken() {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(readFileSync(join(homedir(), '.ollama', 'auth.json'), 'utf8'))
|
|
70
|
+
const token = parsed && typeof parsed.token === 'string' ? parsed.token.trim() : ''
|
|
71
|
+
return token || null
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------------ *
|
|
78
|
+
* 用量查询与解析
|
|
79
|
+
* ------------------------------------------------------------------ */
|
|
80
|
+
|
|
81
|
+
async function fetchUsage(token) {
|
|
82
|
+
const res = await fetch(USAGE_URL, {
|
|
83
|
+
headers: { Authorization: 'Bearer ' + token },
|
|
84
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
85
|
+
})
|
|
86
|
+
return { status: res.status, body: await res.text() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** API 返回的 usage 是 0~1 的小数(如 0.213 = 21.3%),统一转成百分比数字。 */
|
|
90
|
+
function toPercent(v) {
|
|
91
|
+
let n = null
|
|
92
|
+
if (typeof v === 'number' && Number.isFinite(v)) n = v
|
|
93
|
+
else if (typeof v === 'string' && v.trim() !== '') {
|
|
94
|
+
const c = Number(v)
|
|
95
|
+
if (Number.isFinite(c)) n = c
|
|
96
|
+
}
|
|
97
|
+
if (n === null) return null
|
|
98
|
+
const pct = n <= 1 ? n * 100 : n
|
|
99
|
+
return Math.round(pct * 10) / 10
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseUsage(body, status) {
|
|
103
|
+
let json = null
|
|
104
|
+
try {
|
|
105
|
+
json = JSON.parse(body)
|
|
106
|
+
} catch {
|
|
107
|
+
return { ok: false, message: '响应不是合法 JSON' }
|
|
108
|
+
}
|
|
109
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) {
|
|
110
|
+
return { ok: false, message: '响应格式异常' }
|
|
111
|
+
}
|
|
112
|
+
const limits = (json && json.limits) || {}
|
|
113
|
+
const activity = (json && json.activity) || {}
|
|
114
|
+
const sessionUsage = limits.session && typeof limits.session === 'object' ? toPercent(limits.session.usage) : null
|
|
115
|
+
const weeklyUsage = limits.weekly && typeof limits.weekly === 'object' ? toPercent(limits.weekly.usage) : null
|
|
116
|
+
// 模型请求数:真实响应放在 limits.weekly.models(有时 activity.models 也有),按优先级取
|
|
117
|
+
const weeklyModels = limits.weekly && Array.isArray(limits.weekly.models) ? limits.weekly.models : null
|
|
118
|
+
const sessionModels = limits.session && Array.isArray(limits.session.models) ? limits.session.models : null
|
|
119
|
+
const activityModels = Array.isArray(activity.models) ? activity.models : null
|
|
120
|
+
const rawModels =
|
|
121
|
+
(weeklyModels && weeklyModels.length ? weeklyModels : null) ||
|
|
122
|
+
(sessionModels && sessionModels.length ? sessionModels : null) ||
|
|
123
|
+
(activityModels || [])
|
|
124
|
+
const models = rawModels
|
|
125
|
+
.map((m) => ({ name: String((m && m.name) || ''), requestCount: Number((m && m.request_count)) || 0 }))
|
|
126
|
+
.filter((m) => m.name)
|
|
127
|
+
.sort((a, b) => b.requestCount - a.requestCount)
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
usage: {
|
|
131
|
+
cost: typeof activity.cost === 'string' ? activity.cost : null,
|
|
132
|
+
periodType: activity.period && typeof activity.period.type === 'string' ? activity.period.type : null,
|
|
133
|
+
periodStart: activity.period && typeof activity.period.starting_at === 'string' ? activity.period.starting_at : null,
|
|
134
|
+
periodEnd: activity.period && typeof activity.period.ending_at === 'string' ? activity.period.ending_at : null,
|
|
135
|
+
sessionPercent: sessionUsage,
|
|
136
|
+
weeklyPercent: weeklyUsage,
|
|
137
|
+
models,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 用 token 查询并把结果写入持久化文件;返回 RpcResult({ ok, value|error })。 */
|
|
143
|
+
async function checkWithToken(token) {
|
|
144
|
+
let res
|
|
145
|
+
try {
|
|
146
|
+
res = await fetchUsage(token)
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return { ok: false, error: { code: 'network', message: String((e && e.message) || e) } }
|
|
149
|
+
}
|
|
150
|
+
if (res.status === 401 || res.status === 403) {
|
|
151
|
+
// 凭证失效:清掉持久化的 token,保留历史
|
|
152
|
+
const rec = readFile()
|
|
153
|
+
if (rec && rec.token) writeFile({ token: null, updatedAt: rec.updatedAt, usage: rec.usage, history: rec.history || [] })
|
|
154
|
+
return { ok: false, error: { code: 'unauthorized', message: '凭证无效或已过期(' + res.status + '),请重新登录或更换 API Key' } }
|
|
155
|
+
}
|
|
156
|
+
if (res.status >= 400) {
|
|
157
|
+
return { ok: false, error: { code: 'http-error', message: res.body || ('HTTP ' + res.status) } }
|
|
158
|
+
}
|
|
159
|
+
const parsed = parseUsage(res.body, res.status)
|
|
160
|
+
if (!parsed.ok) {
|
|
161
|
+
return { ok: false, error: { code: 'internal', message: parsed.message } }
|
|
162
|
+
}
|
|
163
|
+
// 持久化:token + 快照 + 历史(保留最近 24 条)
|
|
164
|
+
const now = new Date().toISOString()
|
|
165
|
+
const prev = readFile()
|
|
166
|
+
const history = Array.isArray(prev && prev.history) ? prev.history.slice() : []
|
|
167
|
+
history.push({ at: now, sessionPercent: parsed.usage.sessionPercent, weeklyPercent: parsed.usage.weeklyPercent })
|
|
168
|
+
const trimmed = history.length > HISTORY_CAP ? history.slice(history.length - HISTORY_CAP) : history
|
|
169
|
+
const persisted = writeFile({ token, updatedAt: now, usage: parsed.usage, history: trimmed })
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
value: { usage: parsed.usage, updatedAt: now, persisted, history: trimmed.slice(-5) },
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 自动刷新:读持久化 token 重新查询(页面关闭也持续)。 */
|
|
177
|
+
async function autoRefresh() {
|
|
178
|
+
try {
|
|
179
|
+
const rec = readFile()
|
|
180
|
+
if (rec && rec.token) await checkWithToken(rec.token)
|
|
181
|
+
} catch {
|
|
182
|
+
/* 静默失败,下一轮再试 */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------------ *
|
|
187
|
+
* RPC 端点分派
|
|
188
|
+
* ------------------------------------------------------------------ */
|
|
189
|
+
|
|
190
|
+
async function dispatch(endpoint, payload) {
|
|
191
|
+
if (endpoint === 'check') {
|
|
192
|
+
const manual = payload && typeof payload.token === 'string' ? payload.token.trim() : ''
|
|
193
|
+
let token = manual
|
|
194
|
+
let rec = null
|
|
195
|
+
if (!token) {
|
|
196
|
+
rec = readFile()
|
|
197
|
+
token = (rec && rec.token) || null
|
|
198
|
+
}
|
|
199
|
+
if (!token) token = readLocalToken()
|
|
200
|
+
if (!token) return { ok: false, error: { code: 'no-token', message: '未检测到本地登录凭证,也未提供 API Key' } }
|
|
201
|
+
return checkWithToken(token)
|
|
202
|
+
}
|
|
203
|
+
if (endpoint === 'snapshot') {
|
|
204
|
+
const rec = readFile()
|
|
205
|
+
if (!rec || !rec.usage) return { ok: false, error: { code: 'no-data', message: '暂无持久化的用量数据' } }
|
|
206
|
+
return { ok: true, value: { tokenPersisted: !!rec.token, updatedAt: rec.updatedAt, history: (rec.history || []).slice(-5), usage: rec.usage } }
|
|
207
|
+
}
|
|
208
|
+
if (endpoint === 'forget') {
|
|
209
|
+
const rec = readFile()
|
|
210
|
+
if (rec) writeFile({ token: null, updatedAt: rec.updatedAt, usage: rec.usage, history: rec.history || [] })
|
|
211
|
+
return { ok: true, value: null }
|
|
212
|
+
}
|
|
213
|
+
if (endpoint === 'auth-state') {
|
|
214
|
+
const rec = readFile()
|
|
215
|
+
return { ok: true, value: { localTokenPresent: !!(rec && rec.token) } }
|
|
216
|
+
}
|
|
217
|
+
throw new Error('unknown bridge endpoint ' + JSON.stringify(endpoint))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ------------------------------------------------------------------ *
|
|
221
|
+
* HTTP 桥(镜像 Connection RPC 信封协议,参照 dsh-yolo-mode)
|
|
222
|
+
* 请求: POST /ollama-usage/<endpoint> { type:'client-request', rpcId, method, payload }
|
|
223
|
+
* 响应: { type:'server-response', rpcId, result:{ ok, value|error } }
|
|
224
|
+
* ------------------------------------------------------------------ */
|
|
225
|
+
|
|
226
|
+
function readRequestBody(req, limit) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
const chunks = []
|
|
229
|
+
let size = 0
|
|
230
|
+
let done = false
|
|
231
|
+
const fail = (err) => {
|
|
232
|
+
if (done) return
|
|
233
|
+
done = true
|
|
234
|
+
reject(err)
|
|
235
|
+
}
|
|
236
|
+
req.on('data', (chunk) => {
|
|
237
|
+
if (done) return
|
|
238
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8')
|
|
239
|
+
size += buf.length
|
|
240
|
+
if (size > limit) {
|
|
241
|
+
fail(Object.assign(new Error('request body too large'), { code: 'LIMIT' }))
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
chunks.push(buf)
|
|
245
|
+
})
|
|
246
|
+
req.on('end', () => {
|
|
247
|
+
if (done) return
|
|
248
|
+
done = true
|
|
249
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
250
|
+
})
|
|
251
|
+
req.on('error', (err) => fail(err))
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isLoopbackHostname(hostname) {
|
|
256
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
257
|
+
const parts = hostname.split('.')
|
|
258
|
+
return (
|
|
259
|
+
parts.length === 4 &&
|
|
260
|
+
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) &&
|
|
261
|
+
parts[0] === '127'
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isLoopbackHost(rawHost) {
|
|
266
|
+
let host = typeof rawHost === 'string' ? rawHost.trim() : ''
|
|
267
|
+
if (host.length === 0) return false
|
|
268
|
+
if (host.startsWith('[')) {
|
|
269
|
+
const close = host.indexOf(']')
|
|
270
|
+
if (close === -1) return false
|
|
271
|
+
host = host.slice(0, close + 1)
|
|
272
|
+
} else {
|
|
273
|
+
const colon = host.indexOf(':')
|
|
274
|
+
if (colon !== -1) host = host.slice(0, colon)
|
|
275
|
+
}
|
|
276
|
+
return isLoopbackHostname(host)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function endpointFromPath(channel, pathname) {
|
|
280
|
+
if (!pathname.startsWith(channel + '/')) return undefined
|
|
281
|
+
const endpoint = pathname.slice(channel.length + 1)
|
|
282
|
+
if (endpoint.length === 0) return undefined
|
|
283
|
+
if (endpoint.split('/').some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
|
|
284
|
+
return undefined
|
|
285
|
+
}
|
|
286
|
+
return endpoint
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function sendPlain(res, status, text) {
|
|
290
|
+
res.statusCode = status
|
|
291
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
292
|
+
res.end(text)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function sendJson(res, status, payload) {
|
|
296
|
+
res.statusCode = status
|
|
297
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
298
|
+
res.end(JSON.stringify(payload))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildServerResponse(rpcId, result) {
|
|
302
|
+
return { type: 'server-response', rpcId, result }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildBadRequestResponse(rpcId) {
|
|
306
|
+
return buildServerResponse(rpcId, {
|
|
307
|
+
ok: false,
|
|
308
|
+
error: { code: 'bad-request', message: 'invalid client-request message', details: { issues: [] } },
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function handleBridgeRequest(req, res) {
|
|
313
|
+
if (typeof req.method !== 'string' || req.method !== 'POST') {
|
|
314
|
+
sendPlain(res, 404, 'not found')
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const contentType = (req.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase()
|
|
318
|
+
if (contentType !== 'application/json') {
|
|
319
|
+
sendPlain(res, 415, 'content type must be application/json')
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const hostHeader = req.headers.host
|
|
323
|
+
if (hostHeader === undefined || !isLoopbackHost(hostHeader)) {
|
|
324
|
+
sendPlain(res, 403, 'forbidden')
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
let raw
|
|
328
|
+
try {
|
|
329
|
+
raw = await readRequestBody(req, BODY_LIMIT)
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err && err.code === 'LIMIT') {
|
|
332
|
+
sendPlain(res, 413, 'request body too large')
|
|
333
|
+
} else {
|
|
334
|
+
sendPlain(res, 400, 'body is not JSON')
|
|
335
|
+
}
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
let body
|
|
339
|
+
try {
|
|
340
|
+
body = raw.length === 0 ? {} : JSON.parse(raw)
|
|
341
|
+
} catch {
|
|
342
|
+
sendPlain(res, 400, 'body is not JSON')
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const envelope = body
|
|
346
|
+
if (
|
|
347
|
+
envelope === null ||
|
|
348
|
+
typeof envelope !== 'object' ||
|
|
349
|
+
Array.isArray(envelope) ||
|
|
350
|
+
envelope.type !== 'client-request' ||
|
|
351
|
+
typeof envelope.rpcId !== 'string' ||
|
|
352
|
+
typeof envelope.method !== 'string'
|
|
353
|
+
) {
|
|
354
|
+
sendJson(res, 200, buildBadRequestResponse('invalid-request'))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
const rawUrl = typeof req.url === 'string' ? req.url : '/'
|
|
358
|
+
const pathname = new URL(rawUrl, 'http://dsh.internal').pathname
|
|
359
|
+
const endpoint = endpointFromPath(RPC_CHANNEL, pathname)
|
|
360
|
+
if (endpoint === undefined || envelope.method !== endpoint) {
|
|
361
|
+
sendJson(res, 200, buildBadRequestResponse(envelope.rpcId))
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
const result = await dispatch(endpoint, envelope.payload)
|
|
365
|
+
sendJson(res, 200, buildServerResponse(envelope.rpcId, result))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ------------------------------------------------------------------ *
|
|
369
|
+
* 插件入口
|
|
370
|
+
* ------------------------------------------------------------------ */
|
|
371
|
+
|
|
372
|
+
export default {
|
|
373
|
+
inject: ['timer'],
|
|
374
|
+
apply(ctx) {
|
|
375
|
+
const webServer = ctx.get('webServer')
|
|
376
|
+
if (webServer !== undefined) {
|
|
377
|
+
ctx.effect(
|
|
378
|
+
() => webServer.register({ kind: 'prefix', path: RPC_CHANNEL, handler: handleBridgeRequest }),
|
|
379
|
+
'ollama-usage: rpc bridge',
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
ctx.effect(() => ctx.interval(autoRefresh, AUTO_REFRESH_MS), 'ollama-usage: auto refresh')
|
|
383
|
+
autoRefresh()
|
|
384
|
+
},
|
|
385
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-ollama-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 插件:可视化 Ollama Pro 账号用量余量(每 5 小时会话用量 + 周用量),支持侧边栏双横条、设置页面板、API Key 与用量快照持久化、自动刷新与登录引导。Ollama Pro usage & remaining-quota visualization for DeepSeek Harness: 5-hour session + weekly quota bars in the sidebar footer, a full settings panel, persisted API key and usage snapshots, auto-refresh and login guidance.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"bundle": {
|
|
18
|
+
"patch": "./cordis.patch.yml"
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"platform": "web",
|
|
22
|
+
"inject": ["slots", "connection", "timer"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dshhub": {
|
|
26
|
+
"schemaVersion": 1,
|
|
27
|
+
"displayName": "dsh-ollama-usage",
|
|
28
|
+
"summary": "Ollama Pro 用量余量可视化:会话(5h)/周用量双横条 + 设置页完整面板,Key 与快照持久化,自动刷新,登录引导。",
|
|
29
|
+
"categories": ["用量", "Web UI", "观测"],
|
|
30
|
+
"surfaces": ["host", "web"],
|
|
31
|
+
"capabilities": {
|
|
32
|
+
"provides": []
|
|
33
|
+
},
|
|
34
|
+
"compatibility": {
|
|
35
|
+
"dsh": ">=0.1.0-rc.5",
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"permissions": {
|
|
39
|
+
"network": ["https://ollama.com"]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"lib",
|
|
44
|
+
"cordis.patch.yml",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6"
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/SeverusZh/dsh-ollama-usage.git"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/SeverusZh/dsh-ollama-usage#readme",
|
|
56
|
+
"keywords": [
|
|
57
|
+
"deepseek",
|
|
58
|
+
"deepseek-harness",
|
|
59
|
+
"dsh",
|
|
60
|
+
"dsh-plugin",
|
|
61
|
+
"client-plugin",
|
|
62
|
+
"ollama",
|
|
63
|
+
"ollama-pro",
|
|
64
|
+
"usage",
|
|
65
|
+
"quota",
|
|
66
|
+
"session",
|
|
67
|
+
"web-ui",
|
|
68
|
+
"zh",
|
|
69
|
+
"en"
|
|
70
|
+
],
|
|
71
|
+
"license": "MIT"
|
|
72
|
+
}
|