@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 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 12px 14px; overflow-x: auto; --wb-cols: 2; position: relative;
249
- /* A label column on the left and an empty spacer of the same width on the
250
- right: that is what makes the pair of deployment columns sit symmetrically
251
- in the card instead of being pushed right by the labels. Centring keeps the
252
- block in the middle once the columns hit their cap. */
253
- /* The minimum keeps a row on one line; below that the table scrolls instead of
254
- reflowing a row. Kept small because the balance row only ever holds the
255
- number and its refresh. */
256
- grid-template-columns: 64px repeat(var(--wb-cols), minmax(150px, 240px)) 64px;
257
- justify-content: center;
258
- /* Rows are a fixed height in every state, so the card does not change size
259
- between "loading" and "signed in", and the separators line up. */
260
- grid-auto-rows: 52px;
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; margin-left: 2px; }
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
- .wb-td .wb-btn { display: inline-flex; align-items: center; justify-content: center;
288
- padding: 5px 10px; font-size: 11.5px; min-width: 78px; }
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
- h('button', {
1165
- key: 'refresh',
1166
- type: 'button',
1167
- className: 'wb-icon-btn',
1168
- title: t('refreshCredits'),
1169
- 'aria-label': t('refreshCredits'),
1170
- disabled: creditBusy === entry.id,
1171
- onClick: () => void refreshCredits(entry.id)
1172
- }, creditBusy === entry.id ? '…' : '⟳')
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
- // The separator between deployments is drawn once, down the middle
1278
- // of the symmetric grid, when there is exactly a pair of them.
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
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "WorkBuddy model provider for DeepSeek Harness (international + China deployments)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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 = {