@young1lin/dsh-gpt-sub 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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/client.js +908 -0
- package/cordis.patch.yml +44 -0
- package/lib/index.d.ts +52 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +581 -0
- package/lib/index.js.map +1 -0
- package/lib/jwt.d.ts +15 -0
- package/lib/jwt.d.ts.map +1 -0
- package/lib/jwt.js +29 -0
- package/lib/jwt.js.map +1 -0
- package/lib/proxy-config.d.ts +91 -0
- package/lib/proxy-config.d.ts.map +1 -0
- package/lib/proxy-config.js +213 -0
- package/lib/proxy-config.js.map +1 -0
- package/lib/proxy-probe.d.ts +47 -0
- package/lib/proxy-probe.d.ts.map +1 -0
- package/lib/proxy-probe.js +51 -0
- package/lib/proxy-probe.js.map +1 -0
- package/lib/proxy-routing.d.ts +111 -0
- package/lib/proxy-routing.d.ts.map +1 -0
- package/lib/proxy-routing.js +171 -0
- package/lib/proxy-routing.js.map +1 -0
- package/lib/quota-route.d.ts +85 -0
- package/lib/quota-route.d.ts.map +1 -0
- package/lib/quota-route.js +105 -0
- package/lib/quota-route.js.map +1 -0
- package/lib/reset-credits.d.ts +64 -0
- package/lib/reset-credits.d.ts.map +1 -0
- package/lib/reset-credits.js +84 -0
- package/lib/reset-credits.js.map +1 -0
- package/lib/token-store.d.ts +107 -0
- package/lib/token-store.d.ts.map +1 -0
- package/lib/token-store.js +228 -0
- package/lib/token-store.js.map +1 -0
- package/lib/types.d.ts +18 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/usage.d.ts +92 -0
- package/lib/usage.d.ts.map +1 -0
- package/lib/usage.js +106 -0
- package/lib/usage.js.map +1 -0
- package/package.json +82 -0
- package/src/index.ts +685 -0
- package/src/jwt.ts +26 -0
- package/src/proxy-config.ts +217 -0
- package/src/proxy-probe.ts +81 -0
- package/src/proxy-routing.ts +205 -0
- package/src/quota-route.ts +163 -0
- package/src/reset-credits.ts +128 -0
- package/src/token-store.ts +309 -0
- package/src/types.ts +17 -0
- package/src/usage.ts +154 -0
package/client.js
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* dsh-gpt-sub — client bundle (browser half). Registers a Codex subscription
|
|
3
|
+
* quota panel as a Settings section. The panel is a renderer only: it polls the
|
|
4
|
+
* host half's GET /gpt-sub/quota and /gpt-sub/status endpoints, and every
|
|
5
|
+
* semantic (which windows count, the upstream throttle, proxy validation, what
|
|
6
|
+
* a stale reading means) lives host-side.
|
|
7
|
+
*
|
|
8
|
+
* Controls use the app's dsh-client-ui-primitives (Button, Input, Pill) so the
|
|
9
|
+
* panel matches the theme in both light and dark; a local fallback keeps the
|
|
10
|
+
* panel usable when the primitives module is absent.
|
|
11
|
+
*
|
|
12
|
+
* Bundle format: a single window.__ModuleLoader__.load handoff; every
|
|
13
|
+
* cross-package value arrives through the injected require (the loader module
|
|
14
|
+
* table).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: '@young1lin/dsh-gpt-sub',
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
var module = { exports: {} }
|
|
21
|
+
var exports = module.exports
|
|
22
|
+
const React = require('react')
|
|
23
|
+
const { useEffect, useState } = React
|
|
24
|
+
|
|
25
|
+
// The app design system, when the frontend ships it in the module table.
|
|
26
|
+
let Button, Input, Pill
|
|
27
|
+
try {
|
|
28
|
+
;({ Button, Input, Pill } = require('@deepseek-ai/dsh-client-ui-primitives'))
|
|
29
|
+
} catch {
|
|
30
|
+
Button = Pill = undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ENDPOINT = '/gpt-sub/quota'
|
|
34
|
+
const STATUS_ENDPOINT = '/gpt-sub/status'
|
|
35
|
+
const PROXY_ENDPOINT = '/gpt-sub/proxy'
|
|
36
|
+
const PROXY_TEST_ENDPOINT = '/gpt-sub/proxy/test'
|
|
37
|
+
const AUTH_ENDPOINT = '/gpt-sub/auth'
|
|
38
|
+
const AUTH_BROWSE_ENDPOINT = '/gpt-sub/auth/browse'
|
|
39
|
+
const RESET_CREDITS_ENDPOINT = '/gpt-sub/reset-credits'
|
|
40
|
+
const RESET_CREDITS_CONSUME_ENDPOINT = '/gpt-sub/reset-credits/consume'
|
|
41
|
+
const POLL_MS = 60_000
|
|
42
|
+
|
|
43
|
+
const css = [
|
|
44
|
+
'.dshGptSub{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
|
|
45
|
+
'.dshGptSubHead{display:flex;align-items:center;gap:8px;margin:0 0 10px}',
|
|
46
|
+
'.dshGptSubPlan{font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
47
|
+
'.dshGptSubTrack{position:relative;height:8px;border-radius:4px;background:var(--dsw-alias-interactive-bg-hover);overflow:hidden}',
|
|
48
|
+
'.dshGptSubFill{position:absolute;left:0;top:0;bottom:0;border-radius:4px;transition:width .3s}',
|
|
49
|
+
'.dshGptSubMeta{display:flex;justify-content:space-between;margin-top:6px;font-size:12px}',
|
|
50
|
+
// One block per rate-limit window (5-hour rolling, weekly), each with
|
|
51
|
+
// its own label, track, and reset countdown.
|
|
52
|
+
'.dshGptSubWin{margin-top:10px}',
|
|
53
|
+
'.dshGptSubWin+.dshGptSubWin{margin-top:16px}',
|
|
54
|
+
'.dshGptSubWinLabel{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-bottom:4px}',
|
|
55
|
+
'.dshGptSubNote{margin-top:8px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
56
|
+
'.dshGptSubBtn{margin-left:auto}',
|
|
57
|
+
'.dshGptSubDiag{margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-interactive-bg-hover);display:grid;grid-template-columns:max-content 1fr;gap:8px 16px;align-items:center}',
|
|
58
|
+
'.dshGptSubDiagLabel{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px}',
|
|
59
|
+
'.dshGptSubDiagValue{min-width:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary)}',
|
|
60
|
+
'.dshGptSubProxyRow{display:flex;gap:8px;align-items:center;min-width:0}',
|
|
61
|
+
'.dshGptSubProxyField{flex:1;min-width:0;display:flex}',
|
|
62
|
+
'.dshGptSubProxyField>span{flex:1}',
|
|
63
|
+
'.dshGptSubProxyMeta{display:flex;align-items:center;gap:6px;margin-top:6px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
64
|
+
// The credential-file picker: a small host-backed directory browser.
|
|
65
|
+
'.dshGptSubBrowseMask{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}',
|
|
66
|
+
'.dshGptSubBrowse{width:min(560px,92vw);max-height:72vh;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-interactive-bg-hover);border-radius:12px;color:var(--dsw-alias-label-secondary);box-shadow:0 12px 40px rgba(0,0,0,.25)}',
|
|
67
|
+
'.dshGptSubBrowseHead{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--dsw-alias-interactive-bg-hover);font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
68
|
+
'.dshGptSubBrowsePath{padding:8px 16px;font-size:12px;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;border-bottom:1px solid var(--dsw-alias-interactive-bg-hover)}',
|
|
69
|
+
'.dshGptSubBrowseList{flex:1;overflow:auto;margin:0;padding:4px 0;list-style:none}',
|
|
70
|
+
'.dshGptSubBrowseRow{display:flex;gap:8px;padding:5px 16px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-secondary)}',
|
|
71
|
+
'.dshGptSubBrowseRow:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
|
|
72
|
+
'.dshGptSubBrowseMark{width:1em;text-align:center;flex:none}',
|
|
73
|
+
'.dshGptSubBrowseFoot{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:10px 16px;border-top:1px solid var(--dsw-alias-interactive-bg-hover)}',
|
|
74
|
+
// Fallback styling, applied only when the primitives module is absent.
|
|
75
|
+
'.dshGptSubFallbackButton{font-size:12px;background:none;border:1px solid var(--dsw-alias-interactive-bg-hover);border-radius:14px;cursor:pointer;color:var(--dsw-alias-label-secondary);padding:4px 12px;white-space:nowrap}',
|
|
76
|
+
'.dshGptSubFallbackButton:hover{color:var(--dsw-alias-label-primary)}',
|
|
77
|
+
'.dshGptSubFallbackInput{flex:1;min-width:0;font-size:13px;padding:4px 10px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-interactive-bg-hover);border-radius:8px;outline:none}',
|
|
78
|
+
'.dshGptSubFallbackPill{font-size:11px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
|
|
79
|
+
].join('')
|
|
80
|
+
|
|
81
|
+
let styled = false
|
|
82
|
+
/** Inject the panel stylesheet once per page. */
|
|
83
|
+
const ensureStyle = () => {
|
|
84
|
+
if (styled) return
|
|
85
|
+
styled = true
|
|
86
|
+
const tag = document.createElement('style')
|
|
87
|
+
tag.textContent = css
|
|
88
|
+
document.head.appendChild(tag)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Render a small button through the primitives, or a styled native one.
|
|
93
|
+
* @param props - variant, children, and native button attributes.
|
|
94
|
+
* @returns the button element.
|
|
95
|
+
*/
|
|
96
|
+
const SmallButton = (props) =>
|
|
97
|
+
Button !== undefined
|
|
98
|
+
? React.createElement(Button, { size: 'sm', ...props })
|
|
99
|
+
: React.createElement('button', {
|
|
100
|
+
type: 'button',
|
|
101
|
+
className: 'dshGptSubFallbackButton',
|
|
102
|
+
...props,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Render a pill badge through the primitives, or a styled native span.
|
|
107
|
+
* @param props - children.
|
|
108
|
+
* @returns the badge element.
|
|
109
|
+
*/
|
|
110
|
+
const Badge = (props) =>
|
|
111
|
+
Pill !== undefined
|
|
112
|
+
? React.createElement(Pill, props)
|
|
113
|
+
: React.createElement('span', { className: 'dshGptSubFallbackPill' }, props.children)
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bar colour by remaining share, mirroring the five tiers the sibling
|
|
117
|
+
* quota panel uses so the two read the same way. The bar drains as the
|
|
118
|
+
* window is consumed, so the colour follows what is left: green while
|
|
119
|
+
* plenty remains, red as it runs out.
|
|
120
|
+
* @param percent - percent remaining.
|
|
121
|
+
* @returns a CSS colour.
|
|
122
|
+
*/
|
|
123
|
+
const tierColor = (percent) => {
|
|
124
|
+
if (percent >= 80) return '#22c55e'
|
|
125
|
+
if (percent >= 60) return '#16a34a'
|
|
126
|
+
if (percent >= 40) return '#06b6d4'
|
|
127
|
+
if (percent >= 20) return '#eab308'
|
|
128
|
+
return '#ef4444'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Render a remaining duration in compact units, keeping two adjacent
|
|
133
|
+
* components and dropping a trailing zero: days+hours past a day,
|
|
134
|
+
* hours+minutes past an hour, minutes+seconds under one -- '4h22m',
|
|
135
|
+
* '10m22s', '2d5h', '45s', never '4h0m'. Seconds only appear under an
|
|
136
|
+
* hour, where they still change fast enough to read.
|
|
137
|
+
* @param seconds - whole remaining seconds; negative clamps to zero.
|
|
138
|
+
* @returns the compact duration.
|
|
139
|
+
*/
|
|
140
|
+
const compactDuration = (seconds) => {
|
|
141
|
+
const safe = Math.max(0, seconds)
|
|
142
|
+
const days = Math.floor(safe / 86400)
|
|
143
|
+
const hours = Math.floor((safe % 86400) / 3600)
|
|
144
|
+
const minutes = Math.floor((safe % 3600) / 60)
|
|
145
|
+
const rest = safe % 60
|
|
146
|
+
if (days > 0) return days + 'd' + (hours > 0 ? hours + 'h' : '')
|
|
147
|
+
if (hours > 0) return hours + 'h' + (minutes > 0 ? minutes + 'm' : '')
|
|
148
|
+
if (minutes > 0) return minutes + 'm' + (rest > 0 ? rest + 's' : '')
|
|
149
|
+
return rest + 's'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render an epoch-ms timestamp as an exact countdown.
|
|
154
|
+
* @param at - epoch milliseconds.
|
|
155
|
+
* @param now - epoch milliseconds.
|
|
156
|
+
* @returns '4h22m后'-style, '即将' when due, or '' when unknown.
|
|
157
|
+
*/
|
|
158
|
+
const countdownFrom = (at, now) => {
|
|
159
|
+
if (!at) return ''
|
|
160
|
+
const seconds = Math.max(0, Math.round((at - now) / 1000))
|
|
161
|
+
if (seconds === 0) return '即将'
|
|
162
|
+
return compactDuration(seconds) + '后'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Name what a countdown counts down to: '4h22m后' + '重置' reads
|
|
167
|
+
* '4h22m后重置', and '即将' becomes '即将重置'.
|
|
168
|
+
* @param base - a countdownFrom result.
|
|
169
|
+
* @param noun - what happens when the countdown ends.
|
|
170
|
+
* @returns the named string, or '' untouched.
|
|
171
|
+
*/
|
|
172
|
+
const withNoun = (base, noun) =>
|
|
173
|
+
base === '' ? '' : base === '即将' ? '即将' + noun : base.replace(/后$/, '后' + noun)
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Render a reset time as an exact countdown.
|
|
177
|
+
* @param resetAt - epoch seconds.
|
|
178
|
+
* @param now - epoch milliseconds.
|
|
179
|
+
* @returns '4h22m后重置', or an empty string when unknown.
|
|
180
|
+
*/
|
|
181
|
+
const countdown = (resetAt, now) => withNoun(countdownFrom(resetAt * 1000, now), '重置')
|
|
182
|
+
|
|
183
|
+
// The latest quota reading, plus the subscribers waiting on it.
|
|
184
|
+
let state = { phase: 'loading' }
|
|
185
|
+
const listeners = new Set()
|
|
186
|
+
let polling
|
|
187
|
+
|
|
188
|
+
/** The latest status reading, plus its subscribers. */
|
|
189
|
+
let status = undefined
|
|
190
|
+
const statusListeners = new Set()
|
|
191
|
+
let statusPolling
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Publish a new quota reading to every mounted panel.
|
|
195
|
+
* @param next - the reading.
|
|
196
|
+
*/
|
|
197
|
+
const publish = (next) => {
|
|
198
|
+
state = next
|
|
199
|
+
for (const listener of listeners) listener(next)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Publish a new status reading to every mounted panel.
|
|
204
|
+
* @param next - the status.
|
|
205
|
+
*/
|
|
206
|
+
const publishStatus = (next) => {
|
|
207
|
+
status = next
|
|
208
|
+
for (const listener of statusListeners) listener(next)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Poll the quota endpoint.
|
|
213
|
+
* @param force - ask the host to bypass its throttle.
|
|
214
|
+
* @returns the in-flight poll.
|
|
215
|
+
*/
|
|
216
|
+
const poll = (force) => {
|
|
217
|
+
polling ??= (async () => {
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(ENDPOINT + (force ? '?refresh=1' : ''), { cache: 'no-store' })
|
|
220
|
+
if (!response.ok) throw new Error('endpoint answered ' + response.status)
|
|
221
|
+
publish(await response.json())
|
|
222
|
+
} catch (error) {
|
|
223
|
+
publish({ phase: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
224
|
+
}
|
|
225
|
+
})().finally(() => {
|
|
226
|
+
polling = undefined
|
|
227
|
+
})
|
|
228
|
+
return polling
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Poll the status endpoint.
|
|
233
|
+
* @returns the in-flight poll.
|
|
234
|
+
*/
|
|
235
|
+
const pollStatus = () => {
|
|
236
|
+
statusPolling ??= (async () => {
|
|
237
|
+
try {
|
|
238
|
+
const response = await fetch(STATUS_ENDPOINT, { cache: 'no-store' })
|
|
239
|
+
if (!response.ok) throw new Error('endpoint answered ' + response.status)
|
|
240
|
+
publishStatus(await response.json())
|
|
241
|
+
} catch (error) {
|
|
242
|
+
publishStatus({ error: error instanceof Error ? error.message : String(error) })
|
|
243
|
+
}
|
|
244
|
+
})().finally(() => {
|
|
245
|
+
statusPolling = undefined
|
|
246
|
+
})
|
|
247
|
+
return statusPolling
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* POST JSON to a host endpoint.
|
|
252
|
+
* @param url - the endpoint.
|
|
253
|
+
* @param body - the JSON body.
|
|
254
|
+
* @returns the parsed reply.
|
|
255
|
+
*/
|
|
256
|
+
const postJson = async (url, body) => {
|
|
257
|
+
const response = await fetch(url, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'content-type': 'application/json' },
|
|
260
|
+
body: JSON.stringify(body),
|
|
261
|
+
})
|
|
262
|
+
const payload = await response.json().catch(() => ({}))
|
|
263
|
+
if (!response.ok) throw new Error(payload.message || 'endpoint answered ' + response.status)
|
|
264
|
+
return payload
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Short fixed strings, kept out of the render body. */
|
|
268
|
+
const STR = {
|
|
269
|
+
authSource: 'Auth 来源',
|
|
270
|
+
nextRefresh: '自动刷新',
|
|
271
|
+
proxy: 'HTTP 代理',
|
|
272
|
+
direct: '直连',
|
|
273
|
+
test: '测试连接',
|
|
274
|
+
save: '保存',
|
|
275
|
+
saved: '已保存并生效,重启后仍使用该代理',
|
|
276
|
+
testing: '测试中…',
|
|
277
|
+
saving: '保存中…',
|
|
278
|
+
marginNote: (margin, sync) => 'Token 剩余不足 ' + margin + ' 分钟时自动刷新;每 ' + sync + ' 分钟定期同步',
|
|
279
|
+
unknown: '未知(Token 无有效 exp)',
|
|
280
|
+
configOnly: '来自配置',
|
|
281
|
+
overridden: '页面设置',
|
|
282
|
+
ok: (ms) => '连接正常 · ' + ms + 'ms',
|
|
283
|
+
fail: '连接失败',
|
|
284
|
+
authFileLabel: 'Auth 文件',
|
|
285
|
+
authPlaceholder: '~/.codex/auth.json',
|
|
286
|
+
authSaved: '已保存并生效,重启后仍使用该路径',
|
|
287
|
+
authResetHint: '清空并保存恢复配置默认',
|
|
288
|
+
authUnpublished: '已切换,但 Token 发布失败,将在下个同步周期自动重试',
|
|
289
|
+
browse: '浏览…',
|
|
290
|
+
browseTitle: '选择 auth 文件',
|
|
291
|
+
cancel: '取消',
|
|
292
|
+
upLabel: '..',
|
|
293
|
+
truncatedNote: '条目过多,仅显示部分',
|
|
294
|
+
browseFail: '无法读取目录',
|
|
295
|
+
noFiveHour: '当前账号无 5 小时限额,仅按周限额计',
|
|
296
|
+
noWindows: '上游未报告任何限额窗口',
|
|
297
|
+
resetsLeft: (count) => '还有 ' + count + ' 次重置',
|
|
298
|
+
resetCredits: '重置次数',
|
|
299
|
+
rcLoad: '查询重置次数',
|
|
300
|
+
rcLoading: '查询中…',
|
|
301
|
+
rcAvailable: (n) => '可用 ' + n + ' 次',
|
|
302
|
+
rcEmpty: '当前没有可用的重置次数',
|
|
303
|
+
rcUse: '使用并重置',
|
|
304
|
+
rcUsing: '使用中…',
|
|
305
|
+
rcConfirm: (title) => '确定使用这个重置次数吗?将立即重置配额窗口:' + (title || '未命名'),
|
|
306
|
+
rcConsumeFail: '使用失败',
|
|
307
|
+
rcOutcomes: {
|
|
308
|
+
reset: (n) => '✓ 已重置(' + n + ' 个窗口)',
|
|
309
|
+
nothing_to_reset: '✗ 当前没有可重置的窗口',
|
|
310
|
+
no_credit: '✗ 没有可用的重置次数',
|
|
311
|
+
already_redeemed: '✗ 该次数已被使用过',
|
|
312
|
+
},
|
|
313
|
+
rcStatus: { available: '可用', redeeming: '兑换中', redeemed: '已使用', unknown: '未知' },
|
|
314
|
+
rcExpires: (text) => '(' + text + ' 前有效)',
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The proxy control block: current URL, editable, with test and save.
|
|
319
|
+
* @returns the rendered block.
|
|
320
|
+
*/
|
|
321
|
+
const ProxyControl = () => {
|
|
322
|
+
const current = status && status.proxyUrl !== undefined ? status.proxyUrl : ''
|
|
323
|
+
const [draft, setDraft] = useState(current)
|
|
324
|
+
const [dirty, setDirty] = useState(false)
|
|
325
|
+
const [busy, setBusy] = useState('')
|
|
326
|
+
const [note, setNote] = useState('')
|
|
327
|
+
const [result, setResult] = useState(undefined)
|
|
328
|
+
|
|
329
|
+
// Follow host-side changes (poll) until the user edits the field.
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!dirty) setDraft(current)
|
|
332
|
+
}, [current, dirty])
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Probe the candidate URL currently in the input.
|
|
336
|
+
* @returns nothing.
|
|
337
|
+
*/
|
|
338
|
+
const onTest = async () => {
|
|
339
|
+
setBusy('test')
|
|
340
|
+
setNote('')
|
|
341
|
+
setResult(undefined)
|
|
342
|
+
try {
|
|
343
|
+
const probed = await postJson(PROXY_TEST_ENDPOINT, { proxyUrl: draft.trim() })
|
|
344
|
+
setResult(probed)
|
|
345
|
+
if (!probed.ok) setNote('✗ ' + (probed.message || STR.fail))
|
|
346
|
+
} catch (error) {
|
|
347
|
+
setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
|
|
348
|
+
} finally {
|
|
349
|
+
setBusy('')
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Persist and apply the candidate URL currently in the input.
|
|
355
|
+
* @returns nothing.
|
|
356
|
+
*/
|
|
357
|
+
const onSave = async () => {
|
|
358
|
+
setBusy('save')
|
|
359
|
+
setNote('')
|
|
360
|
+
setResult(undefined)
|
|
361
|
+
try {
|
|
362
|
+
await postJson(PROXY_ENDPOINT, { proxyUrl: draft.trim() })
|
|
363
|
+
setDirty(false)
|
|
364
|
+
setNote(STR.saved)
|
|
365
|
+
await Promise.all([pollStatus(), poll(false)])
|
|
366
|
+
} catch (error) {
|
|
367
|
+
setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
|
|
368
|
+
} finally {
|
|
369
|
+
setBusy('')
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const okPill =
|
|
374
|
+
result && result.ok
|
|
375
|
+
? React.createElement(Badge, null, '✓ ' + STR.ok(result.latencyMs))
|
|
376
|
+
: null
|
|
377
|
+
const failPill =
|
|
378
|
+
result && !result.ok
|
|
379
|
+
? React.createElement(Badge, null, '✗ ' + STR.fail)
|
|
380
|
+
: null
|
|
381
|
+
|
|
382
|
+
const inputProps = {
|
|
383
|
+
value: draft,
|
|
384
|
+
placeholder: 'http://127.0.0.1:7890,留空表示直连',
|
|
385
|
+
spellCheck: false,
|
|
386
|
+
disabled: busy !== '',
|
|
387
|
+
onChange: (event) => {
|
|
388
|
+
setDirty(true)
|
|
389
|
+
setDraft(event.target.value)
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return React.createElement(
|
|
394
|
+
'div',
|
|
395
|
+
null,
|
|
396
|
+
React.createElement(
|
|
397
|
+
'div',
|
|
398
|
+
{ className: 'dshGptSubProxyRow' },
|
|
399
|
+
React.createElement(
|
|
400
|
+
'div',
|
|
401
|
+
{ className: 'dshGptSubProxyField' },
|
|
402
|
+
Input !== undefined
|
|
403
|
+
? React.createElement(Input, inputProps)
|
|
404
|
+
: React.createElement('input', { className: 'dshGptSubFallbackInput', ...inputProps }),
|
|
405
|
+
),
|
|
406
|
+
React.createElement(
|
|
407
|
+
SmallButton,
|
|
408
|
+
{ variant: 'outline', disabled: busy !== '', onClick: onTest },
|
|
409
|
+
busy === 'test' ? STR.testing : STR.test,
|
|
410
|
+
),
|
|
411
|
+
React.createElement(
|
|
412
|
+
SmallButton,
|
|
413
|
+
{ variant: 'primary', disabled: busy !== '', onClick: onSave },
|
|
414
|
+
busy === 'save' ? STR.saving : STR.save,
|
|
415
|
+
),
|
|
416
|
+
),
|
|
417
|
+
React.createElement(
|
|
418
|
+
'div',
|
|
419
|
+
{ className: 'dshGptSubProxyMeta' },
|
|
420
|
+
React.createElement(Badge, null, current === '' ? STR.direct : current),
|
|
421
|
+
React.createElement(Badge, null, status && status.overridden ? STR.overridden : STR.configOnly),
|
|
422
|
+
okPill,
|
|
423
|
+
failPill,
|
|
424
|
+
),
|
|
425
|
+
note ? React.createElement('div', { className: 'dshGptSubNote' }, note) : null,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The credential-file control block: an editable path, a browse dialog
|
|
431
|
+
* backed by the host's read-only directory listing, and save. The host
|
|
432
|
+
* validates a candidate against the real file before anything is
|
|
433
|
+
* persisted or applied; saving the empty string restores the configured
|
|
434
|
+
* default. A browser cannot hand back absolute paths from its native
|
|
435
|
+
* picker, which is why browsing runs through the host instead.
|
|
436
|
+
* @returns the rendered block.
|
|
437
|
+
*/
|
|
438
|
+
const AuthControl = () => {
|
|
439
|
+
const current = status && status.authFile !== undefined ? status.authFile : ''
|
|
440
|
+
const [draft, setDraft] = useState(current)
|
|
441
|
+
const [dirty, setDirty] = useState(false)
|
|
442
|
+
const [busy, setBusy] = useState(false)
|
|
443
|
+
const [note, setNote] = useState('')
|
|
444
|
+
const [browse, setBrowse] = useState(undefined)
|
|
445
|
+
|
|
446
|
+
// Follow host-side changes (poll) until the user edits the field.
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (!dirty) setDraft(current)
|
|
449
|
+
}, [current, dirty])
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Validate, persist, and apply the path currently in the input.
|
|
453
|
+
* @returns nothing.
|
|
454
|
+
*/
|
|
455
|
+
const onSave = async () => {
|
|
456
|
+
setBusy(true)
|
|
457
|
+
setNote('')
|
|
458
|
+
try {
|
|
459
|
+
const payload = await postJson(AUTH_ENDPOINT, { authFile: draft.trim() })
|
|
460
|
+
setDirty(false)
|
|
461
|
+
setNote(payload.published === false ? STR.authUnpublished : STR.authSaved)
|
|
462
|
+
await pollStatus()
|
|
463
|
+
} catch (error) {
|
|
464
|
+
setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
|
|
465
|
+
} finally {
|
|
466
|
+
setBusy(false)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* List one directory through the host, defaulting to the home directory.
|
|
472
|
+
* @param path - the directory to list; empty means home.
|
|
473
|
+
* @returns nothing; failures surface as the note under the input.
|
|
474
|
+
*/
|
|
475
|
+
const openBrowserAt = async (path) => {
|
|
476
|
+
setNote('')
|
|
477
|
+
try {
|
|
478
|
+
const query = path ? '?path=' + encodeURIComponent(path) : ''
|
|
479
|
+
const response = await fetch(AUTH_BROWSE_ENDPOINT + query, { cache: 'no-store' })
|
|
480
|
+
const payload = await response.json().catch(() => ({}))
|
|
481
|
+
if (!response.ok || !payload.ok) throw new Error(payload.message || STR.browseFail)
|
|
482
|
+
setBrowse(payload)
|
|
483
|
+
} catch (error) {
|
|
484
|
+
setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Put a picked file's path into the input without saving yet.
|
|
490
|
+
* @param path - the picked file's absolute path.
|
|
491
|
+
* @returns nothing.
|
|
492
|
+
*/
|
|
493
|
+
const pick = (path) => {
|
|
494
|
+
setDirty(true)
|
|
495
|
+
setDraft(path)
|
|
496
|
+
setBrowse(undefined)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const inputProps = {
|
|
500
|
+
value: draft,
|
|
501
|
+
placeholder: STR.authPlaceholder,
|
|
502
|
+
spellCheck: false,
|
|
503
|
+
disabled: busy,
|
|
504
|
+
onChange: (event) => {
|
|
505
|
+
setDirty(true)
|
|
506
|
+
setDraft(event.target.value)
|
|
507
|
+
},
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return React.createElement(
|
|
511
|
+
React.Fragment,
|
|
512
|
+
null,
|
|
513
|
+
React.createElement(
|
|
514
|
+
'div',
|
|
515
|
+
null,
|
|
516
|
+
React.createElement(
|
|
517
|
+
'div',
|
|
518
|
+
{ className: 'dshGptSubProxyRow' },
|
|
519
|
+
React.createElement(
|
|
520
|
+
'div',
|
|
521
|
+
{ className: 'dshGptSubProxyField' },
|
|
522
|
+
Input !== undefined
|
|
523
|
+
? React.createElement(Input, inputProps)
|
|
524
|
+
: React.createElement('input', { className: 'dshGptSubFallbackInput', ...inputProps }),
|
|
525
|
+
),
|
|
526
|
+
React.createElement(
|
|
527
|
+
SmallButton,
|
|
528
|
+
{ variant: 'outline', disabled: busy, onClick: () => void openBrowserAt('') },
|
|
529
|
+
STR.browse,
|
|
530
|
+
),
|
|
531
|
+
React.createElement(
|
|
532
|
+
SmallButton,
|
|
533
|
+
{ variant: 'primary', disabled: busy, onClick: onSave },
|
|
534
|
+
busy ? STR.saving : STR.save,
|
|
535
|
+
),
|
|
536
|
+
),
|
|
537
|
+
React.createElement(
|
|
538
|
+
'div',
|
|
539
|
+
{ className: 'dshGptSubProxyMeta' },
|
|
540
|
+
React.createElement(Badge, null, status && status.authOverridden ? STR.overridden : STR.configOnly),
|
|
541
|
+
React.createElement('span', null, STR.authResetHint),
|
|
542
|
+
),
|
|
543
|
+
note ? React.createElement('div', { className: 'dshGptSubNote' }, note) : null,
|
|
544
|
+
),
|
|
545
|
+
browse === undefined
|
|
546
|
+
? null
|
|
547
|
+
: React.createElement(
|
|
548
|
+
'div',
|
|
549
|
+
{ className: 'dshGptSubBrowseMask', onClick: () => setBrowse(undefined) },
|
|
550
|
+
React.createElement(
|
|
551
|
+
'div',
|
|
552
|
+
{ className: 'dshGptSubBrowse', onClick: (event) => event.stopPropagation() },
|
|
553
|
+
React.createElement('div', { className: 'dshGptSubBrowseHead' }, STR.browseTitle),
|
|
554
|
+
React.createElement('div', { className: 'dshGptSubBrowsePath' }, browse.dir),
|
|
555
|
+
React.createElement(
|
|
556
|
+
'ul',
|
|
557
|
+
{ className: 'dshGptSubBrowseList' },
|
|
558
|
+
browse.parent
|
|
559
|
+
? React.createElement(
|
|
560
|
+
'li',
|
|
561
|
+
{
|
|
562
|
+
key: '..',
|
|
563
|
+
className: 'dshGptSubBrowseRow',
|
|
564
|
+
onClick: () => void openBrowserAt(browse.parent),
|
|
565
|
+
},
|
|
566
|
+
React.createElement('span', { className: 'dshGptSubBrowseMark' }, '↰'),
|
|
567
|
+
STR.upLabel,
|
|
568
|
+
)
|
|
569
|
+
: null,
|
|
570
|
+
browse.entries.map((entry) =>
|
|
571
|
+
React.createElement(
|
|
572
|
+
'li',
|
|
573
|
+
{
|
|
574
|
+
key: entry.path,
|
|
575
|
+
className: 'dshGptSubBrowseRow',
|
|
576
|
+
title: entry.path,
|
|
577
|
+
onClick: () => (entry.dir ? void openBrowserAt(entry.path) : pick(entry.path)),
|
|
578
|
+
},
|
|
579
|
+
React.createElement('span', { className: 'dshGptSubBrowseMark' }, entry.dir ? '▸' : '·'),
|
|
580
|
+
entry.name,
|
|
581
|
+
),
|
|
582
|
+
),
|
|
583
|
+
),
|
|
584
|
+
React.createElement(
|
|
585
|
+
'div',
|
|
586
|
+
{ className: 'dshGptSubBrowseFoot' },
|
|
587
|
+
browse.truncated
|
|
588
|
+
? React.createElement('span', { className: 'dshGptSubNote' }, STR.truncatedNote)
|
|
589
|
+
: null,
|
|
590
|
+
React.createElement(
|
|
591
|
+
SmallButton,
|
|
592
|
+
{ variant: 'outline', onClick: () => setBrowse(undefined) },
|
|
593
|
+
STR.cancel,
|
|
594
|
+
),
|
|
595
|
+
),
|
|
596
|
+
),
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The rate-limit reset credits block: lists the account's redeemable
|
|
603
|
+
* credits on demand, and consumes one after an explicit confirmation.
|
|
604
|
+
* Consuming is destructive, so nothing here fires without a click.
|
|
605
|
+
* @returns the rendered block.
|
|
606
|
+
*/
|
|
607
|
+
const ResetCreditsControl = () => {
|
|
608
|
+
const [details, setDetails] = useState(undefined)
|
|
609
|
+
const [busy, setBusy] = useState(false)
|
|
610
|
+
const [consumingId, setConsumingId] = useState('')
|
|
611
|
+
const [note, setNote] = useState('')
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Fetch the credit list from the host.
|
|
615
|
+
* @returns nothing; failures surface as the note.
|
|
616
|
+
*/
|
|
617
|
+
const load = async () => {
|
|
618
|
+
setBusy(true)
|
|
619
|
+
setNote('')
|
|
620
|
+
try {
|
|
621
|
+
const response = await fetch(RESET_CREDITS_ENDPOINT, { cache: 'no-store' })
|
|
622
|
+
const payload = await response.json().catch(() => ({}))
|
|
623
|
+
if (!response.ok || !payload.ok) throw new Error(payload.message || 'endpoint answered ' + response.status)
|
|
624
|
+
setDetails(payload)
|
|
625
|
+
} catch (error) {
|
|
626
|
+
setNote('✗ ' + (error instanceof Error ? error.message : String(error)))
|
|
627
|
+
} finally {
|
|
628
|
+
setBusy(false)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Load once on mount; later reads happen through the visible button.
|
|
633
|
+
useEffect(() => {
|
|
634
|
+
void load()
|
|
635
|
+
}, [])
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Consume one credit after a confirmation dialog, then refresh both the
|
|
639
|
+
* credit list and the quota reading.
|
|
640
|
+
* @param credit - the credit to consume; null means "any available".
|
|
641
|
+
* @returns nothing.
|
|
642
|
+
*/
|
|
643
|
+
const consume = async (credit) => {
|
|
644
|
+
const title = credit ? credit.title || credit.id : ''
|
|
645
|
+
if (!window.confirm(STR.rcConfirm(title))) return
|
|
646
|
+
setConsumingId(credit ? credit.id : '*')
|
|
647
|
+
setNote('')
|
|
648
|
+
try {
|
|
649
|
+
const payload = await postJson(RESET_CREDITS_CONSUME_ENDPOINT, {
|
|
650
|
+
...(credit ? { creditId: credit.id } : {}),
|
|
651
|
+
})
|
|
652
|
+
const outcome = STR.rcOutcomes[payload.code]
|
|
653
|
+
setNote(outcome ? outcome(payload.windows_reset || 0) : payload.code)
|
|
654
|
+
await Promise.all([load(), poll(false)])
|
|
655
|
+
} catch (error) {
|
|
656
|
+
setNote('✗ ' + STR.rcConsumeFail + ':' + (error instanceof Error ? error.message : String(error)))
|
|
657
|
+
} finally {
|
|
658
|
+
setConsumingId('')
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (details === undefined) {
|
|
663
|
+
return React.createElement(
|
|
664
|
+
'div',
|
|
665
|
+
{ className: 'dshGptSubProxyMeta' },
|
|
666
|
+
React.createElement(
|
|
667
|
+
SmallButton,
|
|
668
|
+
{ variant: 'outline', disabled: busy, onClick: () => void load() },
|
|
669
|
+
busy ? STR.rcLoading : STR.rcLoad,
|
|
670
|
+
),
|
|
671
|
+
note ? React.createElement('span', null, note) : null,
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const available = details.credits.filter((credit) => credit.status === 'available')
|
|
676
|
+
return React.createElement(
|
|
677
|
+
'div',
|
|
678
|
+
null,
|
|
679
|
+
React.createElement(
|
|
680
|
+
'div',
|
|
681
|
+
{ className: 'dshGptSubProxyMeta' },
|
|
682
|
+
React.createElement(Badge, null, STR.rcAvailable(details.available_count)),
|
|
683
|
+
React.createElement(
|
|
684
|
+
SmallButton,
|
|
685
|
+
{ variant: 'ghost', disabled: busy, onClick: () => void load() },
|
|
686
|
+
busy ? STR.rcLoading : '刷新',
|
|
687
|
+
),
|
|
688
|
+
),
|
|
689
|
+
available.length === 0
|
|
690
|
+
? React.createElement('div', { className: 'dshGptSubNote' }, STR.rcEmpty)
|
|
691
|
+
: React.createElement(
|
|
692
|
+
'div',
|
|
693
|
+
null,
|
|
694
|
+
available.map((credit) =>
|
|
695
|
+
React.createElement(
|
|
696
|
+
'div',
|
|
697
|
+
{ key: credit.id, className: 'dshGptSubProxyMeta' },
|
|
698
|
+
React.createElement(
|
|
699
|
+
'span',
|
|
700
|
+
null,
|
|
701
|
+
(credit.title || credit.id) +
|
|
702
|
+
' · ' +
|
|
703
|
+
(STR.rcStatus[credit.status] || credit.status) +
|
|
704
|
+
(credit.expires_at
|
|
705
|
+
? STR.rcExpires(new Date(credit.expires_at).toLocaleString())
|
|
706
|
+
: ''),
|
|
707
|
+
),
|
|
708
|
+
React.createElement(
|
|
709
|
+
SmallButton,
|
|
710
|
+
{
|
|
711
|
+
variant: 'outline',
|
|
712
|
+
disabled: busy || consumingId !== '',
|
|
713
|
+
onClick: () => void consume(credit),
|
|
714
|
+
},
|
|
715
|
+
consumingId === credit.id ? STR.rcUsing : STR.rcUse,
|
|
716
|
+
),
|
|
717
|
+
),
|
|
718
|
+
),
|
|
719
|
+
),
|
|
720
|
+
note ? React.createElement('div', { className: 'dshGptSubNote' }, note) : null,
|
|
721
|
+
)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The Settings section body.
|
|
726
|
+
* @returns the rendered panel.
|
|
727
|
+
*/
|
|
728
|
+
const QuotaSection = () => {
|
|
729
|
+
ensureStyle()
|
|
730
|
+
const [reading, setReading] = useState(state)
|
|
731
|
+
const [stat, setStat] = useState(status)
|
|
732
|
+
const [now, setNow] = useState(() => Date.now())
|
|
733
|
+
|
|
734
|
+
useEffect(() => {
|
|
735
|
+
listeners.add(setReading)
|
|
736
|
+
statusListeners.add(setStat)
|
|
737
|
+
const clock = setInterval(() => { setNow(Date.now()) }, 30_000)
|
|
738
|
+
return () => {
|
|
739
|
+
listeners.delete(setReading)
|
|
740
|
+
statusListeners.delete(setStat)
|
|
741
|
+
clearInterval(clock)
|
|
742
|
+
}
|
|
743
|
+
}, [])
|
|
744
|
+
|
|
745
|
+
if (reading.phase === 'loading') {
|
|
746
|
+
return React.createElement('div', { className: 'dshGptSub' }, '读取中…')
|
|
747
|
+
}
|
|
748
|
+
if (reading.phase === 'error') {
|
|
749
|
+
return React.createElement(
|
|
750
|
+
'div',
|
|
751
|
+
{ className: 'dshGptSub' },
|
|
752
|
+
React.createElement('div', { className: 'dshGptSubNote' }, '无法读取配额:' + (reading.message || '未知错误')),
|
|
753
|
+
React.createElement(
|
|
754
|
+
'div',
|
|
755
|
+
{ className: 'dshGptSubBtn' },
|
|
756
|
+
React.createElement(SmallButton, { variant: 'outline', onClick: () => poll(true) }, '重试'),
|
|
757
|
+
),
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Label a window by its length: '5 小时窗口', '7 天窗口'.
|
|
763
|
+
* @param hours - the window's length in hours.
|
|
764
|
+
* @returns the label.
|
|
765
|
+
*/
|
|
766
|
+
const windowLabel = (hours) => (hours >= 24 ? Math.round(hours / 24) + ' 天窗口' : hours + ' 小时窗口')
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* One window's block: label, a track of the percent REMAINING, and the
|
|
770
|
+
* reset countdown. Codex-style -- the bar starts full at 100% and drains
|
|
771
|
+
* toward 0% as the allowance is spent, so its length is what is left.
|
|
772
|
+
* @param label - the window label.
|
|
773
|
+
* @param window - the window reading from the host.
|
|
774
|
+
* @returns the rendered block.
|
|
775
|
+
*/
|
|
776
|
+
const WindowBlock = (label, window) => {
|
|
777
|
+
const used = typeof window.usedPercent === 'number' ? window.usedPercent : 0
|
|
778
|
+
const remaining = Math.min(100, Math.max(0, 100 - used))
|
|
779
|
+
return React.createElement(
|
|
780
|
+
'div',
|
|
781
|
+
{ className: 'dshGptSubWin' },
|
|
782
|
+
React.createElement('div', { className: 'dshGptSubWinLabel' }, label),
|
|
783
|
+
React.createElement(
|
|
784
|
+
'div',
|
|
785
|
+
{ className: 'dshGptSubTrack' },
|
|
786
|
+
React.createElement('div', {
|
|
787
|
+
className: 'dshGptSubFill',
|
|
788
|
+
style: { width: remaining + '%', background: tierColor(remaining) },
|
|
789
|
+
}),
|
|
790
|
+
),
|
|
791
|
+
React.createElement(
|
|
792
|
+
'div',
|
|
793
|
+
{ className: 'dshGptSubMeta' },
|
|
794
|
+
React.createElement('span', null, '剩余 ' + remaining + '%'),
|
|
795
|
+
React.createElement('span', null, countdown(window.resetAt, now)),
|
|
796
|
+
),
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const refreshLabel = stat
|
|
801
|
+
? stat.refreshAt
|
|
802
|
+
? countdownFrom(stat.refreshAt, now) + '(' + STR.marginNote(stat.refreshMarginMinutes, stat.syncIntervalMinutes) + ')'
|
|
803
|
+
: STR.unknown
|
|
804
|
+
: ''
|
|
805
|
+
const authLabel = stat ? stat.authFile || '' : ''
|
|
806
|
+
const tokenExpiryLabel = stat && stat.tokenExpiresAt ? withNoun(countdownFrom(stat.tokenExpiresAt, now), '过期') : ''
|
|
807
|
+
|
|
808
|
+
return React.createElement(
|
|
809
|
+
'div',
|
|
810
|
+
{ className: 'dshGptSub' },
|
|
811
|
+
React.createElement(
|
|
812
|
+
'div',
|
|
813
|
+
{ className: 'dshGptSubHead' },
|
|
814
|
+
React.createElement('span', { className: 'dshGptSubPlan' }, 'ChatGPT ' + (reading.plan || '')),
|
|
815
|
+
// The on-demand usage resets the account can still spend to clear
|
|
816
|
+
// a capped window without waiting out its timer.
|
|
817
|
+
reading.resetsRemaining !== undefined
|
|
818
|
+
? React.createElement(Badge, null, STR.resetsLeft(reading.resetsRemaining))
|
|
819
|
+
: null,
|
|
820
|
+
React.createElement(
|
|
821
|
+
'span',
|
|
822
|
+
{ className: 'dshGptSubBtn' },
|
|
823
|
+
React.createElement(SmallButton, { variant: 'ghost', onClick: () => poll(true) }, '刷新'),
|
|
824
|
+
),
|
|
825
|
+
),
|
|
826
|
+
// The 5-hour rolling window first -- it is the one that bites first --
|
|
827
|
+
// then the weekly one. An account without a 5-hour limit (pro
|
|
828
|
+
// currently) gets a note where its bar would be, not an empty bar.
|
|
829
|
+
reading.fiveHour !== undefined
|
|
830
|
+
? WindowBlock(windowLabel(reading.fiveHour.windowHours), reading.fiveHour)
|
|
831
|
+
: reading.weekly !== undefined
|
|
832
|
+
? React.createElement('div', { className: 'dshGptSubNote' }, STR.noFiveHour)
|
|
833
|
+
: null,
|
|
834
|
+
reading.weekly !== undefined ? WindowBlock(windowLabel(reading.weekly.windowHours), reading.weekly) : null,
|
|
835
|
+
reading.fiveHour === undefined && reading.weekly === undefined
|
|
836
|
+
? React.createElement('div', { className: 'dshGptSubNote' }, STR.noWindows)
|
|
837
|
+
: null,
|
|
838
|
+
reading.stale
|
|
839
|
+
? React.createElement('div', { className: 'dshGptSubNote' }, '数据可能已过期:' + (reading.message || ''))
|
|
840
|
+
: null,
|
|
841
|
+
React.createElement(
|
|
842
|
+
'div',
|
|
843
|
+
{ className: 'dshGptSubDiag' },
|
|
844
|
+
React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.authSource),
|
|
845
|
+
React.createElement(
|
|
846
|
+
'span',
|
|
847
|
+
{ className: 'dshGptSubDiagValue' },
|
|
848
|
+
authLabel + (tokenExpiryLabel ? ' · Access Token ' + tokenExpiryLabel : ''),
|
|
849
|
+
),
|
|
850
|
+
React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.authFileLabel),
|
|
851
|
+
React.createElement(
|
|
852
|
+
'span',
|
|
853
|
+
{ className: 'dshGptSubDiagValue' },
|
|
854
|
+
React.createElement(AuthControl, null),
|
|
855
|
+
),
|
|
856
|
+
React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.nextRefresh),
|
|
857
|
+
React.createElement('span', { className: 'dshGptSubDiagValue' }, refreshLabel),
|
|
858
|
+
React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.proxy),
|
|
859
|
+
React.createElement('span', { className: 'dshGptSubDiagValue' }, React.createElement(ProxyControl, null)),
|
|
860
|
+
React.createElement('span', { className: 'dshGptSubDiagLabel' }, STR.resetCredits),
|
|
861
|
+
React.createElement('span', { className: 'dshGptSubDiagValue' }, React.createElement(ResetCreditsControl, null)),
|
|
862
|
+
),
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Required services: the slot registry the panel registers into. */
|
|
867
|
+
exports.inject = ['slots']
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Client plugin body: poll while the page is visible, and register the
|
|
871
|
+
* panel as a Settings section.
|
|
872
|
+
* @param ctx - client root context.
|
|
873
|
+
*/
|
|
874
|
+
exports.apply = function apply(ctx) {
|
|
875
|
+
ctx.effect(() => {
|
|
876
|
+
void poll(false)
|
|
877
|
+
void pollStatus()
|
|
878
|
+
const timer = setInterval(() => {
|
|
879
|
+
if (document.visibilityState !== 'hidden') {
|
|
880
|
+
void poll(false)
|
|
881
|
+
void pollStatus()
|
|
882
|
+
}
|
|
883
|
+
}, POLL_MS)
|
|
884
|
+
const onVisibility = () => {
|
|
885
|
+
if (document.visibilityState === 'visible') {
|
|
886
|
+
void poll(false)
|
|
887
|
+
void pollStatus()
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
891
|
+
return () => {
|
|
892
|
+
clearInterval(timer)
|
|
893
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
894
|
+
}
|
|
895
|
+
}, 'gpt-sub: quota poll')
|
|
896
|
+
|
|
897
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
898
|
+
name: 'settings.section',
|
|
899
|
+
id: 'gpt-sub-quota',
|
|
900
|
+
order: 20,
|
|
901
|
+
label: () => 'Codex 配额',
|
|
902
|
+
inject: () => ({}),
|
|
903
|
+
}, QuotaSection))
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return module.exports
|
|
907
|
+
},
|
|
908
|
+
})
|