@huai_xin/dsh-workbuddy 0.1.0 → 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
@@ -22,11 +22,16 @@ WorkBuddy 模型接入插件(DeepSeek Harness / DSH),同时支持两个部
22
22
  # 从 npm 安装到某个 profile(会同时把 bundle 挂进该 profile)
23
23
  dsh plugin --profile <profile> add @huai_xin/dsh-workbuddy
24
24
 
25
- # 本地目录(未发布时)
25
+ # 直接从 GitHub 安装(npm 版本还没跟上改动时用它)
26
+ dsh plugin --profile <profile> add github:2424830124/dsh-workbuddy
27
+
28
+ # 本地目录(开发时)
26
29
  dsh plugin --profile <profile> add /path/to/dsh-workbuddy
27
30
  ```
28
31
 
29
- 也可以在 Web UI 的插件管理里安装(效果相同,都是往 profile 的 `dsh.profile.bundles` 里加一条并应用 patch)。
32
+ npm 页面:<https://www.npmjs.com/package/@huai_xin/dsh-workbuddy>
33
+
34
+ 也可以在 Web UI 的插件管理里安装(效果相同,都是往 profile 的依赖里加一条并应用 patch)。
30
35
 
31
36
  安装后需要重启 DSH(替换已加载的 JS 模块需要新的 generation),随后 `/workbuddy` 命令即可使用。
32
37
 
@@ -34,12 +39,9 @@ dsh plugin --profile <profile> add /path/to/dsh-workbuddy
34
39
 
35
40
  ### 设置页
36
41
 
37
- **设置 → WorkBuddy**(侧边栏齿轮 → WorkBuddy)是**一张卡片 + 账号标签页**:海外版与大陆版各自独立登录,点标签只切换查看,不影响已登录的账号。
42
+ 界面长什么样、每块显示什么,见下面的 [设置面板](#设置面板) 一节(含截图)。
38
43
 
39
- - 卡片头:状态圆点 + 部署名 + provider/端点 + 状态胶囊
40
- - 信息区:账号、到期时间、是否可自动续期、**积分**(总量 + 其中签到获得的)、**每日签到**(今日状态、连续天数、本周进度点)
41
- - 按钮:**登录**、**立即领取**(签到未完成时可点)、**续期**、**退出登录**
42
- - 卡片下方**常驻该账号的模型列表**,切标签即换列表
44
+ 面板通过宿主路由 `/workbuddy/{state,login,logout,refresh,models,checkin,availability,credits,busy,enabled,import}` 读写状态,这些路由走与宿主一致的连接信任栅栏,只对本机已被 GUI 接纳的来源开放。
43
45
 
44
46
  积分与签到:
45
47
 
@@ -48,8 +50,6 @@ dsh plugin --profile <profile> add /path/to/dsh-workbuddy
48
50
  - 登录成功后也会立刻补一次签到
49
51
  - 海外版后端没有签到接口,只显示积分、不显示领取按钮
50
52
 
51
- 面板通过宿主路由 `/workbuddy/{state,login,logout,refresh,models,checkin}` 读写状态,这些路由走与宿主一致的连接信任栅栏,只对本机已被 GUI 接纳的来源开放。
52
-
53
53
  ### 命令
54
54
 
55
55
  ```
@@ -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
 
@@ -163,6 +165,7 @@ dsh-workbuddy/
163
165
  - 繁忙程度**只有大陆版有**(`POST /v2/chat/busy`;海外版 404,那一列整个不显示):空闲 / 繁忙 / 拥堵 / 未知四档,用词与官方前端一致(官方把 `normal` 与 `idle` 并成「空闲」),悬停给出官方的完整说法(轻微拥堵 / 极度拥堵)。
164
166
  - 工具栏:搜索框、「全部启用 / 全部停用」(**只作用于可用分组**,不可用分组自己手动调)、「⟳ 刷新列表」、「检测可用性」(手动,立即执行)。这两个按钮宽度固定,「检测中…」不会把 ⟳ 挤走。
165
167
  - 打开面板时会自动检测一次(国内零消耗、可频繁;海外版每次探测一 token,每天最多一次)。
168
+ - 开关**立即生效**:翻转后插件会通知 harness「模型集变了」,模型选择器随即重读目录(它的目录是缓存的,不通知就会一直显示已关掉的模型)。可用性探测结果与真实调用中学到的判定也会即时反映到默认开关上。
166
169
 
167
170
  > 该预览由 `node test/live/ui-preview.mjs` 用真实样式表生成到 `%TEMP%/wb-ui-preview.html`,改版后可以直接截图对比。
168
171
 
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.0",
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/adapter.js CHANGED
@@ -24,6 +24,7 @@ import { readCatalogCache, writeCatalogCache } from './catalog-cache.js'
24
24
  import { readPrefs, isEnabled } from './model-prefs.js'
25
25
  import { applyOfficialList, readOfficialList } from './official-list.js'
26
26
  import { chatRoutes } from './regions.js'
27
+ import { notifyModelTopology } from './topology.js'
27
28
 
28
29
  const CATALOG_TTL_MS = 10 * 60_000
29
30
  const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:'
@@ -450,12 +451,15 @@ export class WorkBuddyAdapter {
450
451
  if (/11102|service info not found/i.test(text)) {
451
452
  writeAvailability(this.region.id, [{ id: model, state: 'dead', detail: '模型不存在(11102 service info not found)', at: Date.now() }])
452
453
  this.invalidateCatalog()
454
+ // The verdict feeds the default on/off state, so the picker has to re-read.
455
+ notifyModelTopology(this.ctx)
453
456
  return
454
457
  }
455
458
  if (/11158|is not allowed for user type/i.test(text)) {
456
459
  const type = /user type \[([^\]]{0,40})\]/i.exec(text)?.[1] ?? ''
457
460
  writeAvailability(this.region.id, [{ id: model, state: 'denied', detail: `账号无权限(11158${type.length > 0 ? `,用户类型 ${type}` : ''})`, at: Date.now() }])
458
461
  this.invalidateCatalog()
462
+ notifyModelTopology(this.ctx)
459
463
  }
460
464
  }
461
465
 
@@ -538,14 +542,16 @@ export class WorkBuddyAdapter {
538
542
  // when the user asks for it, which is how one gets tried anyway.
539
543
  const prefs = readPrefs(this.region.id)
540
544
  const dead = unusableModelIds(this.region.id)
541
- return catalog
542
- .filter((entry) => isEnabled(prefs, entry.id, dead.has(entry.id)))
543
- .map((entry) => ({
544
- provider,
545
- id: entry.id,
546
- name: entry.name,
547
- inputModalities: [...entry.input]
548
- }))
545
+ const offered = catalog.filter((entry) => isEnabled(prefs, entry.id, dead.has(entry.id)))
546
+ // Always logged: one line per picker open, and the fastest way to answer
547
+ // "did the picker re-read the list after that switch was flipped?".
548
+ this.ctx.logger.info(`[workbuddy] ${this.region.id}: 选择器读取目录 → ${offered.length}/${catalog.length} 个可选`)
549
+ return offered.map((entry) => ({
550
+ provider,
551
+ id: entry.id,
552
+ name: entry.name,
553
+ inputModalities: [...entry.input]
554
+ }))
549
555
  }
550
556
 
551
557
  /**
package/src/api.js CHANGED
@@ -21,7 +21,8 @@ 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
+ import { notifyModelTopology } from './topology.js'
25
26
 
26
27
  const API_PREFIX = '/workbuddy'
27
28
  const MAX_BODY_BYTES = 16 * 1024
@@ -32,6 +33,12 @@ const MASK_PATH = fileURLToPath(new URL('../icon-mask.png', import.meta.url))
32
33
  let iconBytes
33
34
  let maskBytes
34
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
+
35
42
  /**
36
43
  * Which build of this file the running host actually loaded.
37
44
  *
@@ -134,8 +141,14 @@ async function statePayload(ctx, adapters) {
134
141
  /**
135
142
  * Register the panel's routes. Call inside `ctx.inject(['webServer'], …)` so a
136
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.
137
149
  */
138
- export function registerWorkbuddyApi(ctx, adapters) {
150
+ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
151
+ const openInBrowser = options.openInBrowser ?? defaultOpenInBrowser
139
152
  const rejected = (req, res) => {
140
153
  const connection = ctx.get('connection')
141
154
  if (connection === undefined || typeof connection.requestRejection !== 'function') return false
@@ -219,7 +232,12 @@ export function registerWorkbuddyApi(ctx, adapters) {
219
232
  // The adapter's catalog is the merged one the model picker sees:
220
233
  // both live endpoints merged when they answer, otherwise the last
221
234
  // successful read from disk. `source` lets the panel say which.
222
- if (url.searchParams.get('refresh') === '1') adapter.invalidateCatalog()
235
+ if (url.searchParams.get('refresh') === '1') {
236
+ adapter.invalidateCatalog()
237
+ // A manual refresh can change what the picker may offer, so the
238
+ // client's cached directory has to be told to re-read.
239
+ notifyModelTopology(ctx)
240
+ }
223
241
  const models = await adapter.models()
224
242
  const ledger = readAvailability(region.id)
225
243
  const imported = readOfficialList(region.id)
@@ -292,6 +310,9 @@ export function registerWorkbuddyApi(ctx, adapters) {
292
310
  const models = adapter === undefined ? [] : await adapter.models()
293
311
  const dead = unusableModelIds(region.id)
294
312
  const prefs = readPrefs(region.id)
313
+ // The picker caches its catalog until it hears this, so without the
314
+ // notification a model switched off here stays selectable there.
315
+ notifyModelTopology(ctx)
295
316
  ctx.logger.info(`[workbuddy] ${region.id}: ${reset ? '恢复默认' : (enabled ? '启用' : '停用')} ${ids.length} 个模型(${ids.length <= 3 ? ids.join(', ') : `${ids.slice(0, 3).join(', ')}…`}),手工设定 开${result.on}/关${result.off}`)
296
317
  sendJson(res, 200, {
297
318
  region: region.id,
@@ -304,6 +325,32 @@ export function registerWorkbuddyApi(ctx, adapters) {
304
325
  return
305
326
  }
306
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
+
307
354
  if (path === `${API_PREFIX}/availability`) {
308
355
  const id = String(url.searchParams.get('region') ?? '').toLowerCase()
309
356
  const region = REGIONS[id]
@@ -440,6 +487,7 @@ export function registerWorkbuddyApi(ctx, adapters) {
440
487
  const result = writeOfficialList(region.id, body.models, { from: source })
441
488
  adapter?.invalidateCatalog()
442
489
  const models = adapter === undefined ? [] : await adapter.models()
490
+ notifyModelTopology(ctx)
443
491
  ctx.logger.info(`[workbuddy] ${region.id}: 账号名单${result.cleared === true ? '已清除' : `已导入 ${result.count} 个(其中 ${result.withMeta ?? 0} 个带元数据)`}(${source}),当前列表 ${models.length} 个`)
444
492
  sendJson(res, 200, {
445
493
  region: region.id,
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ import { claimDailyCheckin, runAutoClaim } from './credits.js'
23
23
  import { msUntilNextMidnight } from './http.js'
24
24
  import { readPrefs } from './model-prefs.js'
25
25
  import { REGIONS, REGION_IDS, regionList } from './regions.js'
26
+ import { notifyModelTopology } from './topology.js'
26
27
 
27
28
  export const name = 'dsh-workbuddy'
28
29
  export const inject = ['commands', 'credentials', 'llm']
@@ -257,6 +258,8 @@ export function apply(ctx) {
257
258
  for (const verdict of result.results) counts.set(verdict.state, (counts.get(verdict.state) ?? 0) + 1)
258
259
  ctx.logger.info(`[workbuddy] ${id}: 启动可用性检测完成 ${result.results.length} 个${region.freeProbe === true ? '(零消耗)' : '(每个模型一个 token)'} — ${[...counts.entries()].map(([state, count]) => `${state} ${count}`).join(' / ')}`)
259
260
  adapter.invalidateCatalog()
261
+ // Verdicts decide the default on/off state, so tell the picker to re-read.
262
+ notifyModelTopology(ctx)
260
263
  })().catch((error) => {
261
264
  ctx.logger.warn(`[workbuddy] ${id}: 启动可用性检测失败 — ${error instanceof Error ? error.message : String(error)}`)
262
265
  })
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 = {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The one place that tells the harness its model list changed.
3
+ *
4
+ * The Web client's model directory caches the catalog and only re-reads it when
5
+ * it hears a forwarded host event — `llm/adapters-updated` among them. Its
6
+ * declared contract says exactly what this needs: a payload-free notification
7
+ * after which "consumers re-read `listProviders()`, `listModels()`, or
8
+ * `listConfigurableProviders()` for the new state". Nothing else re-lists on a
9
+ * timer, so a switch the user just flipped, an imported account list, or a probe
10
+ * that retired a model stays invisible: the picker keeps offering models the panel
11
+ * has already switched off.
12
+ *
13
+ * `llm/adapters-updated` is on the app's forwarded-event allowlist and the
14
+ * forwarding layer subscribes with a plain `ctx.on(event, …)`, so emitting it
15
+ * reaches host listeners and every connected client. `ctx.emit` is preferred
16
+ * because the event name is a declared contract; the `llm` service's own
17
+ * `emitAdaptersUpdated()` (same dispatch, absent from the published contract) is
18
+ * the fallback.
19
+ *
20
+ * Everything is duck-typed, and the outcome is logged: when neither channel
21
+ * exists the panel still works, and the log says so instead of leaving a silent
22
+ * "the picker didn't refresh" mystery.
23
+ */
24
+
25
+ /**
26
+ * @param {object} ctx - host plugin context.
27
+ * @returns {boolean} whether a notification was delivered.
28
+ */
29
+ export function notifyModelTopology(ctx) {
30
+ let route = 'none'
31
+ let delivered = false
32
+ try {
33
+ if (typeof ctx?.emit === 'function') {
34
+ ctx.emit('llm/adapters-updated')
35
+ route = 'ctx.emit'
36
+ delivered = true
37
+ } else {
38
+ const llm = ctx?.llm ?? ctx?.get?.('llm')
39
+ if (llm !== undefined && llm !== null && typeof llm.emitAdaptersUpdated === 'function') {
40
+ llm.emitAdaptersUpdated()
41
+ route = 'llm.emitAdaptersUpdated'
42
+ delivered = true
43
+ }
44
+ }
45
+ } catch (error) {
46
+ ctx?.logger?.warn?.(`[workbuddy] 通知模型拓扑变化失败(不影响本次操作):${error instanceof Error ? error.message : String(error)}`)
47
+ return false
48
+ }
49
+
50
+ ctx?.logger?.info?.(
51
+ delivered
52
+ ? `[workbuddy] 已通知模型拓扑变化(${route})`
53
+ : '[workbuddy] 无法通知模型拓扑变化:当前上下文既没有 ctx.emit 也没有 llm.emitAdaptersUpdated,模型选择器要等它自己重读才会更新'
54
+ )
55
+ return delivered
56
+ }