@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 +12 -9
- 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/adapter.js +14 -8
- package/src/api.js +51 -3
- package/src/index.js +3 -0
- package/src/regions.js +24 -0
- package/src/topology.js +56 -0
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
|
-
|
|
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
|
-
|
|
42
|
+
界面长什么样、每块显示什么,见下面的 [设置面板](#设置面板) 一节(含截图)。
|
|
38
43
|
|
|
39
|
-
|
|
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
|
|
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/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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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')
|
|
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 = {
|
package/src/topology.js
ADDED
|
@@ -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
|
+
}
|