@huai_xin/dsh-workbuddy 0.1.1 → 0.1.2
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/README.md +2 -0
- package/client/index.js +85 -38
- package/docs/account-card.png +0 -0
- package/docs/ui-preview.png +0 -0
- package/package.json +1 -1
- package/src/api.js +40 -2
- package/src/regions.js +24 -0
package/README.md
CHANGED
|
@@ -151,6 +151,8 @@ dsh-workbuddy/
|
|
|
151
151
|
|
|
152
152
|
- 部署名旁的圆点是登录状态(绿=已登录 / 黄=等待授权 / 红=已过期 / 灰=未登录),悬停显示 `provider · endpoint`。
|
|
153
153
|
- 积分右侧的 **⟳** 只刷新这一行(走 `GET /workbuddy/credits?region=…`),不动其它数据。
|
|
154
|
+
- 同一行最右边还有 **「套餐」**:在浏览器打开该部署的官方套餐/用量页(大陆版 → `workbuddy.cn/profile/plans-usage`,海外版 → `workbuddy.ai/profile/plans-usage`)。地址由宿主从**页面白名单**解析,面板只传一个页面名,所以这个入口无法被用来打开任意网址。
|
|
155
|
+
- 每列的**值贴左边缘**(与账号、到期对齐),**⟳ 与「套餐」贴右边缘**,中间留白,窄面板下也不会挤出横向滚动条。
|
|
154
156
|
- **未登录的部署整列只显示一个居中的「登录」按钮**,不再是四行「未知」加一堆禁用按钮。
|
|
155
157
|
- 签到按钮(只有大陆版有)跟在部署名右边,悬停显示上次自动领取的结果。
|
|
156
158
|
|
package/client/index.js
CHANGED
|
@@ -51,6 +51,9 @@ window.__ModuleLoader__.load({
|
|
|
51
51
|
colCredits: '积分',
|
|
52
52
|
colActions: '操作',
|
|
53
53
|
refreshCredits: '刷新积分',
|
|
54
|
+
openPlans: '套餐',
|
|
55
|
+
openPlansHint: '在浏览器打开该部署的官方套餐页(用量与订阅)',
|
|
56
|
+
openPlansFailed: (url) => `没能自动打开浏览器,请手动访问:${url}`,
|
|
54
57
|
creditsRate: '消耗速度(实时)',
|
|
55
58
|
models: '模型',
|
|
56
59
|
login: '登录',
|
|
@@ -135,6 +138,9 @@ window.__ModuleLoader__.load({
|
|
|
135
138
|
colCredits: 'Credits',
|
|
136
139
|
colActions: 'Actions',
|
|
137
140
|
refreshCredits: 'Refresh credits',
|
|
141
|
+
openPlans: 'Plans',
|
|
142
|
+
openPlansHint: 'Open this deployment’s official plans page in your browser',
|
|
143
|
+
openPlansFailed: (url) => `Could not launch a browser — open this instead: ${url}`,
|
|
138
144
|
creditsRate: 'Consumption rate (live)',
|
|
139
145
|
models: 'Models',
|
|
140
146
|
login: 'Sign in',
|
|
@@ -236,6 +242,11 @@ window.__ModuleLoader__.load({
|
|
|
236
242
|
.wb-card + .wb-card { margin-top: 16px; }
|
|
237
243
|
.wb-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 14px 16px 12px; }
|
|
238
244
|
.wb-card-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--wb-muted); }
|
|
245
|
+
.wb-account-card { border-radius: 12px; border-color: var(--wb-line-soft); }
|
|
246
|
+
.wb-account-card .wb-card-head { gap: 10px; }
|
|
247
|
+
.wb-account-card .wb-card-title { letter-spacing: 0; text-transform: none; color: var(--wb-text); }
|
|
248
|
+
.wb-account-card .wb-btn { border-radius: 6px; }
|
|
249
|
+
.wb-account-card .wb-card-head .wb-btn { min-width: 52px; }
|
|
239
250
|
.wb-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px 12px; }
|
|
240
251
|
/* The mark is a mask painted with the accent colour: one asset, both themes. */
|
|
241
252
|
.wb-logo { flex: none; width: 30px; height: 30px; display: block; background: var(--wb-accent-strong);
|
|
@@ -245,47 +256,51 @@ window.__ModuleLoader__.load({
|
|
|
245
256
|
/* account table — transposed: deployments are columns, fields are rows ------
|
|
246
257
|
One grid for the whole table so a signed-out deployment can occupy its whole
|
|
247
258
|
column with a single centred sign-in button. */
|
|
248
|
-
.wb-table { display: grid; padding: 0
|
|
249
|
-
/*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
column-gap: 24px; row-gap: 0; align-items: stretch; }
|
|
262
|
-
/* One continuous separator between the deployments, drawn down the middle of
|
|
263
|
-
the symmetric grid. It fades out at both ends, so it reads as a division of
|
|
264
|
-
the two accounts rather than a stray table border. */
|
|
265
|
-
.wb-table-split::before { content: ''; position: absolute; left: 50%; top: 16px; bottom: 20px; width: 1px;
|
|
266
|
-
background: linear-gradient(to bottom, transparent, var(--wb-line) 14%, var(--wb-line) 86%, transparent); }
|
|
259
|
+
.wb-table { display: grid; position: relative; padding: 0 10px 8px; overflow-x: auto; --wb-cols: 2;
|
|
260
|
+
/* No trailing spacer: both accounts need enough width for an unbroken action
|
|
261
|
+
pair in the settings column. Narrower views scroll instead of wrapping. */
|
|
262
|
+
grid-template-columns: 56px repeat(var(--wb-cols), minmax(180px, 1fr));
|
|
263
|
+
/* Fixed tracks prevent account loading or login from resizing the card; the
|
|
264
|
+
action row needs less air than the four information rows above it. */
|
|
265
|
+
grid-template-rows: repeat(4, 52px) 34px;
|
|
266
|
+
column-gap: 32px; row-gap: 0; align-items: stretch; }
|
|
267
|
+
/* Centre the divider on the card, not the account-column gap: the label
|
|
268
|
+
column makes that gap visually off-centre. Account contents stay in place. */
|
|
269
|
+
.wb-table-split::before { content: ''; position: absolute; left: 50%; top: 18px; bottom: 8px; width: 1px;
|
|
270
|
+
transform: translateX(-50%); pointer-events: none;
|
|
271
|
+
background: linear-gradient(to bottom, transparent, var(--wb-line-soft) 12%, var(--wb-line-soft) 92%, transparent); }
|
|
267
272
|
.wb-row-label { display: flex; align-items: center; font-size: 11px; letter-spacing: .05em;
|
|
268
273
|
text-transform: uppercase; color: var(--wb-muted); }
|
|
269
|
-
.wb-th { display: flex; align-items: center; font-size: 12.5px; font-weight: 620; }
|
|
270
|
-
.wb-th .wb-btn { padding: 3px 8px; font-size: 11px; font-weight: 500;
|
|
274
|
+
.wb-th { min-width: 0; display: flex; align-items: center; font-size: 12.5px; font-weight: 620; white-space: nowrap; }
|
|
275
|
+
.wb-th .wb-btn { padding: 3px 8px; font-size: 11px; font-weight: 500; line-height: 1.2; }
|
|
271
276
|
.wb-deploy { display: flex; align-items: center; gap: 8px; }
|
|
272
277
|
.wb-deploy-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; }
|
|
273
278
|
/* The cell fills its row and holds its content left, the way the signed-in rows
|
|
274
279
|
read; only the standalone sign-in cell is centred. No rule between rows: the
|
|
275
280
|
spacing and the vertical separator already say where the columns are. */
|
|
276
281
|
.wb-td { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
282
|
+
/* Keep refresh and plans beside the balance, not at the far column edge. */
|
|
283
|
+
.wb-cell-actions { flex: none; display: inline-flex; align-items: center; gap: 8px; }
|
|
284
|
+
.wb-td-credits, .wb-td-last { flex-wrap: nowrap; }
|
|
285
|
+
.wb-td-last { gap: 6px; }
|
|
277
286
|
.wb-td-span { display: flex; align-items: center; justify-content: center; }
|
|
278
287
|
.wb-account-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
279
288
|
.wb-num { font-variant-numeric: tabular-nums; color: var(--wb-muted); }
|
|
280
289
|
.wb-credits-value { font-size: 16.5px; font-weight: 650; color: var(--wb-accent-strong);
|
|
281
290
|
font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
|
|
282
291
|
/* A bare icon action: no box, just the glyph, tinted on hover. */
|
|
283
|
-
.wb-icon-btn { font: inherit; font-size: 13px; line-height: 1; padding: 5px 7px; border-radius: 6px; cursor: pointer;
|
|
284
|
-
border: 0; background: transparent; color: var(--wb-muted); transition: background .15s, color .15s; }
|
|
292
|
+
.wb-icon-btn { font: inherit; font-size: 13px; line-height: 1; padding: 5px 7px; border-radius: 6px; cursor: pointer; border: 0; background: transparent; color: var(--wb-muted); transition: background .15s, color .15s; }
|
|
285
293
|
.wb-icon-btn:hover:not(:disabled) { background: var(--wb-accent-soft); color: var(--wb-accent-strong); }
|
|
286
294
|
.wb-icon-btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
/* The link out to the official plans page: same quiet weight as the icon
|
|
296
|
+
actions beside it, but it needs words to say where it goes — kept tight so the
|
|
297
|
+
balance row still fits one line in a narrow panel. */
|
|
298
|
+
.wb-link-btn { font: inherit; font-size: 11px; line-height: 1; padding: 4px 7px; border-radius: 6px;
|
|
299
|
+
cursor: pointer; border: 1px solid var(--wb-line-soft); background: transparent; color: var(--wb-muted);
|
|
300
|
+
white-space: nowrap; transition: background .15s, color .15s, border-color .15s; }
|
|
301
|
+
.wb-link-btn:hover { background: var(--wb-accent-soft); color: var(--wb-accent-strong); border-color: transparent; }
|
|
302
|
+
.wb-td .wb-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box;
|
|
303
|
+
padding: 4px 10px; font-size: 11.5px; min-width: 78px; min-height: 26px; white-space: nowrap; }
|
|
289
304
|
.wb-td-span .wb-btn { padding: 7px 16px; }
|
|
290
305
|
|
|
291
306
|
/* tab strip: two equal halves, the selected one a tinted segment ------------- */
|
|
@@ -851,6 +866,25 @@ window.__ModuleLoader__.load({
|
|
|
851
866
|
}
|
|
852
867
|
}
|
|
853
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Open the deployment's official plans page in the user's browser.
|
|
871
|
+
*
|
|
872
|
+
* The host resolves the URL from its own allowlist, so the panel only ever
|
|
873
|
+
* names a page. When launching fails the reply still carries the URL and
|
|
874
|
+
* the panel shows it, rather than pretending nothing happened.
|
|
875
|
+
*/
|
|
876
|
+
const openPlans = async (regionId) => {
|
|
877
|
+
setError('')
|
|
878
|
+
try {
|
|
879
|
+
const result = await request('/open', { method: 'POST', body: JSON.stringify({ region: regionId, page: 'plans' }) })
|
|
880
|
+
if (result.opened !== true && typeof result.url === 'string') {
|
|
881
|
+
setError(t('openPlansFailed')(result.url))
|
|
882
|
+
}
|
|
883
|
+
} catch (cause) {
|
|
884
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
854
888
|
const credits = region?.credits
|
|
855
889
|
const checkin = region?.checkin
|
|
856
890
|
const models = region === undefined ? undefined : modelMap[region.id]
|
|
@@ -1161,16 +1195,29 @@ window.__ModuleLoader__.load({
|
|
|
1161
1195
|
entry.credits !== null && entry.credits !== undefined && entry.credits.ok === true
|
|
1162
1196
|
? h('span', { key: 'value', className: 'wb-credits-value' }, formatCredits(entry.credits.total))
|
|
1163
1197
|
: h('span', { key: 'value', className: 'wb-muted' }, t('unknown')),
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1198
|
+
// All values share a left edge; the quiet balance actions follow
|
|
1199
|
+
// the figure directly so each deployment reads as one compact group.
|
|
1200
|
+
h('span', { key: 'actions', className: 'wb-cell-actions' }, [
|
|
1201
|
+
h('button', {
|
|
1202
|
+
key: 'refresh',
|
|
1203
|
+
type: 'button',
|
|
1204
|
+
className: 'wb-icon-btn',
|
|
1205
|
+
title: t('refreshCredits'),
|
|
1206
|
+
'aria-label': t('refreshCredits'),
|
|
1207
|
+
disabled: creditBusy === entry.id,
|
|
1208
|
+
onClick: () => void refreshCredits(entry.id)
|
|
1209
|
+
}, creditBusy === entry.id ? '…' : '⟳'),
|
|
1210
|
+
// The official plans page needs the deployment's own signed-in
|
|
1211
|
+
// session, so it opens in the user's browser rather than in here.
|
|
1212
|
+
h('button', {
|
|
1213
|
+
key: 'plans',
|
|
1214
|
+
type: 'button',
|
|
1215
|
+
className: 'wb-link-btn',
|
|
1216
|
+
title: t('openPlansHint'),
|
|
1217
|
+
onClick: () => void openPlans(entry.id)
|
|
1218
|
+
}, t('openPlans'))
|
|
1219
|
+
])
|
|
1220
|
+
], 'wb-td-credits'))
|
|
1174
1221
|
cells.push(cell(3, 'actions', accountActions(entry)))
|
|
1175
1222
|
})
|
|
1176
1223
|
return cells
|
|
@@ -1256,7 +1303,7 @@ window.__ModuleLoader__.load({
|
|
|
1256
1303
|
error.length > 0 ? h('p', { className: 'wb-error', role: 'alert' }, error) : null,
|
|
1257
1304
|
|
|
1258
1305
|
// Cards are separate concerns: who is signed in, and what they can run.
|
|
1259
|
-
h('div', { className: 'wb-card' },
|
|
1306
|
+
h('div', { className: 'wb-card wb-account-card' },
|
|
1260
1307
|
h('div', { className: 'wb-card-head' },
|
|
1261
1308
|
h(Logomark, null),
|
|
1262
1309
|
h('span', { className: 'wb-card-title' }, t('cardAccounts')),
|
|
@@ -1274,8 +1321,8 @@ window.__ModuleLoader__.load({
|
|
|
1274
1321
|
)
|
|
1275
1322
|
),
|
|
1276
1323
|
h('div', {
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1324
|
+
// A single separator stays at the card's centre when there are
|
|
1325
|
+
// exactly two deployments.
|
|
1279
1326
|
className: regions.length === 2 ? 'wb-table wb-table-split' : 'wb-table',
|
|
1280
1327
|
role: 'table',
|
|
1281
1328
|
style: { '--wb-cols': String(Math.max(1, regions.length)) }
|
|
Binary file
|
package/docs/ui-preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -21,7 +21,7 @@ import { claimDailyCheckin, fetchCheckinStatus, fetchCredits } from './credits.j
|
|
|
21
21
|
import { localDayKey } from './http.js'
|
|
22
22
|
import { isEnabled, readPrefs, setEnabled } from './model-prefs.js'
|
|
23
23
|
import { readOfficialList, writeOfficialList } from './official-list.js'
|
|
24
|
-
import { REGIONS, REGION_IDS } from './regions.js'
|
|
24
|
+
import { REGIONS, REGION_IDS, sitePageUrl } from './regions.js'
|
|
25
25
|
import { notifyModelTopology } from './topology.js'
|
|
26
26
|
|
|
27
27
|
const API_PREFIX = '/workbuddy'
|
|
@@ -33,6 +33,12 @@ const MASK_PATH = fileURLToPath(new URL('../icon-mask.png', import.meta.url))
|
|
|
33
33
|
let iconBytes
|
|
34
34
|
let maskBytes
|
|
35
35
|
|
|
36
|
+
/** Late-bound so importing this module never pulls in the child-process helper. */
|
|
37
|
+
async function defaultOpenInBrowser(url) {
|
|
38
|
+
const { openInBrowser } = await import('./regions.js')
|
|
39
|
+
return openInBrowser(url)
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
/**
|
|
37
43
|
* Which build of this file the running host actually loaded.
|
|
38
44
|
*
|
|
@@ -135,8 +141,14 @@ async function statePayload(ctx, adapters) {
|
|
|
135
141
|
/**
|
|
136
142
|
* Register the panel's routes. Call inside `ctx.inject(['webServer'], …)` so a
|
|
137
143
|
* composition without an HTTP carrier keeps the adapter and the command.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} ctx - host plugin context.
|
|
146
|
+
* @param {Record<string, object>} adapters - per-deployment adapters.
|
|
147
|
+
* @param {{ openInBrowser?: (url: string) => boolean }} [options] - the browser
|
|
148
|
+
* opener is injectable so tests never launch a real one.
|
|
138
149
|
*/
|
|
139
|
-
export function registerWorkbuddyApi(ctx, adapters) {
|
|
150
|
+
export function registerWorkbuddyApi(ctx, adapters, options = {}) {
|
|
151
|
+
const openInBrowser = options.openInBrowser ?? defaultOpenInBrowser
|
|
140
152
|
const rejected = (req, res) => {
|
|
141
153
|
const connection = ctx.get('connection')
|
|
142
154
|
if (connection === undefined || typeof connection.requestRejection !== 'function') return false
|
|
@@ -313,6 +325,32 @@ export function registerWorkbuddyApi(ctx, adapters) {
|
|
|
313
325
|
return
|
|
314
326
|
}
|
|
315
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Open one of the deployment's own site pages in the user's browser.
|
|
330
|
+
*
|
|
331
|
+
* The panel sends a page *name*; the URL comes from `SITE_PAGES`, so this
|
|
332
|
+
* route can never be talked into opening an arbitrary address. The reply
|
|
333
|
+
* carries the URL even when launching failed, so the panel can show it.
|
|
334
|
+
*/
|
|
335
|
+
if (path === `${API_PREFIX}/open` && method === 'POST') {
|
|
336
|
+
const body = await readJsonBody(req)
|
|
337
|
+
const region = REGIONS[String(body?.region ?? '').toLowerCase()]
|
|
338
|
+
const url = sitePageUrl(region, body?.page)
|
|
339
|
+
if (url === undefined) {
|
|
340
|
+
sendJson(res, 400, { error: 'unknown region or page' })
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
let opened = false
|
|
344
|
+
try {
|
|
345
|
+
opened = (await openInBrowser(url)) !== false
|
|
346
|
+
} catch (error) {
|
|
347
|
+
ctx.logger.warn(`[workbuddy] ${region.id}: 打开 ${url} 失败 — ${error instanceof Error ? error.message : String(error)}`)
|
|
348
|
+
}
|
|
349
|
+
ctx.logger.info(`[workbuddy] ${region.id}: 打开官方页面 ${url}(${opened ? '已交给浏览器' : '启动失败'})`)
|
|
350
|
+
sendJson(res, 200, { region: region.id, url, opened })
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
316
354
|
if (path === `${API_PREFIX}/availability`) {
|
|
317
355
|
const id = String(url.searchParams.get('region') ?? '').toLowerCase()
|
|
318
356
|
const region = REGIONS[id]
|
package/src/regions.js
CHANGED
|
@@ -76,6 +76,30 @@ export function regionList(raw) {
|
|
|
76
76
|
return region === undefined ? [] : [region]
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Site pages the panel may ask the user's browser to open.
|
|
81
|
+
*
|
|
82
|
+
* A fixed list on purpose: the panel sends a page *name*, never a URL, so a
|
|
83
|
+
* compromised or careless client cannot make the host open an arbitrary address
|
|
84
|
+
* (or a different host) through this route.
|
|
85
|
+
*/
|
|
86
|
+
export const SITE_PAGES = {
|
|
87
|
+
plans: '/profile/plans-usage'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve one allowed site page for a deployment.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} region - entry from `REGIONS`.
|
|
94
|
+
* @param {string} page - key from `SITE_PAGES`.
|
|
95
|
+
* @returns {string|undefined} absolute URL, or `undefined` when the pair is unknown.
|
|
96
|
+
*/
|
|
97
|
+
export function sitePageUrl(region, page) {
|
|
98
|
+
const path = SITE_PAGES[String(page ?? '').toLowerCase()]
|
|
99
|
+
if (region === undefined || path === undefined) return undefined
|
|
100
|
+
return `${region.endpoint}${path}`
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
/** Chat routes in preference order: the gateway first, the web host as fallback. */
|
|
80
104
|
export function chatRoutes(region) {
|
|
81
105
|
const primary = {
|