chore: 初始化 dsh-enhancements 留档仓库
- 插件源码:dsh-usage(用户头像 + DeepSeek 官方 API 消耗面板) - 工具:dl-gh.mjs(node fetch 下载 gh,绕开沙箱 curl 限制) - 文档:PLAN.md 路线图 + mmx 视觉桥接补丁说明 - 资产:DeepSeek 鲸鱼图标
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# dsh-usage — 用户头像 + API 消耗面板
|
||||
|
||||
在 DeepSeek Harness 侧边栏底部(设置旁)添加一个 Codex 式**用户头像**入口,
|
||||
点击弹出模态面板,展示 **DeepSeek 官方 API 余额/消耗**,并可一键打开官网用量页。
|
||||
|
||||
## 效果
|
||||
|
||||
- 侧边栏底部(设置按钮旁):圆形 🐋 头像按钮(窄栏只显图标,宽栏带"API 用量"文字)
|
||||
- 点击 → 居中模态面板:
|
||||
- 账户可用状态
|
||||
- 各币种:总额 / 赠送余额 / 充值余额(来自官方 `GET https://api.deepseek.com/user/balance`)
|
||||
- 按钮:刷新 / 打开官网用量页 / 关闭
|
||||
|
||||
## 实现
|
||||
|
||||
| 端 | 机制 |
|
||||
|---|---|
|
||||
| Host | `harness.handle('usage/balance')`:shell 运行 node 脚本读取 `%DSH_HOME%\.credentials.yaml` 的 `DEEPSEEK_API_KEY`,调用官方余额 API,返回 JSON(密钥不出进程日志);`usage/openPortal`:打开官网用量页 |
|
||||
| Client | `sidebar.footer.action` 注册头像按钮;`shell.overlay` 注册模态面板;两个组件通过插件内微型 store 共享开关状态;样式用主题 CSS 变量(`--dsw-alias-*`) |
|
||||
|
||||
## 为什么用官方 API
|
||||
|
||||
DeepSeek 官方提供余额接口(`/user/balance`),返回 `is_available` + `balance_infos[]`。
|
||||
用量明细官网暂无公开 API,故面板提供"打开官网用量页"(platform.deepseek.com/usage)兜底。
|
||||
|
||||
## 文件
|
||||
|
||||
- `host.js` — Host 半(RPC + 官方 API 调用)
|
||||
- `client.js` — Client 半(头像 + 模态 UI)
|
||||
- 部署方式:通过动态 Cordis 插件(`cordis_define` / `cordis_run`)或本仓库 `presets/` 下的预设文件
|
||||
|
||||
## 前置
|
||||
|
||||
- `%DSH_HOME%\.credentials.yaml` 中存在 `DEEPSEEK_API_KEY`
|
||||
- 官方余额 API 需可访问(api.deepseek.com)
|
||||
@@ -0,0 +1,111 @@
|
||||
// dsh-usage 插件 — Client 半
|
||||
// 注册两处 UI:
|
||||
// sidebar.footer.action 'dsh-usage-avatar' — 设置旁的 🐋 用户头像按钮
|
||||
// shell.overlay 'dsh-usage-modal' — 点击头像弹出的官方余额模态面板
|
||||
// 两个组件通过插件内微型 store(open + listeners)共享开关状态。
|
||||
return {
|
||||
apply(ctx) {
|
||||
const slots = ctx.get('slots')
|
||||
if (slots === undefined) return
|
||||
styles.insert(`
|
||||
.dsh-ua-avatar { display:flex; align-items:center; gap:8px; width:100%; padding:7px 10px; background:transparent; border:0; border-radius:8px; color:var(--dsw-alias-label-primary); cursor:pointer; font:inherit; }
|
||||
.dsh-ua-avatar:hover { background:var(--dsw-alias-bg-layer-2); }
|
||||
.dsh-ua-avatar.active { background:var(--dsw-alias-bg-layer-2); }
|
||||
.dsh-ua-avatar-icon { width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:15px; flex:none; background:var(--dsw-alias-bg-layer-2); border:1px solid var(--dsw-alias-border-l1); }
|
||||
.dsh-ua-avatar-label { font-size:12px; color:var(--dsw-alias-label-secondary); }
|
||||
.dsh-ua-backdrop { position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; pointer-events:auto; }
|
||||
.dsh-ua-modal { width:380px; max-width:92vw; background:var(--dsw-alias-bg-overlay); border:1px solid var(--dsw-alias-border-l1); border-radius:12px; box-shadow:0 12px 40px rgba(0,0,0,.3); padding:16px; color:var(--dsw-alias-label-primary); pointer-events:auto; font-size:13px; }
|
||||
.dsh-ua-modal h3 { margin:0 0 12px; font-size:15px; display:flex; align-items:center; gap:8px; }
|
||||
.dsh-ua-row { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--dsw-alias-border-l1); }
|
||||
.dsh-ua-row:last-child { border-bottom:0; }
|
||||
.dsh-ua-row .k { color:var(--dsw-alias-label-secondary); }
|
||||
.dsh-ua-row .v { font-weight:600; }
|
||||
.dsh-ua-ok { color:var(--dsw-alias-state-success-primary); }
|
||||
.dsh-ua-err { color:var(--dsw-alias-state-error-primary); }
|
||||
.dsh-ua-btns { display:flex; gap:8px; margin-top:14px; }
|
||||
.dsh-ua-btn { flex:1; padding:7px 10px; border-radius:8px; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary); cursor:pointer; font:inherit; font-size:12px; }
|
||||
.dsh-ua-btn:hover { background:var(--dsw-alias-bg-layer-2); }
|
||||
.dsh-ua-btn.primary { background:var(--dsw-alias-brand-primary); color:#fff; border-color:transparent; }
|
||||
`)
|
||||
const store = { open: false, listeners: new Set() }
|
||||
const setOpen = (v) => { store.open = v; store.listeners.forEach((fn) => fn()) }
|
||||
const useOpen = () => {
|
||||
const [s, setS] = React.useState(store.open)
|
||||
React.useEffect(() => { store.listeners.add(setS); return () => store.listeners.delete(setS) }, [])
|
||||
return s
|
||||
}
|
||||
|
||||
const UsageModal = (props) => {
|
||||
const [state, setState] = React.useState({ loading: true, data: null, error: null })
|
||||
const load = () => {
|
||||
setState({ loading: true, data: null, error: null })
|
||||
host.call('usage/balance', {}).then((data) => {
|
||||
setState({ loading: false, data: data, error: null })
|
||||
}).catch((e) => {
|
||||
setState({ loading: false, data: null, error: String((e && e.message) || e) })
|
||||
})
|
||||
}
|
||||
React.useEffect(() => { load() }, [])
|
||||
const openPortal = () => { host.call('usage/openPortal', {}) }
|
||||
const data = state.data
|
||||
const rows = []
|
||||
if (data && Array.isArray(data.balance_infos)) {
|
||||
data.balance_infos.forEach((b) => {
|
||||
rows.push(React.createElement('div', { className: 'dsh-ua-row', key: b.currency + '-total' },
|
||||
React.createElement('span', { className: 'k' }, '总额 (' + b.currency + ')'),
|
||||
React.createElement('span', { className: 'v' }, String(b.total_balance))))
|
||||
rows.push(React.createElement('div', { className: 'dsh-ua-row', key: b.currency + '-granted' },
|
||||
React.createElement('span', { className: 'k' }, '赠送余额'),
|
||||
React.createElement('span', { className: 'v' }, String(b.granted_balance))))
|
||||
rows.push(React.createElement('div', { className: 'dsh-ua-row', key: b.currency + '-topped' },
|
||||
React.createElement('span', { className: 'k' }, '充值余额'),
|
||||
React.createElement('span', { className: 'v' }, String(b.topped_up_balance))))
|
||||
})
|
||||
}
|
||||
let body = null
|
||||
if (state.loading) {
|
||||
body = React.createElement('div', { className: 'dsh-ua-row' }, React.createElement('span', { className: 'k' }, '正在查询官方余额…'))
|
||||
} else if (state.error || (data && data.error)) {
|
||||
body = React.createElement('div', { className: 'dsh-ua-err' }, String(state.error || (data && data.error)))
|
||||
} else {
|
||||
body = React.createElement('div', null,
|
||||
React.createElement('div', { className: 'dsh-ua-row' },
|
||||
React.createElement('span', { className: 'k' }, '账户可用'),
|
||||
React.createElement('span', { className: 'v ' + (data.is_available ? 'dsh-ua-ok' : 'dsh-ua-err') }, data.is_available ? '可用' : '不可用')),
|
||||
rows)
|
||||
}
|
||||
return React.createElement('div', { className: 'dsh-ua-backdrop', onClick: props.onClose },
|
||||
React.createElement('div', { className: 'dsh-ua-modal', onClick: (e) => e.stopPropagation() },
|
||||
React.createElement('h3', null, '🐋 DeepSeek API 用量',
|
||||
React.createElement('span', { style: { marginLeft: 'auto', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, '官方余额')),
|
||||
body,
|
||||
React.createElement('div', { className: 'dsh-ua-btns' },
|
||||
React.createElement('button', { className: 'dsh-ua-btn', onClick: load }, '刷新'),
|
||||
React.createElement('button', { className: 'dsh-ua-btn', onClick: openPortal }, '打开官网用量页'),
|
||||
React.createElement('button', { className: 'dsh-ua-btn primary', onClick: props.onClose }, '关闭'))))
|
||||
}
|
||||
|
||||
slots.inject('sidebar.footer.action', () => slots.register(
|
||||
{ name: 'sidebar.footer.action', id: 'dsh-usage-avatar', order: 100, label: 'API 用量' },
|
||||
(props) => {
|
||||
const open = useOpen()
|
||||
return React.createElement('button', {
|
||||
className: 'dsh-ua-avatar' + (open ? ' active' : ''),
|
||||
title: 'API 用量 / 余额',
|
||||
onClick: () => setOpen(true),
|
||||
},
|
||||
React.createElement('span', { className: 'dsh-ua-avatar-icon' }, '🐋'),
|
||||
props.wide ? React.createElement('span', { className: 'dsh-ua-avatar-label' }, 'API 用量') : null)
|
||||
}
|
||||
))
|
||||
|
||||
slots.inject('shell.overlay', () => slots.register(
|
||||
{ name: 'shell.overlay', id: 'dsh-usage-modal', order: 100 },
|
||||
() => {
|
||||
const open = useOpen()
|
||||
if (!open) return null
|
||||
return React.createElement(UsageModal, { onClose: () => setOpen(false) })
|
||||
}
|
||||
))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// dsh-usage 插件 — Host 半
|
||||
// 通过 cordis_define 定义(动态 Cordis 插件)。
|
||||
// 提供两个 Package-private RPC:
|
||||
// usage/balance — 读取 %DSH_HOME%\.credentials.yaml 的 DEEPSEEK_API_KEY,
|
||||
// 调用 DeepSeek 官方余额 API(GET /user/balance),返回 JSON。
|
||||
// usage/openPortal— 用系统默认浏览器打开官网用量页。
|
||||
// 密钥只在 node 子进程内读取与使用,绝不打印、不入库。
|
||||
return {
|
||||
inject: ['shell'],
|
||||
apply(ctx) {
|
||||
harness.handle('usage/balance', async (args) => {
|
||||
const script = [
|
||||
"const fs = require('node:fs');",
|
||||
"const os = require('node:os');",
|
||||
"const path = require('node:path');",
|
||||
"const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');",
|
||||
"const file = path.join(home, '.credentials.yaml');",
|
||||
"let key = null;",
|
||||
"try { const txt = fs.readFileSync(file, 'utf8'); const m = txt.match(/DEEPSEEK_API_KEY:\\s*(\\S+)/); if (m) key = m[1]; } catch (e) {}",
|
||||
"if (!key) { console.log(JSON.stringify({ error: 'no-api-key', file: file })); process.exit(0); }",
|
||||
"fetch('https://api.deepseek.com/user/balance', { headers: { Authorization: 'Bearer ' + key, Accept: 'application/json' }, signal: AbortSignal.timeout(15000) })",
|
||||
" .then(r => r.json().then(j => console.log(JSON.stringify(Object.assign({ status: r.status }, j)))))",
|
||||
" .catch(e => console.log(JSON.stringify({ error: String((e && e.message) || e) })));",
|
||||
].join('\n')
|
||||
try {
|
||||
const spec = ctx.shell.resolve({ command: 'node', args: ['-e', script] })
|
||||
const res = await ctx.shell.run(spec)
|
||||
const stdout = String(res.stdout || '')
|
||||
const lines = stdout.trim().split(/\r?\n/).filter(Boolean)
|
||||
if (lines.length) return JSON.parse(lines[lines.length - 1])
|
||||
return { error: 'empty-output', stderr: String(res.stderr || '').slice(0, 400) }
|
||||
} catch (e) {
|
||||
return { error: String((e && e.message) || e) }
|
||||
}
|
||||
})
|
||||
|
||||
harness.handle('usage/openPortal', async () => {
|
||||
try {
|
||||
const spec = ctx.shell.resolve({ command: 'cmd', args: ['/c', 'start', '', 'https://platform.deepseek.com/usage'] })
|
||||
await ctx.shell.run(spec)
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String((e && e.message) || e) }
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user