@huai_xin/dsh-workbuddy 0.1.1 → 0.1.3

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
 
@@ -158,7 +160,8 @@ dsh-workbuddy/
158
160
 
159
161
  - 按**能不能用**分两组:`可用模型` / `不可用模型`(探测判定 + 真实调用的自学习)。不可用分组默认折叠。
160
162
  - 每行:左侧圆点是台账颜色(绿=可用、黄=排队、红=不可用/无权限、灰=未检测),中间是模型名,右侧依次是**可用性标签 · 促销标签 · 繁忙程度 · 实时倍率**,最后是该模型的**启停开关**(停用的行会变淡)。
161
- - 点任意一行展开详情:描述、标识、上下文、最大输出、模态、思考档位、可关闭思考、倍率、繁忙程度、促销类型/时限/详情。所有标签共用一种样式,颜色只表示语义。
163
+ - 点任意一行展开详情:描述、标识、上下文、最大输出、模态、思考档位、倍率、繁忙程度、促销类型/时限/详情。所有标签共用一种样式,颜色只表示语义。
164
+ - **思考恒为开启**:上游只声明"可否关闭思考",却从不给出能兑现它的请求参数(实测见[协议文档 §4.3.1](docs/workbuddy-protocol.md)),所以详情里不放这一项 —— 能调的只有**档位**(低/中/高/超高/极致)。
162
165
  - 倍率取不到就显示「未知」(**不猜、不用旧值兜底**),`0` 显示「免费」标签。
163
166
  - 繁忙程度**只有大陆版有**(`POST /v2/chat/busy`;海外版 404,那一列整个不显示):空闲 / 繁忙 / 拥堵 / 未知四档,用词与官方前端一致(官方把 `normal` 与 `idle` 并成「空闲」),悬停给出官方的完整说法(轻微拥堵 / 极度拥堵)。
164
167
  - 工具栏:搜索框、「全部启用 / 全部停用」(**只作用于可用分组**,不可用分组自己手动调)、「⟳ 刷新列表」、「检测可用性」(手动,立即执行)。这两个按钮宽度固定,「检测中…」不会把 ⟳ 挤走。
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: '登录',
@@ -103,8 +106,6 @@ window.__ModuleLoader__.load({
103
106
  detailRate: '倍率',
104
107
  detailDescription: '描述',
105
108
  detailModality: '模态',
106
- detailCanDisableThinking: '可关闭思考',
107
- detailNo: '否',
108
109
  freeModel: '免费',
109
110
  promoDiscount: '折扣',
110
111
  detailPromoKind: '促销类型',
@@ -117,7 +118,6 @@ window.__ModuleLoader__.load({
117
118
  busyBusyOfficial: '轻微拥堵',
118
119
  busyCongestedOfficial: '极度拥堵',
119
120
  busyUnknown: '未知',
120
- detailYes: '是',
121
121
  modalityText: '文本',
122
122
  modalityImage: '图像',
123
123
  noModels: '登录后这里会显示该账号可用的模型'
@@ -135,6 +135,9 @@ window.__ModuleLoader__.load({
135
135
  colCredits: 'Credits',
136
136
  colActions: 'Actions',
137
137
  refreshCredits: 'Refresh credits',
138
+ openPlans: 'Plans',
139
+ openPlansHint: 'Open this deployment’s official plans page in your browser',
140
+ openPlansFailed: (url) => `Could not launch a browser — open this instead: ${url}`,
138
141
  creditsRate: 'Consumption rate (live)',
139
142
  models: 'Models',
140
143
  login: 'Sign in',
@@ -187,8 +190,6 @@ window.__ModuleLoader__.load({
187
190
  detailRate: 'Rate',
188
191
  detailDescription: 'Description',
189
192
  detailModality: 'Modality',
190
- detailCanDisableThinking: 'Thinking switch',
191
- detailNo: 'no',
192
193
  freeModel: 'free',
193
194
  promoDiscount: 'discount',
194
195
  detailPromoKind: 'Promotion',
@@ -201,7 +202,6 @@ window.__ModuleLoader__.load({
201
202
  busyBusyOfficial: 'slightly congested',
202
203
  busyCongestedOfficial: 'extremely congested',
203
204
  busyUnknown: 'unknown',
204
- detailYes: 'yes',
205
205
  modalityText: 'text',
206
206
  modalityImage: 'image',
207
207
  noModels: 'Models appear here once the account is signed in'
@@ -236,6 +236,11 @@ window.__ModuleLoader__.load({
236
236
  .wb-card + .wb-card { margin-top: 16px; }
237
237
  .wb-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 14px 16px 12px; }
238
238
  .wb-card-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--wb-muted); }
239
+ .wb-account-card { border-radius: 12px; border-color: var(--wb-line-soft); }
240
+ .wb-account-card .wb-card-head { gap: 10px; }
241
+ .wb-account-card .wb-card-title { letter-spacing: 0; text-transform: none; color: var(--wb-text); }
242
+ .wb-account-card .wb-btn { border-radius: 6px; }
243
+ .wb-account-card .wb-card-head .wb-btn { min-width: 52px; }
239
244
  .wb-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px 12px; }
240
245
  /* The mark is a mask painted with the accent colour: one asset, both themes. */
241
246
  .wb-logo { flex: none; width: 30px; height: 30px; display: block; background: var(--wb-accent-strong);
@@ -245,47 +250,51 @@ window.__ModuleLoader__.load({
245
250
  /* account table — transposed: deployments are columns, fields are rows ------
246
251
  One grid for the whole table so a signed-out deployment can occupy its whole
247
252
  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); }
253
+ .wb-table { display: grid; position: relative; padding: 0 10px 8px; overflow-x: auto; --wb-cols: 2;
254
+ /* No trailing spacer: both accounts need enough width for an unbroken action
255
+ pair in the settings column. Narrower views scroll instead of wrapping. */
256
+ grid-template-columns: 56px repeat(var(--wb-cols), minmax(180px, 1fr));
257
+ /* Fixed tracks prevent account loading or login from resizing the card; the
258
+ action row needs less air than the four information rows above it. */
259
+ grid-template-rows: repeat(4, 52px) 34px;
260
+ column-gap: 32px; row-gap: 0; align-items: stretch; }
261
+ /* Centre the divider on the card, not the account-column gap: the label
262
+ column makes that gap visually off-centre. Account contents stay in place. */
263
+ .wb-table-split::before { content: ''; position: absolute; left: 50%; top: 18px; bottom: 8px; width: 1px;
264
+ transform: translateX(-50%); pointer-events: none;
265
+ background: linear-gradient(to bottom, transparent, var(--wb-line-soft) 12%, var(--wb-line-soft) 92%, transparent); }
267
266
  .wb-row-label { display: flex; align-items: center; font-size: 11px; letter-spacing: .05em;
268
267
  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; }
268
+ .wb-th { min-width: 0; display: flex; align-items: center; font-size: 12.5px; font-weight: 620; white-space: nowrap; }
269
+ .wb-th .wb-btn { padding: 3px 8px; font-size: 11px; font-weight: 500; line-height: 1.2; }
271
270
  .wb-deploy { display: flex; align-items: center; gap: 8px; }
272
271
  .wb-deploy-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; }
273
272
  /* The cell fills its row and holds its content left, the way the signed-in rows
274
273
  read; only the standalone sign-in cell is centred. No rule between rows: the
275
274
  spacing and the vertical separator already say where the columns are. */
276
275
  .wb-td { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
276
+ /* Keep refresh and plans beside the balance, not at the far column edge. */
277
+ .wb-cell-actions { flex: none; display: inline-flex; align-items: center; gap: 8px; }
278
+ .wb-td-credits, .wb-td-last { flex-wrap: nowrap; }
279
+ .wb-td-last { gap: 6px; }
277
280
  .wb-td-span { display: flex; align-items: center; justify-content: center; }
278
281
  .wb-account-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
279
282
  .wb-num { font-variant-numeric: tabular-nums; color: var(--wb-muted); }
280
283
  .wb-credits-value { font-size: 16.5px; font-weight: 650; color: var(--wb-accent-strong);
281
284
  font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
282
285
  /* 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; }
286
+ .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
287
  .wb-icon-btn:hover:not(:disabled) { background: var(--wb-accent-soft); color: var(--wb-accent-strong); }
286
288
  .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; }
289
+ /* The link out to the official plans page: same quiet weight as the icon
290
+ actions beside it, but it needs words to say where it goes — kept tight so the
291
+ balance row still fits one line in a narrow panel. */
292
+ .wb-link-btn { font: inherit; font-size: 11px; line-height: 1; padding: 4px 7px; border-radius: 6px;
293
+ cursor: pointer; border: 1px solid var(--wb-line-soft); background: transparent; color: var(--wb-muted);
294
+ white-space: nowrap; transition: background .15s, color .15s, border-color .15s; }
295
+ .wb-link-btn:hover { background: var(--wb-accent-soft); color: var(--wb-accent-strong); border-color: transparent; }
296
+ .wb-td .wb-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box;
297
+ padding: 4px 10px; font-size: 11.5px; min-width: 78px; min-height: 26px; white-space: nowrap; }
289
298
  .wb-td-span .wb-btn { padding: 7px 16px; }
290
299
 
291
300
  /* tab strip: two equal halves, the selected one a tinted segment ------------- */
@@ -851,6 +860,25 @@ window.__ModuleLoader__.load({
851
860
  }
852
861
  }
853
862
 
863
+ /**
864
+ * Open the deployment's official plans page in the user's browser.
865
+ *
866
+ * The host resolves the URL from its own allowlist, so the panel only ever
867
+ * names a page. When launching fails the reply still carries the URL and
868
+ * the panel shows it, rather than pretending nothing happened.
869
+ */
870
+ const openPlans = async (regionId) => {
871
+ setError('')
872
+ try {
873
+ const result = await request('/open', { method: 'POST', body: JSON.stringify({ region: regionId, page: 'plans' }) })
874
+ if (result.opened !== true && typeof result.url === 'string') {
875
+ setError(t('openPlansFailed')(result.url))
876
+ }
877
+ } catch (cause) {
878
+ setError(cause instanceof Error ? cause.message : String(cause))
879
+ }
880
+ }
881
+
854
882
  const credits = region?.credits
855
883
  const checkin = region?.checkin
856
884
  const models = region === undefined ? undefined : modelMap[region.id]
@@ -970,9 +998,6 @@ window.__ModuleLoader__.load({
970
998
  add(t('detailEfforts'), model.efforts.length === 0
971
999
  ? undefined
972
1000
  : h('span', { className: 'wb-tags' }, model.efforts.map((level) => detailTag(level))))
973
- add(t('detailCanDisableThinking'), typeof model.canDisableThinking === 'boolean'
974
- ? (model.canDisableThinking === true ? t('detailYes') : t('detailNo'))
975
- : undefined)
976
1001
  add(t('detailRate'), model.credits === undefined
977
1002
  ? t('unknown')
978
1003
  : (isFreeModel(model) ? detailTag(t('freeModel'), ' wb-tag-accent') : (formatRate(model.credits) ?? model.credits)))
@@ -1161,16 +1186,29 @@ window.__ModuleLoader__.load({
1161
1186
  entry.credits !== null && entry.credits !== undefined && entry.credits.ok === true
1162
1187
  ? h('span', { key: 'value', className: 'wb-credits-value' }, formatCredits(entry.credits.total))
1163
1188
  : 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
- ]))
1189
+ // All values share a left edge; the quiet balance actions follow
1190
+ // the figure directly so each deployment reads as one compact group.
1191
+ h('span', { key: 'actions', className: 'wb-cell-actions' }, [
1192
+ h('button', {
1193
+ key: 'refresh',
1194
+ type: 'button',
1195
+ className: 'wb-icon-btn',
1196
+ title: t('refreshCredits'),
1197
+ 'aria-label': t('refreshCredits'),
1198
+ disabled: creditBusy === entry.id,
1199
+ onClick: () => void refreshCredits(entry.id)
1200
+ }, creditBusy === entry.id ? '…' : '⟳'),
1201
+ // The official plans page needs the deployment's own signed-in
1202
+ // session, so it opens in the user's browser rather than in here.
1203
+ h('button', {
1204
+ key: 'plans',
1205
+ type: 'button',
1206
+ className: 'wb-link-btn',
1207
+ title: t('openPlansHint'),
1208
+ onClick: () => void openPlans(entry.id)
1209
+ }, t('openPlans'))
1210
+ ])
1211
+ ], 'wb-td-credits'))
1174
1212
  cells.push(cell(3, 'actions', accountActions(entry)))
1175
1213
  })
1176
1214
  return cells
@@ -1256,7 +1294,7 @@ window.__ModuleLoader__.load({
1256
1294
  error.length > 0 ? h('p', { className: 'wb-error', role: 'alert' }, error) : null,
1257
1295
 
1258
1296
  // Cards are separate concerns: who is signed in, and what they can run.
1259
- h('div', { className: 'wb-card' },
1297
+ h('div', { className: 'wb-card wb-account-card' },
1260
1298
  h('div', { className: 'wb-card-head' },
1261
1299
  h(Logomark, null),
1262
1300
  h('span', { className: 'wb-card-title' }, t('cardAccounts')),
@@ -1274,8 +1312,8 @@ window.__ModuleLoader__.load({
1274
1312
  )
1275
1313
  ),
1276
1314
  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.
1315
+ // A single separator stays at the card's centre when there are
1316
+ // exactly two deployments.
1279
1317
  className: regions.length === 2 ? 'wb-table wb-table-split' : 'wb-table',
1280
1318
  role: 'table',
1281
1319
  style: { '--wb-cols': String(Math.max(1, regions.length)) }
Binary file
Binary file
@@ -176,9 +176,56 @@ POST <chatEndpoint>/v2/chat/completions
176
176
  | `tools` | 否 | OpenAI function 形态,70 个工具 / 60 KB 实测通过 ✅ |
177
177
  | `prompt_cache_key` | 否 | 服务端前缀缓存键;缺失时缓存命中恒为 0 📖 |
178
178
  | `thinking` | 否 | `{"type":"enabled"}`,deepseek 系使用 📖✅ |
179
- | `reasoning_effort` | 否 | `low` / `medium` / `high` / `xhigh` / `max`,取值须在该模型支持列表内 📖 |
179
+ | `reasoning_effort` | 否 | `low` / `medium` / `high` / `xhigh` / `max`,取值须在该模型支持列表内 ✅ 网关会校验(见下) |
180
180
  | `temperature` / `stop` | 否 | 直通 ✅ |
181
181
 
182
+ **`reasoning_effort` 是真实生效的参数**(2026-10-22 实测,海外版 `deepseek-v4.1-flash`,免费模型,
183
+ 脚本 `test/live/effort-effect.mjs`,`ROUNDS` 可重复):
184
+
185
+ 1. **网关会校验它**:传一个目录里没有的值(`totally-bogus`)→ HTTP 400
186
+ `{"code":11133,"msg":"the request parameters were rejected by the model provider",
187
+ "extError":{"code":"invalid_parameter_value"}}`——说明字段确实被转发给模型服务商,不是被忽略 ✅
188
+ 2. **在一句话就能答的题上,三档看不出区别**:鸡兔同笼题 12 次采样,
189
+ `low` 241 字 / `high` 232 字 / `max` 214 字(单档内波动 44–81 字)——低档也会思考 200+ 字,
190
+ 所以差距被噪声淹没。
191
+ 3. **在需要多步推理的题上,档位单调分档**(三位数推理题,各 3 次):
192
+
193
+ | 档位 | 思考字数(均值) | 范围 | completion tokens(均值) |
194
+ | --- | --- | --- | --- |
195
+ | `low` | 982 | 674–1512 | 615 |
196
+ | `high` | 3605 | 1463–5759 | 1324 |
197
+ | `max` | 6115 | 5460–6461 | 2048(撞到 `max_tokens` 上限) |
198
+
199
+ → 档位有意义,但**题越简单越看不出来**;`max` 在难题上会一路顶到 `max_tokens`。
200
+
201
+ 4. **用量里有 `completion_tokens_details.reasoning_tokens`**,与思考文字数一致;免费模型返回
202
+ `credit: 0` ✅(这也是"免费"标签的判据之一)。
203
+
204
+ **不同厂商的请求结构由厂商自己校验,网关只做转发**(2026-10-22 实测,`test/live/request-structure.mjs`
205
+ 逐字段试探;免费模型优先,海外版免费档有 `deepseek-v4.1-flash` / `hy3` / `step-5-preview`):
206
+
207
+ | 变体 | deepseek-v4.1-flash | step-5-preview(阶跃) | hy3(混元) | glm-5.3-flash(智谱) |
208
+ | --- | --- | --- | --- | --- |
209
+ | 基线(不带档位) | 200,思考 **0 字** | 200,思考 596 字 | 200,思考 370 字 | 200,思考 **0 字** |
210
+ | `+ reasoning_effort:"<合法档>"` | 200,思考 **261 字** | 200,447 字 | 200,502 字 | 200,思考 **337 字** |
211
+ | `+ reasoning_effort:"totally-bogus"` | **400 `11133` 拒绝** | 200 静默忽略 | 200 静默忽略 | 200 静默忽略 |
212
+ | `+ thinking:{type:"enabled"}` | 200,仍 0 字 | 200 | 200 | 200,35 字 |
213
+ | `+ thinking:{type:"disabled"}` | 200 | 200,仍思考 379 字 | 200 | 200,0 字 |
214
+ | `+ thinking:{type:"nonsense"}` | 200 | 200 | 200 | 200 |
215
+ | `+ max_completion_tokens`(去掉 `max_tokens`) | 200 | — | 200 | 200 |
216
+ | `+ 未知字段` | 200 | 200 | 200 | 200 |
217
+
218
+ 结论:
219
+
220
+ 1. **只有 DeepSeek 系会校验 `reasoning_effort`**(非法值 400 `11133`);其余厂商静默忽略非法值。
221
+ 说明网关**不做统一校验/改写**,请求体基本按原样转给各厂商自己的 API ✅
222
+ 2. **`thinking` 字段实际上不是开关**:`enabled` / `disabled` / 乱填一律 200,思考量也没跟着变
223
+ (glm 那次 35 字的差异在噪声范围内)。所以插件只在官方客户端确实会发的 deepseek 系带这个字段。
224
+ 3. **真正决定"思不思考"的是有没有 `reasoning_effort`**:deepseek 与 glm **不带该字段 → 思考 0 字**
225
+ (完全不思考);hy3 / step 属于强制思考型(`onlyReasoning`),不带也会思考。
226
+ → 因此适配器对**任何声明了档位的模型**都必须带上档位(选中的,否则目录里的 `defaultEffort`,
227
+ 再次是 `high`),这正是"思考恒为开启"的落地方式;没声明档位的模型才不带 ✅
228
+
182
229
  **历史消息里的 `reasoning_content` 会原样回放**,服务端接受 ✅(不做回放也能跑通)。
183
230
 
184
231
  ### 3.2 响应(SSE)
@@ -193,6 +240,14 @@ data: [DONE]
193
240
  - `delta` 实测字段:`role` / `content` / `reasoning_content` / `function_call` / `refusal` / `tool_calls` / `extra_fields` ✅
194
241
  - 流式工具调用:**仅首个分片带真实 `id`**,后续分片只有 `index`,需按 index 归并 📖✅
195
242
  - 末帧 `finish_reason`:`stop` / `tool_calls` / `length` ✅
243
+ - **流一定以 `data: [DONE]` 收尾**;没有它、也没有 `finish_reason` 就断流 = 连接被中途掐断
244
+ (不是模型"决定停下")📖。插件据此判定:这种情况必须上报 `TRANSPORT` 错误交给重试策略,
245
+ **绝不能**当成正常 `stop` —— 曾经因此出现"看着还没说完就结束了,也不重试"的现象 ✅(已修)。
246
+ 判定与补救都有日志:断流时打一行带证据的告警(收到多少字节、用时多久、中断时在输出哪一类内容),
247
+ 重试时再由插件订阅 `session/event` 的 `llm/retry` 打一行"第 N/M 次重试(TRANSPORT)… 后重试"。
248
+ 频率实测工具:`node test/live/stream-cut-probe.mjs [cn|intl] [次数] [模型] [--long] [--selftest]`
249
+ (2026-10-22 实测:大陆版短问答 4/4 正常、海外版 `deepseek-v4.1-flash` 长回答 10/10 正常,
250
+ 即断流是间歇性的,常规探测不易复现)
196
251
  - `usage`(末帧携带):`prompt_tokens` / `completion_tokens` / `total_tokens` / `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens` / `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` / `credit` ✅
197
252
 
198
253
  ---
@@ -253,7 +308,7 @@ data: [DONE]
253
308
  | `contextWindow` | 仅部分模型有:`{defaultLength, supportedLengths:[…]}`(如 step-5: `[300000, 1000000]`) |
254
309
  | `maxOutputTokens` | 输出上限 |
255
310
  | `supportsImages` / `supportsReasoning` / `supportsToolCall` / `disabledMultimodal` | 能力位 |
256
- | `onlyReasoning` / `canDisableThinking` | 是否强制思考 / 可否关闭 |
311
+ | `onlyReasoning` / `canDisableThinking` | 是否强制思考 / 上游的"可否关闭思考"声明 —— **插件忽略它**,理由见 §4.3.1 |
257
312
  | `reasoning` | `{effort, summary, defaultEffort, supportedEfforts:[…]}`;`effort` 是默认档,`supportedEfforts` 才是可选集合 |
258
313
  | `relatedModels` | 关联模型映射,如 `{lite, reasoning, builtin-lite}` |
259
314
  | `credits` | 倍率字符串,如 `"x0.00 credits"` / `"x2.20 credits"` / `"x0.05"` |
@@ -263,8 +318,32 @@ data: [DONE]
263
318
  | `iconUrl` / `iconUrl_en` | 图标(base64 data-URI 或 HTTPS URL,按语言选) |
264
319
  | `AccountAttributes` ❓ | 出现在积分包里,模型侧未见 |
265
320
 
266
- ### 4.3 `/v3/config` 结构
321
+ #### 4.3.1 `canDisableThinking`:上游没给关闭参数,插件选择"忽略它、思考恒开"(2026-10-22 实测)
322
+
323
+ 三条结论都有原始响应与缓存对比支撑(`node test/live/reasoning-audit.mjs [cn|intl]`):
324
+
325
+ 1. **上游只发一个布尔位,不发参数名。** `reasoning` 块出现过的字段只有
326
+ `canDisableThinking` / `defaultEffort` / `effort` / `summary` / `supportedEfforts`;
327
+ 整条记录里像"开关"的字段只有 `canDisableThinking` 和 `disabledMultimodal`(后者是
328
+ 多模态能力,与思考无关)。也就是说:**"能不能关"它说了,"怎么关"它没说。**
329
+ 反向参数(`thinking:{type:'disabled'}` / `enable_thinking:false` /
330
+ `reasoning:{effort:'none'}` 之类)**一个都没验证过**。
331
+ 2. **两个部署各自独立声明,而且会变。** 同一天里:大陆版对 `glm-5.2`、`kimi-k3-1`、
332
+ `deepseek-v4-flash`、`deepseek-v4-pro`、`hy3` **完全不发**该字段,海外版对同样的 id
333
+ 一律发 `true`/`false`;大陆版过去发过、现在不发了 —— 说明它不是稳定契约。
334
+ 3. **插件的决定:忽略这个字段,思考恒开。** 既然"能关"无法兑现(没有参数),就不要在
335
+ 面板上宣称它 —— 于是:
336
+ - 目录解析不再读取该字段(`catalog.js`),合并、面板载荷、账号名单富化里也一并去掉;
337
+ - 面板详情不再有「可关闭思考」这一行(没有开关就不摆开关);
338
+ - **唯一能调的仍然是档位**(`reasoning.supportedEfforts` → 低/中/高/超高/极致);
339
+ - `thinking:{type:'enabled'}` 目前只对 deepseek 系发送(§4.5 表,📖✅ 已验证),
340
+ 其它族不发这个字段(它们本身就是 `onlyReasoning` 模型,默认就在思考);
341
+ - 缓存里把该字段当作**已废弃字段**处理:老快照读出来就丢掉,不再留痕。
342
+
343
+ 如果将来上游给出真正的关闭参数,正确做法是把它变成一个档位(例如「关闭」)交给
344
+ 选择器,而不是恢复一个布尔位。
267
345
 
346
+ ### 4.3 `/v3/config` 结构
268
347
  ```json
269
348
  {"code":0,"data":{
270
349
  "$schema":"product-schema.json",
@@ -320,7 +399,7 @@ Web 端 picker 的悬停卡片会显示简介、消耗速度、繁忙程度、
320
399
  | 名称(含「极致」「快速」等预设名) | 目录 | `name` ✅ |
321
400
  | 简介「优先深度与准确性…」 | 目录 | `descriptionZh` / `descriptionEn`,前端按 locale 选(`zh*` → `descriptionZh`)✅ |
322
401
  | 消耗速度 `1.20x 倍率` | 目录 | `credits`,如 `"x1.20 credits"`;前端只取数字再加 `x`(`/(\d+(?:\.\d+)?)/` → `1.20x`)✅ |
323
- | 思考档位(低/中/高/超高/极致) | 目录 | `reasoning.supportedEfforts`(可选集合)、`reasoning.defaultEffort`、`onlyReasoning`、`canDisableThinking`、`supportsReasoning`;**档位文案是前端 i18n**(`model.effort.{low,medium,high,xhigh,max}.label` → 低/中/高/超高/极致),不是服务端下发 ✅ |
402
+ | 思考档位(低/中/高/超高/极致) | 目录 | `reasoning.supportedEfforts`(可选集合)、`reasoning.defaultEffort`、`onlyReasoning`、`supportsReasoning`;**档位文案是前端 i18n**(`model.effort.{low,medium,high,xhigh,max}.label` → 低/中/高/超高/极致),不是服务端下发 ✅ |
324
403
  | 上下文窗口选择 | 目录 | `contextWindow.supportedLengths` + `maxInputTokens`(前端命名 `supportedContextWindows` / `defaultContextWindow`)✅ |
325
404
  | 徽章(如「限时免费」) | 目录 | `tags`,形如 `badge:限时免费:#FF0000`;另有 `isDefault`、`badges` / `tierBadges`(前端派生)✅ |
326
405
  | **繁忙程度**(空闲 / 轻微拥堵 / 极度拥堵 / 不可用) | **`POST /v2/chat/busy`** | 见下 ✅ |
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.3",
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
@@ -656,6 +656,12 @@ export class WorkBuddyAdapter {
656
656
  const maxTokens = positiveInt(options.maxTokens) ?? entry?.maxTokens
657
657
  if (maxTokens !== undefined) body.max_tokens = maxTokens
658
658
 
659
+ // Thinking is pinned on: there is no switch anywhere in the plugin. Upstream
660
+ // publishes `reasoning.canDisableThinking` without naming a request parameter
661
+ // that would act on it (protocol §4.3.1), so the effort level is the only
662
+ // reasoning control, and the explicit `thinking` field is sent where the
663
+ // official client is known to send it (deepseek family, §4.5) — the other
664
+ // families ignore it, whatever the value.
659
665
  const isDeepSeek = model.startsWith('deepseek-')
660
666
  if (isDeepSeek) body.thinking = { type: 'enabled' }
661
667
  // `efforts` is optional: many catalog entries declare no level set, and
@@ -773,6 +779,16 @@ export class WorkBuddyAdapter {
773
779
  let streamEnded = false
774
780
  const reader = response.body.getReader()
775
781
  const decoder = new TextDecoder()
782
+ // Evidence for the "the stream was cut" report below: how much arrived, how
783
+ // long it took, how long the silence before the cut was, and what was being
784
+ // written when it happened. The remote's own reason is not observable, so the
785
+ // report has to carry what *is*.
786
+ const startedAt = Date.now()
787
+ let receivedBytes = 0
788
+ let lastDataAt = startedAt
789
+ let lastDelta = '无内容'
790
+ /** Output tokens the last usage frame reported, for the truncation log. */
791
+ let outputTokens
776
792
 
777
793
  const textBlock = () => {
778
794
  let block = blocks.find((candidate) => candidate.kind === 'text')
@@ -795,6 +811,8 @@ export class WorkBuddyAdapter {
795
811
  while (!streamEnded) {
796
812
  const result = await reader.read()
797
813
  if (result.done) break
814
+ receivedBytes += result.value?.byteLength ?? 0
815
+ lastDataAt = Date.now()
798
816
  buffer += decoder.decode(result.value, { stream: true })
799
817
  let newline
800
818
  while ((newline = buffer.indexOf('\n')) !== -1) {
@@ -826,12 +844,14 @@ export class WorkBuddyAdapter {
826
844
  const block = textBlock()
827
845
  if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'text' }
828
846
  block.text += delta.content
847
+ lastDelta = '正文'
829
848
  yield { type: 'text-delta', index: block.index, text: delta.content }
830
849
  }
831
850
  if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
832
851
  const block = reasoningBlock()
833
852
  if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'reasoning' }
834
853
  block.text += delta.reasoning_content
854
+ lastDelta = '思考'
835
855
  yield { type: 'reasoning-delta', index: block.index, text: delta.reasoning_content }
836
856
  }
837
857
  for (const call of delta?.tool_calls ?? []) {
@@ -845,6 +865,7 @@ export class WorkBuddyAdapter {
845
865
  }
846
866
  block.callId = callId
847
867
  if (typeof call.function?.name === 'string' && call.function.name.length > 0) block.name = call.function.name
868
+ lastDelta = '工具调用'
848
869
  const fragment = call.function?.arguments ?? ''
849
870
  block.text += fragment
850
871
  if (!block.announced) {
@@ -867,6 +888,7 @@ export class WorkBuddyAdapter {
867
888
  const promptTokens = data.usage.prompt_tokens ?? 0
868
889
  const cachedTokens = data.usage.prompt_tokens_details?.cached_tokens ?? data.usage.prompt_cache_hit_tokens ?? 0
869
890
  const reasoningTokens = data.usage.completion_tokens_details?.reasoning_tokens
891
+ outputTokens = data.usage.completion_tokens ?? 0
870
892
  yield {
871
893
  type: 'usage',
872
894
  usage: {
@@ -887,6 +909,11 @@ export class WorkBuddyAdapter {
887
909
  for (const index of toolOrder) {
888
910
  const block = [...toolCalls.values()].find((candidate) => candidate.index === index)
889
911
  if (block === undefined || !hasUsableToolName(block.name)) continue
912
+ // A call whose arguments never finished arriving cannot be executed: handed
913
+ // over, it becomes a parse error in the transcript instead of the truncation
914
+ // that actually happened. It is reported as `max-tokens` below instead, so
915
+ // the step can be continued rather than failed.
916
+ if (isTruncatedArguments(block.text)) continue
890
917
  blockCount += 1
891
918
  yield {
892
919
  type: 'block-end',
@@ -895,7 +922,7 @@ export class WorkBuddyAdapter {
895
922
  type: 'tool-call',
896
923
  id: block.callId,
897
924
  name: block.name,
898
- arguments: isTruncatedArguments(block.text) ? block.text : normalizeToolArguments(block.text)
925
+ arguments: normalizeToolArguments(block.text)
899
926
  }
900
927
  }
901
928
  }
@@ -911,15 +938,67 @@ export class WorkBuddyAdapter {
911
938
  }
912
939
 
913
940
  const argsTruncated = [...toolCalls.values()].some((block) => isTruncatedArguments(block.text))
941
+ // A stream that stops without the `[DONE]` sentinel was cut, not finished:
942
+ // upstream closed the connection mid-answer. Reporting that as a normal stop
943
+ // ends the turn silently — the model looks like it simply chose to stop — and
944
+ // the retry policy never sees a failure. It has to be an error so a truncated
945
+ // answer is retried and, failing that, surfaced.
946
+ if (!streamEnded && finishReason === undefined) {
947
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
948
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应在 finish_reason/[DONE] 之前中断(${detail}),按传输错误上报,可自动重试`)
949
+ yield {
950
+ type: 'finish',
951
+ reason: {
952
+ kind: 'error',
953
+ failure: {
954
+ message: `${this.region.provider}: 上游没有给出结束原因就断开了响应(${detail};可重试的传输错误)`,
955
+ code: 'TRANSPORT'
956
+ }
957
+ }
958
+ }
959
+ return
960
+ }
961
+ if (argsTruncated && !streamEnded) {
962
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
963
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的工具调用参数在传输中被截断(${detail}),按传输错误上报,可自动重试`)
964
+ yield {
965
+ type: 'finish',
966
+ reason: {
967
+ kind: 'error',
968
+ failure: {
969
+ message: `${this.region.provider}: 工具调用的参数在传输中被截断(${detail};可重试的传输错误)`,
970
+ code: 'TRANSPORT'
971
+ }
972
+ }
973
+ }
974
+ return
975
+ }
976
+ // Truncated arguments are checked before the empty-response rule: a step whose
977
+ // only tool call was cut short has no usable block to hand over, and calling
978
+ // that "empty" would hide what happened.
979
+ if (argsTruncated) {
980
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的工具调用参数被输出上限截断,本步按 max-tokens 上报(harness 可续跑)`)
981
+ yield { type: 'finish', reason: { kind: 'max-tokens' } }
982
+ return
983
+ }
914
984
  if (blockCount === 0) {
915
985
  yield { type: 'finish', reason: { kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } } }
916
986
  return
917
987
  }
918
988
  const reason = finishReason === 'tool_calls' || toolOrder.length > 0
919
989
  ? { kind: 'tool-calls' }
920
- : finishReason === 'length' || argsTruncated
990
+ : finishReason === 'length'
921
991
  ? { kind: 'max-tokens' }
922
992
  : { kind: 'stop' }
993
+ // Two endings are complete-looking but are not the model choosing to stop, and
994
+ // nothing downstream can tell them apart. Both get a line of their own:
995
+ // · the answer ran into the output budget (a truncation reads as a finish);
996
+ // · upstream closed with `[DONE]` but never said *why* the answer ended.
997
+ if (reason.kind === 'max-tokens') {
998
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的输出达到上限,本步被截断(输出 ${outputTokens ?? '未知'} tokens)`)
999
+ } else if (reason.kind === 'stop' && finishReason === undefined) {
1000
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应以 [DONE] 收尾但没有 finish_reason,按 stop 处理(输出 ${outputTokens ?? '未知'} tokens)`)
1001
+ }
923
1002
  yield { type: 'finish', reason }
924
1003
  }
925
1004
  }
@@ -927,3 +1006,21 @@ export class WorkBuddyAdapter {
927
1006
  function positiveInt(value) {
928
1007
  return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
929
1008
  }
1009
+
1010
+ /**
1011
+ * Describe a stream that ended without its terminator.
1012
+ *
1013
+ * The rule is deliberately mechanical: a response is complete only when it carries
1014
+ * `finish_reason` (and the SSE stream reaches `[DONE]`) or delivers a tool call.
1015
+ * Anything else is a cut connection, reported as a retryable `TRANSPORT` failure.
1016
+ *
1017
+ * Why the *remote* closed the connection is not observable from here, so the report
1018
+ * carries what is: how much arrived, how long it took, how long the silence before
1019
+ * the cut, and what was being written at the time.
1020
+ */
1021
+ function describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta }) {
1022
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
1023
+ const silence = ((Date.now() - lastDataAt) / 1000).toFixed(1)
1024
+ const size = receivedBytes < 1024 ? `${receivedBytes} B` : `${(receivedBytes / 1024).toFixed(1)} kB`
1025
+ return `收到 ${size},用时 ${elapsed}s,最后一段数据在 ${silence}s 前,中断时正在输出${lastDelta}`
1026
+ }
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
@@ -249,7 +261,6 @@ export function registerWorkbuddyApi(ctx, adapters) {
249
261
  credits: entry.credits,
250
262
  efforts: entry.efforts === undefined ? [] : Object.keys(entry.efforts),
251
263
  defaultEffort: entry.defaultEffort,
252
- canDisableThinking: entry.canDisableThinking,
253
264
  thinking: entry.thinking === true,
254
265
  contextWindow: entry.contextWindow,
255
266
  maxTokens: entry.maxTokens,
@@ -313,6 +324,32 @@ export function registerWorkbuddyApi(ctx, adapters) {
313
324
  return
314
325
  }
315
326
 
327
+ /**
328
+ * Open one of the deployment's own site pages in the user's browser.
329
+ *
330
+ * The panel sends a page *name*; the URL comes from `SITE_PAGES`, so this
331
+ * route can never be talked into opening an arbitrary address. The reply
332
+ * carries the URL even when launching failed, so the panel can show it.
333
+ */
334
+ if (path === `${API_PREFIX}/open` && method === 'POST') {
335
+ const body = await readJsonBody(req)
336
+ const region = REGIONS[String(body?.region ?? '').toLowerCase()]
337
+ const url = sitePageUrl(region, body?.page)
338
+ if (url === undefined) {
339
+ sendJson(res, 400, { error: 'unknown region or page' })
340
+ return
341
+ }
342
+ let opened = false
343
+ try {
344
+ opened = (await openInBrowser(url)) !== false
345
+ } catch (error) {
346
+ ctx.logger.warn(`[workbuddy] ${region.id}: 打开 ${url} 失败 — ${error instanceof Error ? error.message : String(error)}`)
347
+ }
348
+ ctx.logger.info(`[workbuddy] ${region.id}: 打开官方页面 ${url}(${opened ? '已交给浏览器' : '启动失败'})`)
349
+ sendJson(res, 200, { region: region.id, url, opened })
350
+ return
351
+ }
352
+
316
353
  if (path === `${API_PREFIX}/availability`) {
317
354
  const id = String(url.searchParams.get('region') ?? '').toLowerCase()
318
355
  const region = REGIONS[id]
@@ -42,8 +42,18 @@ const MODALITY_ORDER = ['text', 'image']
42
42
  * ago is not a fallback — it is wrong. The cache stores no copy, and a cached
43
43
  * entry therefore reports no rate at all; the panel shows 未知 rather than a
44
44
  * stale number.
45
+ *
46
+ * `promotion` is a live *statement* rather than a measurement, and the cache is
47
+ * append-only (a field another read omits is never cleared), so keeping it would
48
+ * let an old claim outlive the payload that made it: the deployment's
49
+ * `modelPromotions` block moves with each campaign, and a cached badge can
50
+ * advertise a promotion that ended.
51
+ *
52
+ * `canDisableThinking` is retired — the plugin pins thinking on and never reads
53
+ * the flag (protocol §4.3.1). It stays in this set only so that snapshots written
54
+ * before the retirement drop it on read instead of leaving a dead field behind.
45
55
  */
46
- const VOLATILE_FIELDS = new Set(['credits'])
56
+ const VOLATILE_FIELDS = new Set(['credits', 'promotion', 'canDisableThinking'])
47
57
 
48
58
  function cacheDir() {
49
59
  const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0
package/src/catalog.js CHANGED
@@ -199,7 +199,11 @@ export function entryFromMeta(id, meta) {
199
199
  input: meta?.supportsImages === true ? ['text', 'image'] : ['text'],
200
200
  ...(levels.length === 0 ? {} : { efforts: Object.fromEntries(levels.map((level) => [level, level])) }),
201
201
  ...(declared === undefined ? {} : { defaultEffort: declared }),
202
- ...(reasoning?.canDisableThinking === undefined ? {} : { canDisableThinking: reasoning.canDisableThinking === true }),
202
+ // `reasoning.canDisableThinking` is deliberately ignored. Upstream publishes
203
+ // the flag without naming any parameter that would act on it (measured
204
+ // 2026-10-22, protocol §4.3.1), and it also stops sending the flag for models
205
+ // it used to describe, so the plugin pins thinking on instead of advertising a
206
+ // switch it cannot honour. The effort level is the only reasoning control.
203
207
  ...(id.startsWith('deepseek-') ? { thinking: true } : {}),
204
208
  // `/v3/config` publishes which tier a model needs; that is a free, static
205
209
  // entitlement signal (see `account-info.js`), so it is carried along.
@@ -306,7 +310,6 @@ export function mergeEntries(entries, extra) {
306
310
  ...(entry.contextWindow === undefined && other.contextWindow !== undefined ? { contextWindow: other.contextWindow } : {}),
307
311
  ...(entry.maxTokens === undefined && other.maxTokens !== undefined ? { maxTokens: other.maxTokens } : {}),
308
312
  ...(entry.defaultEffort === undefined && other.defaultEffort !== undefined ? { defaultEffort: other.defaultEffort } : {}),
309
- ...(entry.canDisableThinking === undefined && other.canDisableThinking !== undefined ? { canDisableThinking: other.canDisableThinking } : {}),
310
313
  ...(entry.promotion === undefined && other.promotion !== undefined ? { promotion: other.promotion } : {}),
311
314
  ...(levels.length === 0 ? {} : { efforts: Object.fromEntries(levels.map((level) => [level, level])) }),
312
315
  // `official` is only ever set by the account-scoped payload (see
package/src/index.js CHANGED
@@ -267,6 +267,29 @@ export function apply(ctx) {
267
267
  }, AVAILABILITY_BOOT_DELAY_MS)
268
268
  availabilityTimer.unref?.()
269
269
 
270
+ /**
271
+ * Retry log.
272
+ *
273
+ * The retry itself belongs to `@deepseek-ai/dsh-llm-retry`, which appends
274
+ * `llm/retry` to the session; nothing prints it, so a step that failed three
275
+ * times and then succeeded looks identical to one that succeeded first try.
276
+ * Logging the decision keeps a truncated answer visible even when the retry
277
+ * hides it — which is exactly the case that used to be reported as a clean stop.
278
+ */
279
+ ctx.effect(() => {
280
+ const byProvider = new Map(Object.values(REGIONS).map((region) => [region.provider, region]))
281
+ const off = ctx.on('session/event', (_session, event) => {
282
+ if (event?.type !== 'llm/retry') return
283
+ const region = byProvider.get(event.data?.provider)
284
+ if (region === undefined) return
285
+ const failure = event.data?.failure ?? {}
286
+ const delay = typeof event.data?.delayMs === 'number' ? `${(event.data.delayMs / 1000).toFixed(1)}s` : '未知延迟'
287
+ const message = String(failure.message ?? '').replace(/\s+/g, ' ').slice(0, 200)
288
+ ctx.logger.warn(`[workbuddy] ${region.id}: 第 ${event.data?.retry ?? '?'}/${event.data?.maxRetries ?? '?'} 次重试(${failure.code ?? '未知'})${message.length > 0 ? ` — ${message}` : ''};${delay} 后重试`)
289
+ })
290
+ return () => { if (typeof off === 'function') off() }
291
+ }, 'dsh-workbuddy: retry log')
292
+
270
293
  ctx.effect(() => () => {
271
294
  ctx.logger.info(`[workbuddy] 定时器全部撤销(实例 ${instance})`)
272
295
  clearTimeout(bootTimer)
@@ -132,7 +132,7 @@ export function writeOfficialList(regionId, models, options = {}) {
132
132
  * current. A model whose rate cannot be read right now reads 「未知」 — which is
133
133
  * why the console capture keeps the other fields only.
134
134
  */
135
- const ENRICHABLE = ['name', 'descriptionZh', 'descriptionEn', 'contextWindow', 'maxTokens', 'defaultEffort', 'canDisableThinking', 'requiredUserType']
135
+ const ENRICHABLE = ['name', 'descriptionZh', 'descriptionEn', 'contextWindow', 'maxTokens', 'defaultEffort', 'requiredUserType']
136
136
 
137
137
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
138
138
 
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 = {