sintbot 1.3.0 → 1.4.1
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/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- package/src/usage.js +144 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 控制台登录会话:落盘 + 过期 + 可按条件吊销,守护进程重启不再把人登出。
|
|
3
|
+
//
|
|
4
|
+
// 此前会话令牌只在内存 Set 里,每次发版重启都要重输密码 —— 那是「两道密码」体验里最频繁的一次输入。
|
|
5
|
+
// 这里改成:令牌 → { exp, via, who, ip, at, …身份线索 } 存 JSON(权限 0600,原子写),默认 30 天固定有效期
|
|
6
|
+
// (不滑动:拿到令牌的人最多用 30 天,登出即失效)。文件读坏就当空(登录一次就恢复,不阻断)。
|
|
7
|
+
// 与密码 / 飞书登录两条路无关 —— 谁签发都进这里,鉴权只问 has(token)。
|
|
8
|
+
//
|
|
9
|
+
// ⚠ 落盘带来一个新义务:会话活得比进程久,**名单里删掉的人、换过的密码**必须能让旧会话立刻失效,
|
|
10
|
+
// 否则被删的人最多还能再遥控这台机器 30 天(对抗审查 🔴)。所以每条会话记着签发时的身份线索
|
|
11
|
+
// (飞书:openId;密码:当时密码 hash 的前缀),由 server 在配置变化时调 revokeWhere 对齐。
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const crypto = require('node:crypto')
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TTL_MS = 30 * 24 * 3600_000
|
|
18
|
+
|
|
19
|
+
function createSessionStore({ file, ttlMs = DEFAULT_TTL_MS, now = Date.now, log = () => {} } = {}) {
|
|
20
|
+
const map = new Map() // token -> meta
|
|
21
|
+
|
|
22
|
+
const prune = () => { const t = now(); for (const [k, m] of map) if (!m || !(m.exp > t)) map.delete(k) }
|
|
23
|
+
const persist = () => {
|
|
24
|
+
if (!file) return
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
27
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: 1, tokens: Object.fromEntries(map) }, null, 1) + '\n', { mode: 0o600 })
|
|
29
|
+
fs.renameSync(tmp, file)
|
|
30
|
+
} catch (e) { log(`ui sessions: 写不进 ${path.basename(file)}(${e.message})—— 会话只在内存里,重启后要重新登录`) }
|
|
31
|
+
}
|
|
32
|
+
const load = () => {
|
|
33
|
+
map.clear()
|
|
34
|
+
if (!file || !fs.existsSync(file)) return
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
37
|
+
const toks = j && j.tokens && typeof j.tokens === 'object' ? j.tokens : {}
|
|
38
|
+
// 文件虽是 0600、我们自己写的,但曾被手改 / 写坏过的可能永远存在:只认形状合法的令牌与带过期时间的项,其余当没有
|
|
39
|
+
for (const [k, m] of Object.entries(toks)) if (/^[0-9a-f]{16,128}$/.test(k) && m && typeof m.exp === 'number') map.set(k, m)
|
|
40
|
+
prune()
|
|
41
|
+
} catch (e) { log(`ui sessions: ${path.basename(file)} 读不出来(${e.message}),按没有会话处理`) }
|
|
42
|
+
}
|
|
43
|
+
load()
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
// 签发:via = 'password' | 'feishu';who = 展示名;ip = TCP 对端;其余字段(openId / authRef / xff)原样存,
|
|
47
|
+
// 供吊销与审计用 —— 只存字符串,不存令牌以外的任何秘密。
|
|
48
|
+
add({ via = 'password', who = '', ip = '', ...extra } = {}) {
|
|
49
|
+
prune()
|
|
50
|
+
const token = crypto.randomBytes(24).toString('hex')
|
|
51
|
+
const meta = { exp: now() + ttlMs, via, who: String(who || ''), ip: String(ip || ''), at: now() }
|
|
52
|
+
for (const [k, v] of Object.entries(extra)) if (v != null && v !== '') meta[k] = String(v)
|
|
53
|
+
map.set(token, meta)
|
|
54
|
+
persist(); return token
|
|
55
|
+
},
|
|
56
|
+
// 读路径上也会写盘:撞见过期项就顺手删并落盘 —— 没有定时清理器,读到就是唯一的清理时机
|
|
57
|
+
has(token) {
|
|
58
|
+
if (!token || !map.has(token)) return false
|
|
59
|
+
const m = map.get(token)
|
|
60
|
+
if (!(m && m.exp > now())) { map.delete(token); persist(); return false }
|
|
61
|
+
return true
|
|
62
|
+
},
|
|
63
|
+
get(token) { return this.has(token) ? { ...map.get(token) } : null },
|
|
64
|
+
remove(token) { if (map.delete(token)) persist() },
|
|
65
|
+
// 按条件吊销(名单删人 / 换密码时由 server 调),返回吊销条数;一条都没吊销就不写盘
|
|
66
|
+
revokeWhere(pred) {
|
|
67
|
+
let n = 0
|
|
68
|
+
for (const [k, m] of map) if (pred({ ...m })) { map.delete(k); n++ }
|
|
69
|
+
if (n) persist()
|
|
70
|
+
return n
|
|
71
|
+
},
|
|
72
|
+
// 全部吊销(「退出所有设备」)
|
|
73
|
+
clear() { const n = map.size; map.clear(); persist(); return n },
|
|
74
|
+
get size() { prune(); return map.size },
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { createSessionStore, DEFAULT_TTL_MS }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 控制台「接管浏览器」的一次性链接(纯逻辑,可测)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:群 agent 撞到登录墙 / 验证码 / 扫码,自己做不了 ⇒ 它在群里发一条链接,群成员在手机上打开就能
|
|
5
|
+
// 直接操作 bot 的浏览器(登录态落在 bot 这边,以后它自己接着干)。群成员多半不是控制台管理员,所以
|
|
6
|
+
// 链接本身就是门票 —— 由此三条硬约束:
|
|
7
|
+
// · **只能打开一次**:第一次打开换成一枚会话 cookie,链接立刻作废;被转发的人拿到的是一张已用过的票。
|
|
8
|
+
// · **短命**:默认 10 分钟(cfg.browser.takeoverMinutes 可调),会话与链接同一到期时刻,不滑动续期。
|
|
9
|
+
// · **只开这一扇门**:cookie 只对接管页与它的接口有效,控制台其它 /api/* 一律不认(server 那层裁决)。
|
|
10
|
+
// 谁能签票:控制台管理员(已登录)与本机环回上的进程(群 agent 跑 `sbot browser link`)。接管者自己不能再签。
|
|
11
|
+
|
|
12
|
+
const crypto = require('node:crypto')
|
|
13
|
+
|
|
14
|
+
const COOKIE = 'sbot-takeover'
|
|
15
|
+
const DEFAULT_TTL_MS = 10 * 60_000 // 够人从群里点开、登录、过一个验证码;再长就是给转发的人留窗口
|
|
16
|
+
const MIN_TTL_MS = 60_000 // 少于 1 分钟,人还没点开就过期了
|
|
17
|
+
const MAX_TTL_MS = 60 * 60_000 // 最多 1 小时:超过这个时长的「接管」应该去控制台用管理员身份做
|
|
18
|
+
const MAX_UNUSED = 50 // 没用过的票封顶:本机任何进程都能签票,不封顶就是一个内存黑洞;超了丢最老的
|
|
19
|
+
|
|
20
|
+
const rand = () => crypto.randomBytes(16).toString('hex')
|
|
21
|
+
|
|
22
|
+
// onClaimed / onExpired / onEnded:票挂了元数据(群里那张卡的 cardId)时,兑换 / 没人来过期 / 会话结束各回调一次 —— daemon 拿它把卡翻成
|
|
23
|
+
// 「正在接管中」/「已过期」/「接管已结束」。⚠ 结束那条是真机补的:v0.11.87 群里点了「结束接管」,卡还挂着「正在接管中」
|
|
24
|
+
function createTakeoverStore({ ttlMs = DEFAULT_TTL_MS, now = Date.now, onClaimed = null, onExpired = null, onEnded = null } = {}) {
|
|
25
|
+
const links = new Map() // token -> { id, exp, at, by, usedAt, sid, targetId, meta, timer }
|
|
26
|
+
const byId = new Map() // id -> token(id 不是秘密,给 attach 用)
|
|
27
|
+
const sessions = new Map() // sid -> { exp, at, token, ip, targetId, meta, timer }
|
|
28
|
+
// 会话结束的唯一出口:主动结束(ended)/ 到期(expired)都走这里 ⇒ 每个会话只回调一次
|
|
29
|
+
const endSession = (sid, reason) => {
|
|
30
|
+
const s = sessions.get(sid); if (!s) return false
|
|
31
|
+
sessions.delete(sid); if (s.timer) clearTimeout(s.timer)
|
|
32
|
+
if (s.meta && onEnded) { try { onEnded(s.meta, { reason }) } catch {} }
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
const prune = () => {
|
|
36
|
+
const t = now()
|
|
37
|
+
for (const [k, v] of links) if (!(v.exp > t)) links.delete(k)
|
|
38
|
+
for (const [k, v] of sessions) if (!(v.exp > t)) endSession(k, 'expired')
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
// 签一张票。ttl 越界收编回 [1, 60] 分钟(不报错:签票方多半是 agent,报错它只会换个数再来)
|
|
42
|
+
// targetId:把票绑在某个标签页上,持票人只能碰那一页(不给 = 全部标签页)
|
|
43
|
+
mint({ by = '', ttl = ttlMs, targetId = '' } = {}) {
|
|
44
|
+
prune()
|
|
45
|
+
const unused = [...links.entries()].filter(([, l]) => !l.usedAt)
|
|
46
|
+
for (const [k] of unused.slice(0, Math.max(0, unused.length - MAX_UNUSED + 1))) links.delete(k)
|
|
47
|
+
const life = Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, Number(ttl) || ttlMs))
|
|
48
|
+
const token = rand(); const id = rand().slice(0, 12); const at = now()
|
|
49
|
+
const link = { id, exp: at + life, at, by: String(by || ''), usedAt: null, sid: null, targetId: String(targetId || ''), meta: null, timer: null }
|
|
50
|
+
links.set(token, link); byId.set(id, token)
|
|
51
|
+
// 过期回调:只对挂了元数据且没被兑换的票(群里有张卡等着翻成「已过期」);daemon 重启定时器就没了,可接受
|
|
52
|
+
if (onExpired) { link.timer = setTimeout(() => { const l = links.get(token); if (l && !l.usedAt && l.meta) { try { onExpired(l.meta) } catch {} } }, life); if (link.timer.unref) link.timer.unref() }
|
|
53
|
+
return { token, id, exp: at + life, ttlMs: life, targetId: String(targetId || '') }
|
|
54
|
+
},
|
|
55
|
+
// 用票换会话:过期 / 用过 / 不存在 ⇒ null。同一张票第二次来,哪怕是同一个人也不再放行(单次即用完)
|
|
56
|
+
consume(token, { ip = '' } = {}) {
|
|
57
|
+
prune()
|
|
58
|
+
const l = links.get(String(token || ''))
|
|
59
|
+
if (!l || l.usedAt) return null
|
|
60
|
+
const sid = rand(); const at = now()
|
|
61
|
+
l.usedAt = at; l.sid = sid
|
|
62
|
+
if (l.timer) clearTimeout(l.timer)
|
|
63
|
+
const s = { exp: l.exp, at, token: String(token), ip: String(ip || ''), targetId: l.targetId || '', meta: l.meta, timer: null }
|
|
64
|
+
sessions.set(sid, s)
|
|
65
|
+
// 到点没人主动结束 ⇒ 也算结束(人来过,卡翻「已结束」而不是「已过期」);prune 是懒的,靠这个定时器按时翻
|
|
66
|
+
if (l.meta && onEnded) { s.timer = setTimeout(() => endSession(sid, 'expired'), Math.max(0, l.exp - now())); if (s.timer.unref) s.timer.unref() }
|
|
67
|
+
if (l.meta && onClaimed) { try { onClaimed(l.meta) } catch {} }
|
|
68
|
+
return { sid, exp: l.exp, targetId: l.targetId || '', meta: l.meta }
|
|
69
|
+
},
|
|
70
|
+
// 给票挂元数据(群里那张卡):按 id 找,票本身不用再传回来
|
|
71
|
+
attach(id, meta) { const tok = byId.get(String(id || '')); const l = tok && links.get(tok); if (!l || l.usedAt) return false; l.meta = meta || null; return true },
|
|
72
|
+
hasSession(sid) { prune(); return sessions.has(String(sid || '')) },
|
|
73
|
+
session(sid) { prune(); return sessions.get(String(sid || '')) || null },
|
|
74
|
+
revokeSession(sid) { return endSession(String(sid || ''), 'ended') },
|
|
75
|
+
revokeAll() { const n = sessions.size + links.size; for (const k of [...sessions.keys()]) endSession(k, 'ended'); for (const l of links.values()) if (l.timer) clearTimeout(l.timer); links.clear(); return n },
|
|
76
|
+
// 群里还挂着、没人来认领的票(控制台「需要你处理」用):只算挂了群卡的票 —— 那是 agent 在群里求助;
|
|
77
|
+
// 管理员自己在控制台签的没有卡,不算。只给元数据,不给令牌。
|
|
78
|
+
pending() {
|
|
79
|
+
prune()
|
|
80
|
+
return [...links.values()].filter((l) => !l.usedAt && l.meta && l.meta.chatId)
|
|
81
|
+
.map((l) => ({ id: l.id, at: l.at, exp: l.exp, chatId: l.meta.chatId, pageTitle: l.meta.pageTitle || '', pageUrl: l.meta.pageUrl || '' }))
|
|
82
|
+
},
|
|
83
|
+
// 摘要(测试与排障用,不暴露令牌本身)
|
|
84
|
+
stats() { prune(); return { links: links.size, unused: [...links.values()].filter((l) => !l.usedAt).length, sessions: sessions.size } },
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// —— cookie ——
|
|
89
|
+
function cookieValue(header, name = COOKIE) {
|
|
90
|
+
const h = String(header || '')
|
|
91
|
+
for (const part of h.split(';')) {
|
|
92
|
+
const i = part.indexOf('=')
|
|
93
|
+
if (i < 0) continue
|
|
94
|
+
if (part.slice(0, i).trim() === name) { try { return decodeURIComponent(part.slice(i + 1).trim()) } catch { return '' } } // 畸形 cookie 不能把整站打成 500(对抗审查 🟡5)
|
|
95
|
+
}
|
|
96
|
+
return ''
|
|
97
|
+
}
|
|
98
|
+
// HttpOnly:页面脚本拿不到,只随请求走;SameSite=Lax:别的站点发不了带它的跨站 POST;经 https 反代时加 Secure
|
|
99
|
+
function setCookieHeader(sid, { maxAgeSec = 600, secure = false } = {}) {
|
|
100
|
+
return `${COOKIE}=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.max(0, Math.floor(maxAgeSec))}${secure ? '; Secure' : ''}`
|
|
101
|
+
}
|
|
102
|
+
const clearCookieHeader = ({ secure = false } = {}) => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? '; Secure' : ''}`
|
|
103
|
+
|
|
104
|
+
// 链接形状:<控制台根>/browser#k=<票>。票放在 URL 片段里:浏览器不会把它发给服务器 ⇒ 不进 nginx 访问日志、不被链接预览 / 安全扫描的裸 GET
|
|
105
|
+
// 拿走;页面脚本读片段后由人点「开始接管」再 POST 兑换。根优先公网地址(群成员出了局域网也打得开),没配就退局域网地址
|
|
106
|
+
const takeoverUrl = (base, token) => `${String(base || '').replace(/\/+$/, '')}/browser#k=${encodeURIComponent(token)}`
|
|
107
|
+
|
|
108
|
+
module.exports = { COOKIE, DEFAULT_TTL_MS, MIN_TTL_MS, MAX_TTL_MS, MAX_UNUSED, createTakeoverStore, cookieValue, setCookieHeader, clearCookieHeader, takeoverUrl }
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Per-provider account balance / plan usage — the numbers cc-hud surfaces, for
|
|
2
|
+
// the console. Server-side (keys never reach the browser), best-effort, cached
|
|
3
|
+
// 5 min, 2.5s timeout. Endpoints mirror cc-hud (deepseek / glm / minimax); other
|
|
4
|
+
// providers (volcano coding-plan, Claude) have no public balance API → skipped.
|
|
5
|
+
|
|
6
|
+
const CACHE = new Map() // provider name -> { val, ts }
|
|
7
|
+
const TTL = 5 * 60 * 1000
|
|
8
|
+
|
|
9
|
+
async function fetchJson(url, key, ms = 2500) {
|
|
10
|
+
const ac = new AbortController()
|
|
11
|
+
const timer = setTimeout(() => ac.abort(), ms)
|
|
12
|
+
try {
|
|
13
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: ac.signal })
|
|
14
|
+
if (!r.ok) return null
|
|
15
|
+
return await r.json()
|
|
16
|
+
} catch { return null } finally { clearTimeout(timer) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// baseUrl → 哪一家的余额接口(null = 这家根本没有公开余额接口,如火山 coding plan)。
|
|
20
|
+
// 抽出来是为了「能不能查」与「怎么查」共用同一份判定 —— 两处各写一份必然漂移,而漂移的
|
|
21
|
+
// 后果正是群里那句错话:明明这家没接口,却说成「暂时没查到,稍后再试」让人白等。
|
|
22
|
+
const balanceKind = (base = '') =>
|
|
23
|
+
base.includes('deepseek') ? 'deepseek'
|
|
24
|
+
: (base.includes('bigmodel.cn') || base.includes('api.z.ai')) ? 'glm'
|
|
25
|
+
: base.includes('minimax') ? 'minimax'
|
|
26
|
+
: null
|
|
27
|
+
|
|
28
|
+
async function providerUsage(provider) {
|
|
29
|
+
const key = provider.apiKey
|
|
30
|
+
const base = provider.baseUrl || ''
|
|
31
|
+
// Claude 订阅登录形态(无 baseUrl、非 codex):额度走 oauth/usage(cc-hud 同源通道,
|
|
32
|
+
// v0.11.3;凭据取本机 Claude Code 登录态,拿不到/未登录 ⇒ null 不显示)。
|
|
33
|
+
if (!base && provider.runner !== 'codex') {
|
|
34
|
+
const v = await require('./agent/claudeusage').usageLine()
|
|
35
|
+
return v ? { label: '订阅额度', value: v } : null
|
|
36
|
+
}
|
|
37
|
+
if (!key) return null
|
|
38
|
+
|
|
39
|
+
const kind = balanceKind(base)
|
|
40
|
+
if (kind === 'deepseek') {
|
|
41
|
+
const d = await fetchJson('https://api.deepseek.com/user/balance', key)
|
|
42
|
+
const t = d && d.balance_infos && d.balance_infos[0] && d.balance_infos[0].total_balance
|
|
43
|
+
return t ? { label: '余额', value: `¥${t}` } : null
|
|
44
|
+
}
|
|
45
|
+
if (kind === 'glm') {
|
|
46
|
+
const host = base.includes('api.z.ai') ? 'https://api.z.ai' : 'https://open.bigmodel.cn'
|
|
47
|
+
const d = await fetchJson(`${host}/api/biz/account/query-customer-account-report`, key)
|
|
48
|
+
if (d && d.code && d.code !== 200) return null
|
|
49
|
+
const inner = (d && d.data) || d || {}
|
|
50
|
+
const v = inner.balance != null ? inner.balance : inner.total_balance != null ? inner.total_balance : inner.amount
|
|
51
|
+
if (v == null) return null
|
|
52
|
+
return { label: '余额', value: typeof v === 'number' ? `¥${v.toFixed(2)}` : `¥${String(v).replace(/^¥/, '')}` }
|
|
53
|
+
}
|
|
54
|
+
if (kind === 'minimax') {
|
|
55
|
+
const host = base.includes('minimaxi.com') ? 'https://api.minimaxi.com' : 'https://api.minimax.io'
|
|
56
|
+
const d = await fetchJson(`${host}/v1/token_plan/remains`, key)
|
|
57
|
+
const m = d && d.model_remains && (d.model_remains.find((x) => x.current_interval_status === 1) || d.model_remains[0])
|
|
58
|
+
if (!m) return null
|
|
59
|
+
return { label: '额度', value: `5h:${Math.min(100, Math.max(0, Math.round(100 - m.current_interval_remaining_percent)))}%` } // 口径统一为「已用」(v0.11.8);接口给的是剩余 ⇒ 换算
|
|
60
|
+
}
|
|
61
|
+
return null // volcano / claude: no public balance API
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 能不能给这个 provider 查额度 —— getUsage 与 refreshUsage 共用的单一判据(两处各写一份
|
|
65
|
+
// 必然漂移:手点刷新放行了 hidden,就等于绕开「hidden ⇒ 零探测」)。
|
|
66
|
+
// hidden = operator 主动撤下 ⇒ 不拉(卡片都不显示,拉了白拉;生产机的 claude 标 hidden,
|
|
67
|
+
// 不该为它去翻 Keychain)· codex 额度走自家 app-server 通道(codexstatus),不在这 ·
|
|
68
|
+
// 第三方缺 key 查不了。剩下 = 第三方有 key / 登录态 claude(无 baseUrl 非 codex),都可查。
|
|
69
|
+
const queryable = (p) => !!p && !p.hidden && p.runner !== 'codex' && !(p.baseUrl && !p.apiKey)
|
|
70
|
+
|
|
71
|
+
// 登录态 claude(无 baseUrl、非 codex)—— 它的额度走 claudeusage 那条通道,自带 60s TTL。
|
|
72
|
+
const isClaudeLogin = (p) => !!p && !p.baseUrl && p.runner !== 'codex'
|
|
73
|
+
|
|
74
|
+
// 这个模型形态**本来**有没有额度可查(与 providerUsage 的分支一一对应)。
|
|
75
|
+
// queryable 答的是「该不该去探」(撤下的、缺 key 的、走自家通道的都别探);这里答的是
|
|
76
|
+
// 「探了本该有没有数」—— 两者不是一回事:火山这类第三方 key 齐全该探,但它没有公开余额接口,
|
|
77
|
+
// 查不到是**结构性**的,不是网络抖动。分不开这两件事,群里就只能把偶发失败也说成「不支持」。
|
|
78
|
+
//
|
|
79
|
+
// ⚠ **不看 hidden**(2026-08-11 修回归):hidden 只是「控制台的模型页别列它」这一条收纳口径,
|
|
80
|
+
// 群照样在用它 —— 生产机的 claude 正是 hidden。把 queryable 的 !hidden 混进来,群里 /hud 就会
|
|
81
|
+
// 对着一个天天在跑的模型说「不支持查额度」,而它明明查得到。缺 key 仍算「不支持」(旧行为)。
|
|
82
|
+
const hasUsageApi = (p) => !!p && p.runner !== 'codex' && !(p.baseUrl && !p.apiKey)
|
|
83
|
+
&& (isClaudeLogin(p) || !!balanceKind(p.baseUrl || ''))
|
|
84
|
+
|
|
85
|
+
// Fetch balances for all keyed providers (resolved apiKey). Returns { name: {label,value} }.
|
|
86
|
+
async function getUsage(providers = {}) {
|
|
87
|
+
const out = {}
|
|
88
|
+
await Promise.all(Object.entries(providers).map(async ([name, p]) => {
|
|
89
|
+
if (!queryable(p)) return
|
|
90
|
+
const c = CACHE.get(name)
|
|
91
|
+
if (c && Date.now() - c.ts < TTL) { out[name] = c.val; return }
|
|
92
|
+
const v = await providerUsage(p)
|
|
93
|
+
if (v) { CACHE.set(name, { val: v, ts: Date.now() }); out[name] = v }
|
|
94
|
+
else if (c) out[name] = c.val // stale fallback
|
|
95
|
+
}))
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 单个 provider 强制刷新(控制台额度行的「↻」)。**点按钮 = 要此刻的数**,所以两层缓存都得绕:
|
|
100
|
+
// 本模块的 5 分钟 CACHE,以及登录态 claude 那条通道自己的 60s TTL(与 /api/claude/verify 同口径)。
|
|
101
|
+
// ⚠ 拉不到时把上一次的值留在缓存里 —— 手点刷新失败不该让卡片上原本显示着的额度行凭空消失
|
|
102
|
+
// (旧 ts 保留 ⇒ 它照样算过期,下一次 getUsage 仍会重拉)。probe/now 注入是为了可单测。
|
|
103
|
+
async function refreshUsage(name, provider, { probe = providerUsage, now = Date.now } = {}) {
|
|
104
|
+
if (!queryable(provider)) { CACHE.delete(name); return null }
|
|
105
|
+
const prev = CACHE.get(name)
|
|
106
|
+
CACHE.delete(name)
|
|
107
|
+
if (isClaudeLogin(provider)) { try { require('./agent/claudeusage').clearCache() } catch { /* best-effort */ } }
|
|
108
|
+
const v = await probe(provider).catch(() => null)
|
|
109
|
+
if (v) CACHE.set(name, { val: v, ts: now() })
|
|
110
|
+
else if (prev) CACHE.set(name, prev)
|
|
111
|
+
return v
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// /hud 的额度探针(daemon 注入给 engine —— core 不认具体 runner)。放这儿而不是内联在
|
|
115
|
+
// 启动流程里,是为了它能被单测钉住:这层的两个判断都出过事。
|
|
116
|
+
//
|
|
117
|
+
// 返回 **{ supported, value }**,两件事分开答:
|
|
118
|
+
// supported = 这个模型形态本来能不能查(codex 走自家订阅通道 · 登录态 claude 与有余额接口的
|
|
119
|
+
// 第三方走 providerUsage);value = 此刻真查到的串,查不到给 null。
|
|
120
|
+
// ⚠ 合成一个 null 就是本案的根因:群里于是把「接口超时/限流」一律说成「模型不支持查额度」,
|
|
121
|
+
// 而这两句话给群成员的下一步完全相反(一个别再问,一个过会儿再问)。
|
|
122
|
+
// 缓存:**成功压 60s,失败只压 10s**(与 claudeusage 同口径)—— 失败也压一分钟,等于一次
|
|
123
|
+
// 偶发超时让整整一分钟查不到;完全不压又让连点 /hud 直捅端点限流。
|
|
124
|
+
function createUsageProbe({ codexUsage = async () => null, probe = providerUsage, ttl = 60_000, failTtl = 10_000, now = Date.now } = {}) {
|
|
125
|
+
const cache = new Map() // provider name + '|' + baseUrl -> { v, ts }
|
|
126
|
+
return async (name, provider) => {
|
|
127
|
+
const isCodex = !!provider && provider.runner === 'codex'
|
|
128
|
+
if (!isCodex && !hasUsageApi(provider)) return { supported: false, value: null }
|
|
129
|
+
// 缓存键带上 baseUrl:控制台热改端点(deepseek → minimax)后名字没变,只按名字缓存会让
|
|
130
|
+
// 接下来 60 秒把上一家的余额当成这一家的报出去 —— 换了端点就是换了账,不许串味。
|
|
131
|
+
const key = name + '|' + ((provider && provider.baseUrl) || '')
|
|
132
|
+
const c = cache.get(key)
|
|
133
|
+
if (c && now() - c.ts < (c.v ? ttl : failTtl)) return { supported: true, value: c.v }
|
|
134
|
+
let v = null
|
|
135
|
+
try {
|
|
136
|
+
if (isCodex) v = await codexUsage(provider)
|
|
137
|
+
else { const u = await probe(provider); v = u ? `${u.label} ${u.value}` : null }
|
|
138
|
+
} catch { v = null }
|
|
139
|
+
cache.set(key, { v: v || null, ts: now() })
|
|
140
|
+
return { supported: true, value: v || null }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { getUsage, providerUsage, refreshUsage, queryable, hasUsageApi, createUsageProbe }
|