dsh-connect-workbuddy 2.0.4 → 2.0.6
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/CHANGELOG.md +78 -1
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +187 -54
- package/lib/{host-heartbeat-CPIHUxKL.js → host-heartbeat--iKDuwr5.js} +59 -4
- package/lib/index.d.ts +110 -3
- package/lib/index.js +103 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.6 (2026-09-22)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **没有账号的区域不再向模型选择器列出模型**(issue #12,报告者 `stephenyab`)。此前只有国内版账号的用户,模型选择器里会多出一整组 `WorkBuddy Global`(**恰好 20 个**,与 `FALLBACK_WORKBUDDY_MODELS_GLOBAL` 逐条一致)——这些模型一个都用不了:该区域连凭据都解析不出来,选中后 `store.resolve()` 必抛错,shim 一律回 `401 not_signed_in`。**选择器说可选、实际必失败**,对单区域用户是纯噪音。
|
|
8
|
+
|
|
9
|
+
- **根因**:静态 fallback 的初衷是「上游离线也不让 provider 空着」,但它无条件播种(`new WorkBuddyCatalog(region)` 即装载整份 fallback),而 `deriveCatalog` 的「空勾选 = 全量」又把整份放行;启动时的真实刷新对无账号区域必然失败、只留一条 `logger.warn`,fallback 便永久留存。DSH 宿主对凭据一无所知(`buildModelCatalog` 只过滤 `models.length > 0`),于是原样渲染。
|
|
10
|
+
- **修复**:`WorkBuddyCatalog` 新增「本区域是否有本机登录」状态(`setRegionUsable()` / `isRegionUsable()`),无账号时 `current()` 返回空数组 → 宿主的空分组过滤让该分组**直接消失**。**provider 本身照常注册**:它的设置卡片与账号选择器正是用户登录的入口,隐藏分组是目录层决定,不是注册层决定。
|
|
11
|
+
- **与 `set()` 的非空守卫分开**,没有放宽它:两者回答不同问题——`set()` 承载**上游真实答案**,永不允许为空;「本区域没有账号」是**合法的空状态**,且必须能跨刷新存活。合并二者会要么丢掉守卫、要么丢掉隐藏分组的能力。测试专门锁定这一点:区域不可用期间上游刷新仍会在内部恢复目录,账号一出现即可立即服务。
|
|
12
|
+
- **默认可用(宽松方向)**:`setRegionUsable` 由异步扫描调用,扫描尚未完成或失败时保持原状,因此**一次瞬时文件系统错误不会把正常区域清空**。
|
|
13
|
+
- **两个触发点覆盖登录后的恢复**:① 设置变更时重算(`applySelection` 之后);② 卡片侧——usage 轮询与「重新检测账号」路由本就调用 `store.accounts()`,顺手上报可用性,所以用户登录桌面端后**无需重启**,模型自动出现。`setRegionUsable()` 返回值表明状态是否真的变化,未变化时不做多余的 adapter 失效。
|
|
14
|
+
- **对称覆盖两侧**:报告举的例子是「只有国内版账号 → 国际版多出 20 个」,反之亦然(只有国际版账号时国内版会多出 12 个),修复同时作用于两个区域。
|
|
15
|
+
|
|
16
|
+
**核查**:报告的根因链**逐条属实**,本版本按报告建议的方向(其「建议修复方向」第 1、2、3 条)实现。我们额外确认了它列出的两条实现约束确实成立——`catalog.ts` 的 `set([])` 会抛错(故另开一条状态而非放宽守卫)、空目录会让宿主过滤掉整个分组(故无需注销 provider),并验证了它在第 5 条提到但未展开的点:`routableProviders` 仍包含该 provider id,这与选择器过滤是两个独立概念,本次不改动。
|
|
17
|
+
|
|
18
|
+
**措辞说明**:`README` 此前写「两边模型同时出现在选择器里」——该表述自本版本起变为**有条件**(有账号才出现),中英文 README 与 2.0.0 的 CHANGELOG 条目均已就地更正,而不是留着一个已被推翻的说法。
|
|
19
|
+
|
|
20
|
+
### Tests
|
|
21
|
+
|
|
22
|
+
测试总数 173 → 179(新增 6 例):
|
|
23
|
+
|
|
24
|
+
- `tests/issue12-region-hiding.spec.ts` 新增 2 例(新文件,端到端复现报告者的环境):CN-only 机器上 `workbuddy-global` **不再列出任何模型**,而 `workbuddy` 正常服务;同时断言 **provider 与可配置 provider 仍在册**(登录入口不能消失)。第 2 例覆盖恢复路径:登录国际版账号后经设置链路重算,模型**无需重启**即可用。
|
|
25
|
+
- `catalog.spec.ts` 新增 4 例:默认可用(扫描未完成不误清空)、无账号后 `current()` 为空且 `isRegionUsable()` 为假、`setRegionUsable()` 的「无变化」返回值语义、以及**非空守卫与可用性标记互相独立**(区域不可用期间 `set()` 仍保有真实目录,账号出现即恢复)。
|
|
26
|
+
|
|
27
|
+
每条新增守卫都用「删掉对应实现即失败」验证过:让 `current()` 忽略可用性标记会挂 5 例;移除 `applySelection` 的重算调用会挂 3 例(含一条端到端用例)。
|
|
28
|
+
|
|
29
|
+
### Docs
|
|
30
|
+
|
|
31
|
+
- `README.md` / `README.en.md`:双供应商一节的「两边模型同时出现」改为「**有账号的区域**其模型出现」,并说明无账号区域不列出、登录后自动恢复、provider 始终注册。
|
|
32
|
+
- `CHANGELOG.md` 2.0.0 条目中同一处表述就地标注了 2.0.6 的收紧。
|
|
33
|
+
|
|
34
|
+
## 2.0.5 (2026-09-22)
|
|
35
|
+
|
|
36
|
+
### Fixes
|
|
37
|
+
|
|
38
|
+
- **「跟随 App 当前登录」清除后不再被旧版 `accountId` 顶掉,且清除结果在卡片上可见**(issue #11 的缺陷 1;其症状属实,但所声称的机制与严重性不准确——见下方「核查」)。
|
|
39
|
+
|
|
40
|
+
- **空字符串哨兵现在显式终止旧值回退**。`selectAccountFor()`(由 `applySelection` 内联逻辑提升为导出纯函数)按顺序判定:`accounts.<region> === ""` → 无选择(跟随 App);具体 id → 该区域的显式选择;键不存在 → 回退到所属区域的旧版 `accountId`。此前 `""` 走的是「值已存在」分支并被 `selectAccount('')` 归一化为 `undefined`,结果碰巧正确,但语义靠下游兜底;任何一处偏离(下游不再归一化、或增加新的读取方)都会让旧账号复活。现在哨兵在源头终止回退,并有测试锁定。
|
|
41
|
+
- **启动时的旧值归属(attribution)跳过被显式清除的区域**(`legacyAttributionRegion()`)。该 pass 在启动后异步运行:跳过被清除的区域,既避免把用户刚丢弃的选择重新绑回,也保证**另一个区域仍有机会认领同一个 id**(用 `continue` 而非 `break`,否则国际版的迁移会被国内版的清除顺手打断)。
|
|
42
|
+
- **卡片新增区域状态行**:显示该区域当前是「使用保存的账号」还是「跟随 App 当前登录」,清除后先显示确认文案。这是 issue #11 里真正属于产品缺陷的那一半——清除后回退到的默认账号**通常正是刚被清掉的那个账号**(用户当初就是在 App 登录状态下选它的),因此清除成功与清除无效在界面上完全同形。此前 `usage` 文档也没有任何字段能区分二者。
|
|
43
|
+
- **设置写入改为「写入后回读校验」,失败时明确报错**(Windows 上尤其重要,见下)。`settingsScope.set()` 的 promise resolve **并不代表值已落盘**:设置文档 `settings.yaml` 靠「写临时文件 + rename 覆盖」替换,而 `@deepseek-ai/dsh-atomic-write` 对 `EPERM`/`EBUSY`/`EACCES` 的重试**只在 `win32` 生效**——Windows 上杀毒软件、OneDrive 等同步盘或编辑器会短暂锁住该文件。重试耗尽后,失败以「不成功的响应」回到客户端,而设置 scope 的 `mutate()` 对此**只是重新加载 Host 状态然后正常返回**,所以调用方的 `await` 会成功、文档却没变。新增 `writeAccountSlot()` 与 `writeRegionModels()`:写入后把值读回,读不回即抛 `WorkBuddySettingsWriteError`。对三处写入的影响:
|
|
44
|
+
- **清除账号**:只有确认落盘才显示「已清除」,失败则显示错误并说明原选择仍生效——否则会给出一个被旧选择默默反驳的假确认,比不提示更糟;
|
|
45
|
+
- **切换账号**:同样校验。静默丢弃会让下拉框显示一个账号、实际计费另一个;
|
|
46
|
+
- **保存模型**:尤其要紧——保存成功会**丢弃草稿**,若写入没落盘就丢,用户唯一的副本就没了,却还被告知「已保存」。校验比对回读的 `lastCatalog` id 序列(不能只看「槽位存在」,截断/残缺的写入同样要检出)。
|
|
47
|
+
|
|
48
|
+
**核查**:报告的根因链在 HEAD 上不成立,逐条复现如下。**注意措辞更正**:2.0.5 初稿曾断言「清除本来就生效」,该结论只在 Host 侧成立;在 Windows 上写入可能被静默丢弃,因此报告的「点了没反应」在那里是真实存在的,只是机制与报告所述不同(不是 `accountId` 顶掉,而是写入没落盘)。回读校验修复的正是后者。
|
|
49
|
+
|
|
50
|
+
| 报告的主张 | 实测结果 |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `clearAccount()` 只写区域槽位,旧 `accountId` 会持续顶掉清除结果 | 清除后该区域立即跟随 App 默认;三种变体(旧账号≠当前登录、旧账号=当前登录、启动归属与清除竞争)均通过 |
|
|
53
|
+
| `effectiveAccountFor()` 在哨兵值上不提前返回,因此旧值继续生效 | 该分支返回 `""`,经 `selectAccount('')` 归一化为 `undefined`,**不会**回退到 `accountId`——返回值虽然「不提前」,但结果正确 |
|
|
54
|
+
| 启动迁移块会把区域重新绑回旧账号 | 迁移块只在启动时运行一次,且 `applySelection` 走同一个 `effectiveAccountFor`,同样被哨兵挡住 |
|
|
55
|
+
| `""` 与「未设置」在 schema 层不可区分 | `""` 与键缺失**是可区分的**:`z.object({cn: z.string()})` 对 `{cn: ''}` 保留空串、对 `{}` 保留缺键;`Object.hasOwn` 亦为真。不可区分的只有**语义**,不是 schema |
|
|
56
|
+
| (报告未提及)写入可能根本不生效 | **成立,且与平台相关**:见上一条。空串本身在 YAML 里能正确往返(写成 `cn: ""`),所以哨兵语义没问题;丢的是**整次写入** |
|
|
57
|
+
|
|
58
|
+
因此本版本不按报告的建议去「清除旧字段」——旧字段是**两个区域共享的**迁移来源,清掉它会让另一侧失去迁移依据;正确的做法是让哨兵赢(已实现),并把无法观测的状态暴露给界面(已实现),再加上对「写入没生效」的显式检测。
|
|
59
|
+
|
|
60
|
+
### Tests
|
|
61
|
+
|
|
62
|
+
测试总数 142 → 173(新增 31 例,**全套 173 通过**)。
|
|
63
|
+
|
|
64
|
+
> 关于 `host-heartbeat.spec.ts` 的 `processStartTimeMs`:在受限沙箱下该例会失败
|
|
65
|
+
> (`processStartTimeMs` 需要执行 `ps`,被沙箱以 `EPERM` 拒绝 → 返回 `undefined`)。
|
|
66
|
+
> 放开文件策略后 `ps` 可用,**5/5 通过**。该例与本次改动无关——两次运行用同一份
|
|
67
|
+
> 源码,差异只来自沙箱能否执行 `ps`。
|
|
68
|
+
|
|
69
|
+
- `settings-integration.spec.ts` 新增 15 例:`selectAccountFor` 的 6 例(显式选择优先、旧值只归属本区域、哨兵终止回退、清除与未配置可区分、两区域同时清除、清除一侧不影响另一侧);`legacyAttributionRegion` 的 6 例(归属、跳过已清除区域、跳过但继续扫描另一区域、账号已消失、无旧字段、跨区域共享 id);另有 3 例走真实 settings 链路 —— 清除后旧字段仍在但选不中、**重启后**已清除区域不被重新归属、以及清除前后经 usage 路由可见 `selectionExplicit` 翻转(账号名不变,这正是报告里「看起来没反应」的场景)。
|
|
70
|
+
- `client-account-selection.spec.ts` 新增 13 例(新文件):`configuredAccountsOf` 的容错读取;`writeAccountSlot` 的保存与「保留另一区域」、哨兵写成 `""` 而非删键、**锁文件导致的静默未落盘必须抛错**、错误携带字段名、**不把「键缺失」误判为清除成功**、正常落盘不误报、回读与卡片读取同源(scope 的 flush 默认 `sync`,落盘后同步可见,故不会误失败);`writeRegionModels` 的保存与区域保留、**静默未落盘必须抛错**、目录被截断也要检出(不能只看「槽位存在」)、错误字段名、正常落盘不误报。
|
|
71
|
+
- `auth.spec.ts` 新增 1 例:`hasExplicitSelection()` 在未选择/已选择/选择失效/哨兵清除四种状态下的取值。
|
|
72
|
+
- `web-status.spec.ts` 新增 2 例:signed-in 与两个 signed-out 分支都携带 `selectionExplicit`。
|
|
73
|
+
|
|
74
|
+
每条新增守卫都用「删掉对应实现即失败」逐一验证过:移除哨兵判断会挂 5 例,移除归属跳过会挂 2 例,把 `selectionExplicit` 写死为 `true` 会挂 3 例,把账号回读校验换成「相信 resolve」会挂 3 例,去掉模型保存的回读校验会挂 3 例。
|
|
75
|
+
|
|
76
|
+
### Docs
|
|
77
|
+
|
|
78
|
+
- `README.md` / `README.en.md` 的「已知限制 / Known limitations」补充:旧版 `accountId` 只作用于所属区域、且该区域被显式清除后不再生效;卡片状态行用于分辨清除是否生效;**Windows 上设置文件被占用会导致账号改动无法保存**,此时卡片报错且原选择保持生效。
|
|
79
|
+
|
|
3
80
|
## 2.0.4 (2026-09-21)
|
|
4
81
|
|
|
5
82
|
### Fixes
|
|
@@ -85,7 +162,7 @@
|
|
|
85
162
|
|
|
86
163
|
- **🆕 国内版与国际版拆分为两个并行供应商,可同时使用**——此前一个 provider 一次只能活一个区域(选哪个账号整个 `workbuddy` 就服务于哪个区域,切账号 = 翻转整个运行时目录)。现在两侧是两套完全独立的实例,**互不干扰**:
|
|
87
164
|
|
|
88
|
-
- **双 provider 注册**:国内版保持 `workbuddy`(老 id 不变,存量会话的默认模型与已保存选择全部继续有效),国际版新增 `workbuddy-global`(displayName `WorkBuddy Global
|
|
165
|
+
- **双 provider 注册**:国内版保持 `workbuddy`(老 id 不变,存量会话的默认模型与已保存选择全部继续有效),国际版新增 `workbuddy-global`(displayName `WorkBuddy Global`)。两边模型同时出现在 DSH 模型选择器里——不同会话 / 子代理可以各选一边,互不干扰。(**2.0.6 起收紧**:没有账号的区域不再列出模型,理由见该版本 CHANGELOG 的 issue #12 一节。)
|
|
89
166
|
- **每个区域一套完整运行时栈**:凭据 store、模型 catalog、回环 shim、adapter 各一份。store 按凭据域名过滤可见账号(`workbuddy.ai` → 国际版,其余 → 国内版),两个区域的账号可同时在线。
|
|
90
167
|
- **插件卡片 tab 化**:设置卡片顶部新增「国内版 / 国际版」tab 栏(带各自登录状态圆点)。每个 tab 有独立的账号选择、积分概览、签到与模型管理;**切 tab 不丢另一侧未保存的草稿**(模型勾选 / 图片开关 / 上下文预算的草稿按区域隔离保存)。4 条卡片路由全部按 `?region=cn|global` 参数化。
|
|
91
168
|
- **「减少刷新变化」**:一个 tab 里切账号、刷新模型、轮询积分,完全不触碰另一边的运行时目录——绑定另一边模型的进行中会话不受任何影响(单 provider 架构做不到这一点)。
|
package/README.en.md
CHANGED
|
@@ -21,7 +21,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
|
|
|
21
21
|
|
|
22
22
|
## Features
|
|
23
23
|
|
|
24
|
-
- **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.)
|
|
24
|
+
- **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.). **A region's models appear in the DSH model picker when that region has an account**: with accounts on both sides, both rosters are selectable at once, and different sessions can each pick a side without interfering. **A region with no local sign-in lists no models** — it cannot serve a single request, so listing them would offer nothing but failures. Sign in once for that region and its models come back on "detect accounts again" or after a restart; the provider itself stays registered either way, because its settings card and account picker are how you sign in. Model names carry the upstream credit multiplier (e.g. `GLM-5.3 · x0.79`), matching WorkBuddy's own model menu.
|
|
25
25
|
- **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account picker, credits overview, and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
|
|
26
26
|
- **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; image input is opted in per model manually (off by default).
|
|
27
27
|
- **Local account switching** — discovers the multiple sign-in credentials WorkBuddy's desktop app leaves behind and lets you switch per region. Tokens are never written to DSH settings.
|
|
@@ -116,6 +116,8 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
|
|
|
116
116
|
- The plugin depends on WorkBuddy client endpoints (not an official public API), so a WorkBuddy update may require adjustments.
|
|
117
117
|
- **Account switching** relies on the historical auth files the WorkBuddy desktop app leaves behind (the app's own backups), not an official multi-account API. Following the app's current sign-in remains the default; switching is an explicit opt-in, and historical credentials can be invalidated by the app's cleanup or sign-out.
|
|
118
118
|
- If an explicitly selected account disappears locally (the app replaced its login or cleaned up its backups), the plugin does **not** silently switch to another account — that would bill a different account. The card says the saved account no longer exists; pick an account again, or use "Follow the app's sign-in" to clear the saved choice. **Signing in again in the desktop app will not fix this**: the credentials are fine, it is the saved account id that no longer matches.
|
|
119
|
+
- Users upgrading from before 2.0.0 may still carry the old top-level `accountId` (the migration source). It applies only to its own region and **stops applying once that region is explicitly cleared (`accounts.<region> = ""`)** — otherwise the account you cleared would be silently restored at the next startup. The card shows whether a region is "using the saved account" or "following the app's current sign-in": the restored default is usually the very account you just cleared, so without that line there is no way to tell a successful clear from a no-op.
|
|
120
|
+
- **On Windows, an account change may fail to save while `settings.yaml` is held open.** DSH updates that file by writing a temp file and replacing the target; an antivirus scanner, a sync client (OneDrive), or an open editor can lock it briefly, and once the replacement retries are exhausted the failed write does **not** make `set()` reject. The plugin therefore reads the value back after writing: it shows "cleared" **only once the write is confirmed on disk**, and on failure it reports the error and keeps your previous choice (never a false confirmation that the old selection silently contradicts). Close whatever holds the file and retry.
|
|
119
121
|
- Windows and Linux credential paths are derived from platform conventions and are not verified on real hardware; set `WORKBUDDY_AUTH_FILE` if yours lives elsewhere.
|
|
120
122
|
|
|
121
123
|
## Disclaimer
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
## 功能特性
|
|
29
29
|
|
|
30
|
-
- **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3`
|
|
30
|
+
- **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片与账号选择器正是登录的入口。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
|
|
31
31
|
- **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号选择、积分概览与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
|
|
32
32
|
- **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;图片输入按模型手动勾选(默认不勾选)。
|
|
33
33
|
- **本机账号切换** —— 自动发现 WorkBuddy 桌面端留下的多个登录凭据,可按区域切换账号;Token 不写入 DSH 设置。
|
|
@@ -124,6 +124,8 @@ dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
|
|
|
124
124
|
- 依赖 WorkBuddy 客户端接口(非官方开放 API),WorkBuddy 更新后插件可能需要随之调整。
|
|
125
125
|
- **账号切换**基于 WorkBuddy 桌面端留下的历史 auth 文件(App 自身的备份产物),并非官方多账号 API。默认行为仍是跟随 App 当前登录;切换账号是显式选项,且历史凭据可能因 App 清理或退出登录而失效。
|
|
126
126
|
- 显式选择的账号若在本机消失(App 更换登录或清理备份文件),插件**不会**静默改选其他账号——那样会让账单落到另一个账号上。卡片会明确说明「保存的账号已不存在」,此时重新选择一个账号,或点「跟随 App 当前登录」清除该选择即可恢复。**重新登录桌面端 App 无法修复这种状态**:凭据本身是好的,失效的只是保存下来的账号标识。
|
|
127
|
+
- 从 2.0.0 之前的版本升级而来的用户,设置里可能仍留有旧版的顶层 `accountId`(迁移来源)。它只作用于其所属区域,且**该区域被显式清除(`accounts.<区域> = ""`)后即不再生效**——否则清除掉的账号会在下次启动时被静默恢复。卡片会显示每个区域当前是「使用保存的账号」还是「跟随 App 当前登录」:清除后回到的默认账号往往与刚清掉的账号是同一个,没有这行提示就无法分辨清除是否生效。
|
|
128
|
+
- **Windows:设置文件 `settings.yaml` 被占用时,账号改动可能无法保存**。DSH 用「写临时文件 + 覆盖替换」的方式更新该文件,而杀毒软件、OneDrive 等同步盘或正在打开该文件的编辑器会短暂锁住它;重试耗尽后,写入失败**不会**让 `set()` 报错。插件因此会在写入后回读校验:**确认落盘才显示「已清除」**,失败则给出明确错误并保留原来的选择(不会显示一个旧选择默默反驳的假确认)。遇到该提示时,关闭占用该文件的程序后重试即可。
|
|
127
129
|
- Windows / Linux 的凭据默认路径为按平台约定推导,未经真机验证;必要时可通过环境变量 `WORKBUDDY_AUTH_FILE` 指定实际位置。
|
|
128
130
|
|
|
129
131
|
## 免责声明
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat
|
|
2
|
+
import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat--iKDuwr5.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|
package/lib/client.js
CHANGED
|
@@ -57,6 +57,92 @@ window.__ModuleLoader__.load({
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
//#endregion
|
|
60
|
+
//#region src/client/account-selection.ts
|
|
61
|
+
/**
|
|
62
|
+
* A settings write that did not take effect.
|
|
63
|
+
*
|
|
64
|
+
* Distinct from a rejected `set()`: this is thrown when the write reported
|
|
65
|
+
* success and the value is nonetheless absent from the document.
|
|
66
|
+
*/
|
|
67
|
+
var WorkBuddySettingsWriteError = class extends Error {
|
|
68
|
+
/** The settings field that did not land. */
|
|
69
|
+
field;
|
|
70
|
+
constructor(field) {
|
|
71
|
+
super(`workbuddy: settings field "${field}" was not persisted by the settings write`);
|
|
72
|
+
this.name = "WorkBuddySettingsWriteError";
|
|
73
|
+
this.field = field;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** Read the per-region account selections out of the settings snapshot. */
|
|
77
|
+
function configuredAccountsOf(configured) {
|
|
78
|
+
const accounts = configured?.accounts;
|
|
79
|
+
return typeof accounts === "object" && accounts !== null ? accounts : {};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Write one region's account slot, then confirm the value actually landed.
|
|
83
|
+
*
|
|
84
|
+
* `settingsScope.set()` resolving is NOT proof that anything was stored. The
|
|
85
|
+
* Host's document is `settings.yaml`, replaced by writing a temp file and
|
|
86
|
+
* renaming it over the target; on Windows an antivirus scanner, a sync client
|
|
87
|
+
* (OneDrive, Dropbox), or an open editor can hold the file briefly.
|
|
88
|
+
* `@deepseek-ai/dsh-atomic-write` retries `EPERM` / `EBUSY` / `EACCES` a
|
|
89
|
+
* bounded number of times and those retries exist only for `win32` — and once
|
|
90
|
+
* they are exhausted the failure reaches the client as an unsuccessful
|
|
91
|
+
* response, which the settings scope handles by reloading Host state and
|
|
92
|
+
* merely RETURNING. The caller's `await` therefore succeeds while the document
|
|
93
|
+
* is unchanged.
|
|
94
|
+
*
|
|
95
|
+
* Reading the field back is the only reliable check, and it is what the
|
|
96
|
+
* official plugin cards do (`CardForm.store()` compares the user layer against
|
|
97
|
+
* the value it wrote). Failing loudly matters most on Clear: a false success
|
|
98
|
+
* would flip the card to "following the app's sign-in" while the old selection
|
|
99
|
+
* keeps running — the exact confusion that state line exists to remove.
|
|
100
|
+
*
|
|
101
|
+
* @param scope - the bound settings scope for this plugin's namespace.
|
|
102
|
+
* @param region - the region whose slot is written; the other is preserved.
|
|
103
|
+
* @param value - the account id to save, or `''` to clear the region.
|
|
104
|
+
* @throws {WorkBuddySettingsWriteError} when the value is readable back as
|
|
105
|
+
* something else — i.e. the write silently did not persist.
|
|
106
|
+
*/
|
|
107
|
+
async function writeAccountSlot(scope, region, value) {
|
|
108
|
+
const accounts = configuredAccountsOf(scope.getSnapshot().value);
|
|
109
|
+
await scope.set("accounts", {
|
|
110
|
+
...accounts,
|
|
111
|
+
[region]: value
|
|
112
|
+
});
|
|
113
|
+
if (configuredAccountsOf(scope.getSnapshot().value)[region] !== value) throw new WorkBuddySettingsWriteError("accounts");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Write one region's saved model state, then confirm it actually landed.
|
|
117
|
+
*
|
|
118
|
+
* The same silent-failure mode as {@link writeAccountSlot} applies to EVERY
|
|
119
|
+
* settings write, not just the account slot. It matters here for a different
|
|
120
|
+
* reason: a successful save discards the user's draft, so a write that did not
|
|
121
|
+
* persist makes the card throw away unsaved edits while claiming they were
|
|
122
|
+
* saved — unrecoverable, since the drafts are the only copy.
|
|
123
|
+
*
|
|
124
|
+
* Verification compares the round-tripped `lastCatalog` ids, which is the part
|
|
125
|
+
* of the payload that is both user-visible and cheap to compare; the selection
|
|
126
|
+
* fields ride along in the same object and cannot land separately.
|
|
127
|
+
*
|
|
128
|
+
* @param scope - the bound settings scope for this plugin's namespace.
|
|
129
|
+
* @param region - the region whose model slot is written.
|
|
130
|
+
* @param payload - the complete next state for that region's slot.
|
|
131
|
+
* @throws {WorkBuddySettingsWriteError} when the catalog does not read back.
|
|
132
|
+
*/
|
|
133
|
+
async function writeRegionModels(scope, region, payload) {
|
|
134
|
+
const configured = scope.getSnapshot().value?.regions;
|
|
135
|
+
const regions = typeof configured === "object" && configured !== null ? configured : {};
|
|
136
|
+
await scope.set("regions", {
|
|
137
|
+
...regions,
|
|
138
|
+
[region]: payload
|
|
139
|
+
});
|
|
140
|
+
const landed = scope.getSnapshot().value?.regions?.[region];
|
|
141
|
+
const written = payload.lastCatalog.map((model) => model.id);
|
|
142
|
+
const stored = landed?.lastCatalog?.map((model) => model.id);
|
|
143
|
+
if (!(stored !== void 0 && stored.length === written.length && stored.every((id, index) => id === written[index]))) throw new WorkBuddySettingsWriteError("regions");
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
60
146
|
//#region src/client/icon.ts
|
|
61
147
|
/**
|
|
62
148
|
* Plugin icon (LD brand logo, 64px) shared across the LaoDing plugin family.
|
|
@@ -117,7 +203,10 @@ window.__ModuleLoader__.load({
|
|
|
117
203
|
.dsm-workbuddy-usage-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
|
|
118
204
|
.dsm-workbuddy-usage-expiry{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
119
205
|
.dsm-workbuddy-usage-hint{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
120
|
-
.dsm-workbuddy-account-picker{display:
|
|
206
|
+
.dsm-workbuddy-account-picker{display:flex;flex-direction:column;gap:8px}
|
|
207
|
+
.dsm-workbuddy-account-state{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
208
|
+
.dsm-workbuddy-account-state:empty{display:none}
|
|
209
|
+
.dsm-workbuddy-account-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;white-space:pre-line}
|
|
121
210
|
.dsm-workbuddy-usage-select-wrap{position:relative}
|
|
122
211
|
.dsm-workbuddy-usage-select{appearance:none;width:100%;font:inherit;padding:10px 34px 10px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-3,#2a2c33);cursor:pointer;transition:border-color .15s,box-shadow .15s}
|
|
123
212
|
.dsm-workbuddy-usage-select:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary,#5686fe);box-shadow:0 0 0 3px rgba(86,134,254,.22)}
|
|
@@ -255,26 +344,30 @@ window.__ModuleLoader__.load({
|
|
|
255
344
|
function dotStyle(status) {
|
|
256
345
|
return { background: status === "signed-in" ? "var(--dsw-alias-state-success-primary, #22a06b)" : status === "error" ? "var(--dsw-alias-state-error-primary, #d92d20)" : "var(--dsw-alias-label-dimmed, #9aa0a6)" };
|
|
257
346
|
}
|
|
258
|
-
/** Read the per-region account selections out of the settings snapshot. */
|
|
259
|
-
function configuredAccountsOf(configured) {
|
|
260
|
-
const accounts = configured?.accounts;
|
|
261
|
-
return typeof accounts === "object" && accounts !== null ? accounts : {};
|
|
262
|
-
}
|
|
263
347
|
/** Render WorkBuddy sign-in state, credits, and model selection as one card. */
|
|
264
348
|
function WorkBuddyCard({ t, settingsScope }) {
|
|
265
349
|
if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
|
|
266
350
|
const [open, setOpen] = (0, react.useState)(false);
|
|
267
351
|
/** The region whose tab is on screen; each tab is its own provider stack. */
|
|
268
352
|
const [activeRegion, setActiveRegion] = (0, react.useState)("cn");
|
|
269
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* Last-known usage per region, so tab dots survive tab switches.
|
|
355
|
+
*
|
|
356
|
+
* The placeholder carries `selectionExplicit: false` because that is the
|
|
357
|
+
* state the plugin documents as the default before any choice is saved. It
|
|
358
|
+
* is never rendered: the account picker and its state line only appear once
|
|
359
|
+
* a real fetch has populated `accounts`.
|
|
360
|
+
*/
|
|
270
361
|
const [statusByRegion, setStatusByRegion] = (0, react.useState)({
|
|
271
362
|
cn: {
|
|
272
363
|
status: "signed-out",
|
|
273
|
-
accounts: []
|
|
364
|
+
accounts: [],
|
|
365
|
+
selectionExplicit: false
|
|
274
366
|
},
|
|
275
367
|
global: {
|
|
276
368
|
status: "signed-out",
|
|
277
|
-
accounts: []
|
|
369
|
+
accounts: [],
|
|
370
|
+
selectionExplicit: false
|
|
278
371
|
}
|
|
279
372
|
});
|
|
280
373
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
@@ -286,6 +379,10 @@ window.__ModuleLoader__.load({
|
|
|
286
379
|
/** Save failure surfaced next to the buttons; cleared by the next attempt. */
|
|
287
380
|
const [saveError, setSaveError] = (0, react.useState)(void 0);
|
|
288
381
|
const [switchingAccount, setSwitchingAccount] = (0, react.useState)(false);
|
|
382
|
+
/** Set right after a CONFIRMED Clear so the dropped choice is stated. */
|
|
383
|
+
const [accountNote, setAccountNote] = (0, react.useState)(void 0);
|
|
384
|
+
/** A refused account write (silently unpersisted settings on a locked file). */
|
|
385
|
+
const [accountError, setAccountError] = (0, react.useState)(void 0);
|
|
289
386
|
const [checkingIn, setCheckingIn] = (0, react.useState)(false);
|
|
290
387
|
const [checkinActionError, setCheckinActionError] = (0, react.useState)(void 0);
|
|
291
388
|
const mounted = (0, react.useRef)(true);
|
|
@@ -338,7 +435,8 @@ window.__ModuleLoader__.load({
|
|
|
338
435
|
]);
|
|
339
436
|
const status = statusByRegion[activeRegion] ?? {
|
|
340
437
|
status: "signed-out",
|
|
341
|
-
accounts: []
|
|
438
|
+
accounts: [],
|
|
439
|
+
selectionExplicit: false
|
|
342
440
|
};
|
|
343
441
|
(0, react.useEffect)(() => {
|
|
344
442
|
if (!open || status.status !== "signed-in") return;
|
|
@@ -387,13 +485,13 @@ window.__ModuleLoader__.load({
|
|
|
387
485
|
const switchAccount = async (accountId) => {
|
|
388
486
|
if (settingsScope === void 0) return;
|
|
389
487
|
setSwitchingAccount(true);
|
|
488
|
+
setAccountNote(void 0);
|
|
489
|
+
setAccountError(void 0);
|
|
390
490
|
try {
|
|
391
|
-
|
|
392
|
-
await settingsScope.set("accounts", {
|
|
393
|
-
...configuredAccounts,
|
|
394
|
-
[activeRegion]: accountId
|
|
395
|
-
});
|
|
491
|
+
await writeAccountSlot(settingsScope, activeRegion, accountId);
|
|
396
492
|
await refreshUsage(activeRegion);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
|
|
397
495
|
} finally {
|
|
398
496
|
if (mounted.current) setSwitchingAccount(false);
|
|
399
497
|
}
|
|
@@ -402,18 +500,29 @@ window.__ModuleLoader__.load({
|
|
|
402
500
|
* Drop the explicit choice so the region returns to its documented default:
|
|
403
501
|
* follow whatever the WorkBuddy app is currently signed in as. The empty
|
|
404
502
|
* string is the settings-level sentinel for "no explicit selection" (the
|
|
405
|
-
* store normalizes it away
|
|
503
|
+
* store normalizes it away, and the host's legacy attribution treats it as a
|
|
504
|
+
* deliberate clear rather than an unset key); the account id itself is never
|
|
505
|
+
* written here.
|
|
506
|
+
*
|
|
507
|
+
* The confirmation is only shown once the write is CONFIRMED. That matters
|
|
508
|
+
* twice over: the restored default is usually the very account that was
|
|
509
|
+
* saved, so the state line is the only feedback the user gets — and on
|
|
510
|
+
* Windows the atomic replace of `settings.yaml` can fail outright (locked by
|
|
511
|
+
* an antivirus scanner or a sync client), in which case claiming "cleared"
|
|
512
|
+
* would be a lie that the old selection silently contradicts.
|
|
406
513
|
*/
|
|
407
514
|
const clearAccount = async () => {
|
|
408
515
|
if (settingsScope === void 0) return;
|
|
409
516
|
setSwitchingAccount(true);
|
|
517
|
+
setAccountError(void 0);
|
|
410
518
|
try {
|
|
411
|
-
|
|
412
|
-
await settingsScope.set("accounts", {
|
|
413
|
-
...configuredAccounts,
|
|
414
|
-
[activeRegion]: ""
|
|
415
|
-
});
|
|
519
|
+
await writeAccountSlot(settingsScope, activeRegion, "");
|
|
416
520
|
await refreshUsage(activeRegion);
|
|
521
|
+
if (mounted.current) setAccountNote("cleared");
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (!mounted.current) return;
|
|
524
|
+
setAccountNote(void 0);
|
|
525
|
+
setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
|
|
417
526
|
} finally {
|
|
418
527
|
if (mounted.current) setSwitchingAccount(false);
|
|
419
528
|
}
|
|
@@ -542,15 +651,11 @@ window.__ModuleLoader__.load({
|
|
|
542
651
|
setSaving(true);
|
|
543
652
|
setSaveError(void 0);
|
|
544
653
|
try {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
[
|
|
549
|
-
|
|
550
|
-
enabledModelIds: [...activeEnabledIds],
|
|
551
|
-
imageModelIds: [...activeImageIds],
|
|
552
|
-
contextBudgets: activeContextBudgets
|
|
553
|
-
}
|
|
654
|
+
await writeRegionModels(settingsScope, status.region, {
|
|
655
|
+
lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
|
|
656
|
+
enabledModelIds: [...activeEnabledIds],
|
|
657
|
+
imageModelIds: [...activeImageIds],
|
|
658
|
+
contextBudgets: activeContextBudgets
|
|
554
659
|
});
|
|
555
660
|
discardModels();
|
|
556
661
|
await refreshUsage(activeRegion);
|
|
@@ -564,6 +669,11 @@ window.__ModuleLoader__.load({
|
|
|
564
669
|
const label = status.status === "signed-in" ? t("row.signedIn", { accountName: status.accountName }) : status.status === "error" ? t("row.requestFailed") : t("row.signedOut");
|
|
565
670
|
/** The saved choice no longer matches a local sign-in (tokens are fine). */
|
|
566
671
|
const selectionLost = status.status === "signed-out" && status.selectionLost === true;
|
|
672
|
+
/**
|
|
673
|
+
* Whether this region runs a saved choice, per the Host. `undefined` only on
|
|
674
|
+
* the error branch, which renders no picker and therefore no state line.
|
|
675
|
+
*/
|
|
676
|
+
const selectionExplicit = status.status === "error" ? void 0 : status.selectionExplicit;
|
|
567
677
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
568
678
|
className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
|
|
569
679
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
@@ -615,6 +725,8 @@ window.__ModuleLoader__.load({
|
|
|
615
725
|
className: `dsm-workbuddy-tab${region === activeRegion ? " dsm-workbuddy-tab-active" : ""}`,
|
|
616
726
|
onClick: () => {
|
|
617
727
|
setActiveRegion(region);
|
|
728
|
+
setAccountNote(void 0);
|
|
729
|
+
setAccountError(void 0);
|
|
618
730
|
},
|
|
619
731
|
children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
620
732
|
"aria-hidden": "true",
|
|
@@ -668,33 +780,46 @@ window.__ModuleLoader__.load({
|
|
|
668
780
|
status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
669
781
|
className: "dsm-workbuddy-account-picker",
|
|
670
782
|
"aria-label": t("row.accountsTitle"),
|
|
671
|
-
children: [
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
783
|
+
children: [
|
|
784
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
785
|
+
className: "dsm-workbuddy-usage-select-wrap",
|
|
786
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
787
|
+
className: "dsm-workbuddy-usage-select",
|
|
788
|
+
value: status.accounts.find((account) => account.selected)?.id ?? "",
|
|
789
|
+
disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
|
|
790
|
+
onChange: (event) => {
|
|
791
|
+
switchAccount(event.currentTarget.value);
|
|
792
|
+
},
|
|
793
|
+
children: [status.accounts.some((account) => account.selected) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
794
|
+
value: "",
|
|
795
|
+
disabled: true,
|
|
796
|
+
children: t("row.accountNoneInEffect")
|
|
797
|
+
}), status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
|
|
798
|
+
value: account.id,
|
|
799
|
+
children: [account.accountName, account.domain === "" ? "" : ` · ${account.domain}`]
|
|
800
|
+
}, account.id))]
|
|
801
|
+
})
|
|
802
|
+
}),
|
|
803
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
804
|
+
type: "button",
|
|
805
|
+
className: "dsm-btn dsm-btn-outline",
|
|
676
806
|
disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
|
|
677
|
-
|
|
678
|
-
|
|
807
|
+
onClick: () => {
|
|
808
|
+
clearAccount();
|
|
679
809
|
},
|
|
680
|
-
children:
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
810
|
+
children: t("row.accountsFollowApp")
|
|
811
|
+
}),
|
|
812
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
813
|
+
className: "dsm-workbuddy-account-state",
|
|
814
|
+
role: "status",
|
|
815
|
+
children: accountNote === "cleared" ? t("row.accountsCleared") : selectionExplicit === false ? t("row.accountsFollowingApp") : selectionExplicit === true ? t("row.accountsSavedChoice") : ""
|
|
816
|
+
}),
|
|
817
|
+
accountError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
818
|
+
className: "dsm-workbuddy-account-error",
|
|
819
|
+
role: "alert",
|
|
820
|
+
children: t("row.accountsWriteFailed", { message: accountError })
|
|
688
821
|
})
|
|
689
|
-
|
|
690
|
-
type: "button",
|
|
691
|
-
className: "dsm-btn dsm-btn-outline",
|
|
692
|
-
disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
|
|
693
|
-
onClick: () => {
|
|
694
|
-
clearAccount();
|
|
695
|
-
},
|
|
696
|
-
children: t("row.accountsFollowApp")
|
|
697
|
-
})]
|
|
822
|
+
]
|
|
698
823
|
}) : null,
|
|
699
824
|
status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
700
825
|
status.credits === void 0 ? null : (() => {
|
|
@@ -975,6 +1100,10 @@ window.__ModuleLoader__.load({
|
|
|
975
1100
|
"row.accountsScanning": "Detecting…",
|
|
976
1101
|
"row.accountsFollowApp": "Follow the app's sign-in",
|
|
977
1102
|
"row.accountNoneInEffect": "No account in effect",
|
|
1103
|
+
"row.accountsSavedChoice": "Using the account saved for this region.",
|
|
1104
|
+
"row.accountsFollowingApp": "Following the app's current sign-in — no account is saved for this region.",
|
|
1105
|
+
"row.accountsCleared": "Saved choice cleared. This region follows the app's current sign-in again.",
|
|
1106
|
+
"row.accountsWriteFailed": "The account change was not saved: {message} — the settings file could not be updated (on Windows it may be locked by an antivirus scanner or a sync client). Your previous choice is still in effect; close whatever holds settings.yaml and try again.",
|
|
978
1107
|
"row.selectionLostHint": "The saved account no longer exists locally.",
|
|
979
1108
|
"row.selectionLostMessage": "The account saved for this region is no longer among the local sign-ins (WorkBuddy replaced its login or cleaned up backups). Your other sign-ins are fine — pick one above, or choose \"Follow the app's sign-in\" to clear the saved choice. Signing in again in the desktop app will not fix this.",
|
|
980
1109
|
"row.modelsTitle": "Models",
|
|
@@ -1027,6 +1156,10 @@ window.__ModuleLoader__.load({
|
|
|
1027
1156
|
"row.accountsScanning": "正在检测…",
|
|
1028
1157
|
"row.accountsFollowApp": "跟随 App 当前登录",
|
|
1029
1158
|
"row.accountNoneInEffect": "当前没有生效的账号",
|
|
1159
|
+
"row.accountsSavedChoice": "正在使用该区域保存的账号。",
|
|
1160
|
+
"row.accountsFollowingApp": "正在跟随 App 当前登录——该区域没有保存账号。",
|
|
1161
|
+
"row.accountsCleared": "已清除保存的选择,该区域重新跟随 App 当前登录。",
|
|
1162
|
+
"row.accountsWriteFailed": "账号改动未保存:{message} —— 设置文件写入失败(Windows 上可能被杀毒软件或同步盘占用)。当前仍是原来保存的选择,请先关闭占用 settings.yaml 的程序再重试。",
|
|
1030
1163
|
"row.selectionLostHint": "已保存的账号在本机已不存在。",
|
|
1031
1164
|
"row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方选择一个,或点「跟随 App 当前登录」清除已保存的选择。重新登录桌面端 App 无法修复此问题。",
|
|
1032
1165
|
"row.modelsTitle": "模型",
|
|
@@ -931,6 +931,19 @@ var WorkBuddyCredentialStore = class {
|
|
|
931
931
|
selectedAccountId() {
|
|
932
932
|
return this.accountId;
|
|
933
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* Whether the region runs a SAVED choice rather than the documented default.
|
|
936
|
+
*
|
|
937
|
+
* `false` means "follow the app's current sign-in", which is also what the
|
|
938
|
+
* card's Clear action restores. The two states can resolve to the very same
|
|
939
|
+
* account — an upgraded user whose pre-split `accountId` happens to be the
|
|
940
|
+
* app's current sign-in clears the choice and sees no change at all — so the
|
|
941
|
+
* card cannot infer this from the account list alone. It reports the state
|
|
942
|
+
* here so clearing is observable instead of looking like a dead button.
|
|
943
|
+
*/
|
|
944
|
+
hasExplicitSelection() {
|
|
945
|
+
return this.accountId !== void 0;
|
|
946
|
+
}
|
|
934
947
|
/** The auth-file path candidates, in probe order. */
|
|
935
948
|
resolveDesktopCandidates() {
|
|
936
949
|
const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
|
|
@@ -1427,10 +1440,24 @@ function applyContextBudgets(catalog, budgets = {}) {
|
|
|
1427
1440
|
function deriveCatalog(catalog, enabled, budgets = {}) {
|
|
1428
1441
|
return applyContextBudgets(enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id)), budgets);
|
|
1429
1442
|
}
|
|
1430
|
-
/**
|
|
1443
|
+
/**
|
|
1444
|
+
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
|
1445
|
+
*
|
|
1446
|
+
* The static fallback exists so an OFFLINE upstream never leaves a provider
|
|
1447
|
+
* empty — but "offline" presumes the region is usable at all. A region with no
|
|
1448
|
+
* local sign-in cannot serve one single request, so seeding it with a roster
|
|
1449
|
+
* puts models in the picker that are guaranteed to 401 (issue #12). The host
|
|
1450
|
+
* distinguishes the two cases through {@link WorkBuddyCatalog.setRegionUsable}.
|
|
1451
|
+
*/
|
|
1431
1452
|
var WorkBuddyCatalog = class {
|
|
1432
1453
|
models;
|
|
1433
1454
|
/**
|
|
1455
|
+
* Whether this region has any local sign-in. Defaults to `true` — the
|
|
1456
|
+
* permissive direction — so a scan that has not run yet (or failed) keeps
|
|
1457
|
+
* serving the fallback rather than blanking a region that may be fine.
|
|
1458
|
+
*/
|
|
1459
|
+
usable = true;
|
|
1460
|
+
/**
|
|
1434
1461
|
* @param region Seeds the static fallback for this region; each region's
|
|
1435
1462
|
* provider must never serve the other region's roster before its first
|
|
1436
1463
|
* live refresh lands.
|
|
@@ -1438,9 +1465,37 @@ var WorkBuddyCatalog = class {
|
|
|
1438
1465
|
constructor(region = "cn") {
|
|
1439
1466
|
this.models = fallbackModelsFor(region);
|
|
1440
1467
|
}
|
|
1441
|
-
/**
|
|
1468
|
+
/**
|
|
1469
|
+
* Current entries; the fallback list until the upstream answer lands.
|
|
1470
|
+
*
|
|
1471
|
+
* Empty while this region has no local sign-in: the provider stays
|
|
1472
|
+
* registered (its row still hosts the settings card and the account picker),
|
|
1473
|
+
* but it advertises nothing to pick. DSH drops empty provider groups from
|
|
1474
|
+
* the picker (`buildModelCatalog` filters `models.length > 0`), so the group
|
|
1475
|
+
* disappears exactly when it would be pure noise.
|
|
1476
|
+
*/
|
|
1442
1477
|
current() {
|
|
1443
|
-
return this.models;
|
|
1478
|
+
return this.usable ? this.models : [];
|
|
1479
|
+
}
|
|
1480
|
+
/** Whether this region currently advertises any model. */
|
|
1481
|
+
isRegionUsable() {
|
|
1482
|
+
return this.usable;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Record whether this region has a local sign-in.
|
|
1486
|
+
*
|
|
1487
|
+
* Separate from {@link set} on purpose: `set()` carries a live upstream
|
|
1488
|
+
* answer and must never be empty, while "this region has no account" is a
|
|
1489
|
+
* legitimate empty state that must survive across refreshes. Keeping them
|
|
1490
|
+
* apart is what lets the non-empty guard stay strict.
|
|
1491
|
+
*
|
|
1492
|
+
* @returns whether the value changed, so the caller can skip invalidating
|
|
1493
|
+
* its adapter snapshots when nothing moved.
|
|
1494
|
+
*/
|
|
1495
|
+
setRegionUsable(usable) {
|
|
1496
|
+
if (this.usable === usable) return false;
|
|
1497
|
+
this.usable = usable;
|
|
1498
|
+
return true;
|
|
1444
1499
|
}
|
|
1445
1500
|
/** Replace the list; callers invalidate their adapter snapshot after this. */
|
|
1446
1501
|
set(models) {
|
|
@@ -1451,7 +1506,7 @@ var WorkBuddyCatalog = class {
|
|
|
1451
1506
|
//#endregion
|
|
1452
1507
|
//#region src/version.ts
|
|
1453
1508
|
/** The npm package version this build was produced from. */
|
|
1454
|
-
const WORKBUDDY_CONNECT_VERSION = "2.0.
|
|
1509
|
+
const WORKBUDDY_CONNECT_VERSION = "2.0.6";
|
|
1455
1510
|
//#endregion
|
|
1456
1511
|
//#region src/host-heartbeat.ts
|
|
1457
1512
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -187,6 +187,17 @@ declare class WorkBuddyCredentialStore {
|
|
|
187
187
|
selectAccount(accountId: string | undefined): void;
|
|
188
188
|
/** Selected account id, for diagnostics and route assembly. */
|
|
189
189
|
selectedAccountId(): string | undefined;
|
|
190
|
+
/**
|
|
191
|
+
* Whether the region runs a SAVED choice rather than the documented default.
|
|
192
|
+
*
|
|
193
|
+
* `false` means "follow the app's current sign-in", which is also what the
|
|
194
|
+
* card's Clear action restores. The two states can resolve to the very same
|
|
195
|
+
* account — an upgraded user whose pre-split `accountId` happens to be the
|
|
196
|
+
* app's current sign-in clears the choice and sees no change at all — so the
|
|
197
|
+
* card cannot infer this from the account list alone. It reports the state
|
|
198
|
+
* here so clearing is observable instead of looking like a dead button.
|
|
199
|
+
*/
|
|
200
|
+
hasExplicitSelection(): boolean;
|
|
190
201
|
/** The auth-file path candidates, in probe order. */
|
|
191
202
|
private resolveDesktopCandidates;
|
|
192
203
|
/** The resolved desktop auth-file path, for diagnostics. */
|
|
@@ -468,17 +479,53 @@ declare function fallbackModelsFor(region: 'cn' | 'global'): readonly WorkBuddyM
|
|
|
468
479
|
*/
|
|
469
480
|
type WorkBuddyContextBudget = number;
|
|
470
481
|
declare function deriveCatalog(catalog: readonly WorkBuddyModelInfo[], enabled: ReadonlySet<string>, budgets?: Readonly<Record<string, WorkBuddyContextBudget | undefined>>): WorkBuddyModelInfo[];
|
|
471
|
-
/**
|
|
482
|
+
/**
|
|
483
|
+
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
|
484
|
+
*
|
|
485
|
+
* The static fallback exists so an OFFLINE upstream never leaves a provider
|
|
486
|
+
* empty — but "offline" presumes the region is usable at all. A region with no
|
|
487
|
+
* local sign-in cannot serve one single request, so seeding it with a roster
|
|
488
|
+
* puts models in the picker that are guaranteed to 401 (issue #12). The host
|
|
489
|
+
* distinguishes the two cases through {@link WorkBuddyCatalog.setRegionUsable}.
|
|
490
|
+
*/
|
|
472
491
|
declare class WorkBuddyCatalog {
|
|
473
492
|
private models;
|
|
493
|
+
/**
|
|
494
|
+
* Whether this region has any local sign-in. Defaults to `true` — the
|
|
495
|
+
* permissive direction — so a scan that has not run yet (or failed) keeps
|
|
496
|
+
* serving the fallback rather than blanking a region that may be fine.
|
|
497
|
+
*/
|
|
498
|
+
private usable;
|
|
474
499
|
/**
|
|
475
500
|
* @param region Seeds the static fallback for this region; each region's
|
|
476
501
|
* provider must never serve the other region's roster before its first
|
|
477
502
|
* live refresh lands.
|
|
478
503
|
*/
|
|
479
504
|
constructor(region?: 'cn' | 'global');
|
|
480
|
-
/**
|
|
505
|
+
/**
|
|
506
|
+
* Current entries; the fallback list until the upstream answer lands.
|
|
507
|
+
*
|
|
508
|
+
* Empty while this region has no local sign-in: the provider stays
|
|
509
|
+
* registered (its row still hosts the settings card and the account picker),
|
|
510
|
+
* but it advertises nothing to pick. DSH drops empty provider groups from
|
|
511
|
+
* the picker (`buildModelCatalog` filters `models.length > 0`), so the group
|
|
512
|
+
* disappears exactly when it would be pure noise.
|
|
513
|
+
*/
|
|
481
514
|
current(): readonly WorkBuddyModelInfo[];
|
|
515
|
+
/** Whether this region currently advertises any model. */
|
|
516
|
+
isRegionUsable(): boolean;
|
|
517
|
+
/**
|
|
518
|
+
* Record whether this region has a local sign-in.
|
|
519
|
+
*
|
|
520
|
+
* Separate from {@link set} on purpose: `set()` carries a live upstream
|
|
521
|
+
* answer and must never be empty, while "this region has no account" is a
|
|
522
|
+
* legitimate empty state that must survive across refreshes. Keeping them
|
|
523
|
+
* apart is what lets the non-empty guard stay strict.
|
|
524
|
+
*
|
|
525
|
+
* @returns whether the value changed, so the caller can skip invalidating
|
|
526
|
+
* its adapter snapshots when nothing moved.
|
|
527
|
+
*/
|
|
528
|
+
setRegionUsable(usable: boolean): boolean;
|
|
482
529
|
/** Replace the list; callers invalidate their adapter snapshot after this. */
|
|
483
530
|
set(models: readonly WorkBuddyModelInfo[]): void;
|
|
484
531
|
}
|
|
@@ -766,6 +813,11 @@ type WorkBuddyWebUsage =
|
|
|
766
813
|
* sign-in again), versus nothing usable was found at all (sign in in the
|
|
767
814
|
* desktop app). Only the latter makes the "sign in again" hint truthful —
|
|
768
815
|
* re-signing in does not repair an orphaned id.
|
|
816
|
+
*
|
|
817
|
+
* `selectionExplicit` answers a question the account list cannot: whether
|
|
818
|
+
* the region runs a saved choice at all. Clearing restores the default, and
|
|
819
|
+
* when the default resolves to the same account the card would otherwise
|
|
820
|
+
* look unchanged — the "clear did nothing" report.
|
|
769
821
|
*/
|
|
770
822
|
{
|
|
771
823
|
status: 'signed-out';
|
|
@@ -773,6 +825,8 @@ type WorkBuddyWebUsage =
|
|
|
773
825
|
message?: string;
|
|
774
826
|
/** The persisted account id matches no local account. */
|
|
775
827
|
selectionLost?: boolean;
|
|
828
|
+
/** A saved per-region choice is in effect (false = following the app). */
|
|
829
|
+
selectionExplicit: boolean;
|
|
776
830
|
} | {
|
|
777
831
|
status: 'signed-in';
|
|
778
832
|
accountId: string;
|
|
@@ -783,6 +837,8 @@ type WorkBuddyWebUsage =
|
|
|
783
837
|
region: WorkBuddyWebRegion;
|
|
784
838
|
source?: 'desktop' | 'dsh';
|
|
785
839
|
tokenExpiresAtMs: number;
|
|
840
|
+
/** A saved per-region choice is in effect (false = following the app). */
|
|
841
|
+
selectionExplicit: boolean;
|
|
786
842
|
accounts: readonly WorkBuddyWebAccount[];
|
|
787
843
|
models: readonly WorkBuddyWebModel[];
|
|
788
844
|
enabledModelIds: readonly string[];
|
|
@@ -817,6 +873,16 @@ interface WorkBuddyStatusRouteOptions {
|
|
|
817
873
|
contextBudgets(region: WorkBuddyRegion): Readonly<Record<string, number | undefined>>;
|
|
818
874
|
/** Re-read the live catalog of one region from the upstream. */
|
|
819
875
|
discoverModels?(region: WorkBuddyRegion, signal?: AbortSignal): Promise<readonly WorkBuddyModelInfo[]>;
|
|
876
|
+
/**
|
|
877
|
+
* Report whether a region still has at least one local sign-in.
|
|
878
|
+
*
|
|
879
|
+
* The card is where sign-in state actually changes under the plugin's nose
|
|
880
|
+
* (the user signs in to the desktop app, then presses "detect accounts
|
|
881
|
+
* again"), so the routes are the earliest honest place to notice. Both
|
|
882
|
+
* callers already read `store.accounts()` for their own answer, so this adds
|
|
883
|
+
* no scan of its own.
|
|
884
|
+
*/
|
|
885
|
+
regionUsable?(region: WorkBuddyRegion, usable: boolean): void;
|
|
820
886
|
}
|
|
821
887
|
/**
|
|
822
888
|
* Assemble one region's card document. `region` is the tab the card is on;
|
|
@@ -898,6 +964,47 @@ declare const Config: z<Config>;
|
|
|
898
964
|
* picks.
|
|
899
965
|
*/
|
|
900
966
|
declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBuddyRegionState;
|
|
967
|
+
/**
|
|
968
|
+
* The account id a region should select, or `undefined` for the documented
|
|
969
|
+
* default: follow whatever the WorkBuddy app is currently signed in as.
|
|
970
|
+
*
|
|
971
|
+
* Three inputs decide it, and the ORDER is the whole point:
|
|
972
|
+
*
|
|
973
|
+
* 1. `accounts[region] === ''` — the Clear sentinel. It means "the user
|
|
974
|
+
* dropped this region's choice", which is the opposite of the key being
|
|
975
|
+
* absent ("never configured"). It therefore terminates the lookup: falling
|
|
976
|
+
* through to the legacy `accountId` here would re-bind the very account the
|
|
977
|
+
* user just dropped, and would do it after clearing appeared to succeed.
|
|
978
|
+
* 2. `accounts[region]` set to a real id — an explicit per-region choice.
|
|
979
|
+
* 3. absent key, with the pre-split `accountId` belonging to this region —
|
|
980
|
+
* the legacy migration, attributed once at startup from the local scan.
|
|
981
|
+
*
|
|
982
|
+
* `legacyAccountRegion` is resolved asynchronously after the first
|
|
983
|
+
* `applySelection` pass, so this is a pure function of it rather than a
|
|
984
|
+
* closure over the stores: the same call answers both the early pass (no
|
|
985
|
+
* attribution yet) and every later one.
|
|
986
|
+
*/
|
|
987
|
+
declare function selectAccountFor(region: WorkBuddyRegion, value: Config, legacyAccountRegion: WorkBuddyRegion | undefined): string | undefined;
|
|
988
|
+
/** Whether a region carries the Clear sentinel rather than a saved choice. */
|
|
989
|
+
declare function regionCleared(value: Config, region: WorkBuddyRegion): boolean;
|
|
990
|
+
/**
|
|
991
|
+
* Which region owns the pre-split `accountId`, or `undefined` when it cannot
|
|
992
|
+
* be attributed (the field is absent, or the account is gone from every local
|
|
993
|
+
* sign-in list).
|
|
994
|
+
*
|
|
995
|
+
* Regions the user explicitly cleared are skipped, even when the saved account
|
|
996
|
+
* IS among their local sign-ins. `selectAccountFor` already refuses to fall
|
|
997
|
+
* back for a cleared region, so this cannot change what runs — but it keeps the
|
|
998
|
+
* attribution honest about its own decision: the region that owns this id is
|
|
999
|
+
* not the one the user just told the plugin to stop pinning, so the other
|
|
1000
|
+
* region must get the chance to claim it.
|
|
1001
|
+
*
|
|
1002
|
+
* `accountsFor` is injected so the sequential, stop-at-first-match scan is
|
|
1003
|
+
* testable without a filesystem.
|
|
1004
|
+
*/
|
|
1005
|
+
declare function legacyAttributionRegion(value: Config, accountsFor: (region: WorkBuddyRegion) => Promise<readonly {
|
|
1006
|
+
id: string;
|
|
1007
|
+
}[]>): Promise<WorkBuddyRegion | undefined>;
|
|
901
1008
|
/**
|
|
902
1009
|
* Start both regions' loopback endpoints, register the `workbuddy` (CN) and
|
|
903
1010
|
* `workbuddy-global` (international) providers, and refresh each region's
|
|
@@ -907,4 +1014,4 @@ declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBud
|
|
|
907
1014
|
*/
|
|
908
1015
|
declare function apply(ctx: Context, config: Config): void;
|
|
909
1016
|
//#endregion
|
|
910
|
-
export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyReasoning, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|
|
1017
|
+
export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyReasoning, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionCleared, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat
|
|
1
|
+
import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat--iKDuwr5.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
@@ -561,9 +561,13 @@ function toWebAccount(account) {
|
|
|
561
561
|
async function workBuddyWebStatus(deps, region) {
|
|
562
562
|
const store = deps.store(region);
|
|
563
563
|
const accounts = await store.accounts();
|
|
564
|
-
|
|
564
|
+
deps.regionUsable?.(region, accounts.length > 0);
|
|
565
|
+
const authStatus = await store.status();
|
|
566
|
+
const selectionExplicit = store.hasExplicitSelection();
|
|
567
|
+
if (authStatus.state !== "signed-out" && accounts.length === 0) return {
|
|
565
568
|
status: "signed-out",
|
|
566
|
-
accounts: []
|
|
569
|
+
accounts: [],
|
|
570
|
+
selectionExplicit
|
|
567
571
|
};
|
|
568
572
|
let credential;
|
|
569
573
|
try {
|
|
@@ -573,6 +577,7 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
573
577
|
status: "signed-out",
|
|
574
578
|
accounts: accounts.map(toWebAccount),
|
|
575
579
|
message: safeMessage(error),
|
|
580
|
+
selectionExplicit,
|
|
576
581
|
...await store.selectionLost() ? { selectionLost: true } : {}
|
|
577
582
|
};
|
|
578
583
|
}
|
|
@@ -584,6 +589,7 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
584
589
|
region,
|
|
585
590
|
source: credential.source,
|
|
586
591
|
tokenExpiresAtMs: credential.expiresAtMs,
|
|
592
|
+
selectionExplicit,
|
|
587
593
|
accounts: accounts.map(toWebAccount),
|
|
588
594
|
models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
|
|
589
595
|
enabledModelIds: [...deps.enabledModelIds(region)],
|
|
@@ -646,7 +652,9 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
646
652
|
const region = requestRegion(req, res);
|
|
647
653
|
if (region === void 0) return;
|
|
648
654
|
try {
|
|
649
|
-
|
|
655
|
+
const accounts = await deps.store(region).accounts();
|
|
656
|
+
deps.regionUsable?.(region, accounts.length > 0);
|
|
657
|
+
json(res, 200, { accounts: accounts.map(toWebAccount) });
|
|
650
658
|
} catch (error) {
|
|
651
659
|
json(res, 500, { error: safeMessage(error) });
|
|
652
660
|
}
|
|
@@ -759,6 +767,59 @@ function regionStateOf(config, region) {
|
|
|
759
767
|
/** Every region, in card tab order. */
|
|
760
768
|
const REGION_KEYS = ["cn", "global"];
|
|
761
769
|
/**
|
|
770
|
+
* The account id a region should select, or `undefined` for the documented
|
|
771
|
+
* default: follow whatever the WorkBuddy app is currently signed in as.
|
|
772
|
+
*
|
|
773
|
+
* Three inputs decide it, and the ORDER is the whole point:
|
|
774
|
+
*
|
|
775
|
+
* 1. `accounts[region] === ''` — the Clear sentinel. It means "the user
|
|
776
|
+
* dropped this region's choice", which is the opposite of the key being
|
|
777
|
+
* absent ("never configured"). It therefore terminates the lookup: falling
|
|
778
|
+
* through to the legacy `accountId` here would re-bind the very account the
|
|
779
|
+
* user just dropped, and would do it after clearing appeared to succeed.
|
|
780
|
+
* 2. `accounts[region]` set to a real id — an explicit per-region choice.
|
|
781
|
+
* 3. absent key, with the pre-split `accountId` belonging to this region —
|
|
782
|
+
* the legacy migration, attributed once at startup from the local scan.
|
|
783
|
+
*
|
|
784
|
+
* `legacyAccountRegion` is resolved asynchronously after the first
|
|
785
|
+
* `applySelection` pass, so this is a pure function of it rather than a
|
|
786
|
+
* closure over the stores: the same call answers both the early pass (no
|
|
787
|
+
* attribution yet) and every later one.
|
|
788
|
+
*/
|
|
789
|
+
function selectAccountFor(region, value, legacyAccountRegion) {
|
|
790
|
+
const configured = value.accounts?.[region];
|
|
791
|
+
if (configured === "") return void 0;
|
|
792
|
+
if (configured !== void 0) return configured;
|
|
793
|
+
return legacyAccountRegion === region ? value.accountId : void 0;
|
|
794
|
+
}
|
|
795
|
+
/** Whether a region carries the Clear sentinel rather than a saved choice. */
|
|
796
|
+
function regionCleared(value, region) {
|
|
797
|
+
return value.accounts?.[region] === "";
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Which region owns the pre-split `accountId`, or `undefined` when it cannot
|
|
801
|
+
* be attributed (the field is absent, or the account is gone from every local
|
|
802
|
+
* sign-in list).
|
|
803
|
+
*
|
|
804
|
+
* Regions the user explicitly cleared are skipped, even when the saved account
|
|
805
|
+
* IS among their local sign-ins. `selectAccountFor` already refuses to fall
|
|
806
|
+
* back for a cleared region, so this cannot change what runs — but it keeps the
|
|
807
|
+
* attribution honest about its own decision: the region that owns this id is
|
|
808
|
+
* not the one the user just told the plugin to stop pinning, so the other
|
|
809
|
+
* region must get the chance to claim it.
|
|
810
|
+
*
|
|
811
|
+
* `accountsFor` is injected so the sequential, stop-at-first-match scan is
|
|
812
|
+
* testable without a filesystem.
|
|
813
|
+
*/
|
|
814
|
+
async function legacyAttributionRegion(value, accountsFor) {
|
|
815
|
+
const id = value.accountId;
|
|
816
|
+
if (id === void 0) return void 0;
|
|
817
|
+
for (const region of REGION_KEYS) {
|
|
818
|
+
if (regionCleared(value, region)) continue;
|
|
819
|
+
if ((await accountsFor(region)).some((account) => account.id === id)) return region;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
762
823
|
* Start both regions' loopback endpoints, register the `workbuddy` (CN) and
|
|
763
824
|
* `workbuddy-global` (international) providers, and refresh each region's
|
|
764
825
|
* model catalog from the upstream once that region's credentials allow it.
|
|
@@ -808,11 +869,7 @@ function apply(ctx, config) {
|
|
|
808
869
|
* a selection that belongs to the other side of the split.
|
|
809
870
|
*/
|
|
810
871
|
let legacyAccountRegion;
|
|
811
|
-
const effectiveAccountFor = (region, value) =>
|
|
812
|
-
const explicit = value.accounts?.[region];
|
|
813
|
-
if (explicit !== void 0) return explicit;
|
|
814
|
-
return legacyAccountRegion === region ? value.accountId : void 0;
|
|
815
|
-
};
|
|
872
|
+
const effectiveAccountFor = (region, value) => selectAccountFor(region, value, legacyAccountRegion);
|
|
816
873
|
const discoverModels = async (region, signal) => {
|
|
817
874
|
const credential = await stacks[region].store.resolve();
|
|
818
875
|
return client.fetchModels(credential, signal);
|
|
@@ -825,6 +882,34 @@ function apply(ctx, config) {
|
|
|
825
882
|
stacks[region].catalog.set(configuredModels(value, region));
|
|
826
883
|
}
|
|
827
884
|
invalidateCatalog();
|
|
885
|
+
refreshRegionUsability();
|
|
886
|
+
};
|
|
887
|
+
/**
|
|
888
|
+
* Tell each catalog whether its region has ANY local sign-in, so a region the
|
|
889
|
+
* user has no account for advertises nothing instead of a roster that can
|
|
890
|
+
* only 401 (issue #12).
|
|
891
|
+
*
|
|
892
|
+
* `accounts()` is the right source rather than the SELECTED credential: an
|
|
893
|
+
* orphaned saved id must not blank a region that still has other sign-ins to
|
|
894
|
+
* fall back on, and a region the user deliberately cleared must come back to
|
|
895
|
+
* life the moment its first account appears.
|
|
896
|
+
*
|
|
897
|
+
* Deliberately fire-and-forget and idempotent — it runs on every settings
|
|
898
|
+
* change, and `setRegionUsable` reports whether anything moved so a
|
|
899
|
+
* no-change pass costs nothing beyond the scan. A failed scan leaves the
|
|
900
|
+
* previous answer alone (the catalog starts permissive), so a transient
|
|
901
|
+
* filesystem error never blanks a working region.
|
|
902
|
+
*/
|
|
903
|
+
const refreshRegionUsability = async () => {
|
|
904
|
+
for (const region of REGION_KEYS) {
|
|
905
|
+
let accounts;
|
|
906
|
+
try {
|
|
907
|
+
accounts = await stacks[region].store.accounts();
|
|
908
|
+
} catch {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (stacks[region].catalog.setRegionUsable(accounts.length > 0)) invalidateCatalog();
|
|
912
|
+
}
|
|
828
913
|
};
|
|
829
914
|
ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
|
|
830
915
|
store: (region) => stacks[region].store,
|
|
@@ -833,7 +918,10 @@ function apply(ctx, config) {
|
|
|
833
918
|
enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
|
|
834
919
|
imageModelIds: (region) => regionStateOf(current(), region).imageModelIds ?? [],
|
|
835
920
|
contextBudgets: (region) => regionStateOf(current(), region).contextBudgets ?? {},
|
|
836
|
-
discoverModels
|
|
921
|
+
discoverModels,
|
|
922
|
+
regionUsable(region, usable) {
|
|
923
|
+
if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
|
|
924
|
+
}
|
|
837
925
|
}));
|
|
838
926
|
ctx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
|
|
839
927
|
setSource(source) {
|
|
@@ -845,14 +933,11 @@ function apply(ctx, config) {
|
|
|
845
933
|
});
|
|
846
934
|
applySelection(config);
|
|
847
935
|
(async () => {
|
|
848
|
-
const id = current().accountId;
|
|
849
|
-
if (id === void 0) return;
|
|
850
936
|
try {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
937
|
+
const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
|
|
938
|
+
if (region === void 0) return;
|
|
939
|
+
legacyAccountRegion = region;
|
|
940
|
+
applySelection(current());
|
|
856
941
|
} catch {}
|
|
857
942
|
})();
|
|
858
943
|
let stopped = false;
|
|
@@ -950,4 +1035,4 @@ function apply(ctx, config) {
|
|
|
950
1035
|
});
|
|
951
1036
|
}
|
|
952
1037
|
//#endregion
|
|
953
|
-
export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|
|
1038
|
+
export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionCleared, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "Connect locally signed-in WorkBuddy models to DeepSeek Harness with a read-only credits overview and model management.",
|
|
5
|
-
"version": "2.0.
|
|
5
|
+
"version": "2.0.6",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|