dsh-connect-workbuddy 4.9.1 → 4.9.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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +114 -20
- package/lib/{host-heartbeat-DahwuDGO.js → host-heartbeat-BHaBzzOr.js} +1 -1
- package/lib/index.d.ts +54 -1
- package/lib/index.js +175 -45
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.9.3 (2026-10-19)
|
|
4
|
+
|
|
5
|
+
> 版本号是 **patch**:新增一个**可选**的查询参数(`detail=fast`)和一条**只读**的本地取数路径;不带参数的老请求逐字节不变,发往上游的请求体、路由 id、模型 id、凭据与配置项一律未改。但**它修的是一个用户可见的等待**——打开设置页后要等半分钟到一分钟才出现账号列表。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **issue #77:打开设置页要等 30–60 秒才出现各个账号**
|
|
10
|
+
- **原来错在哪**:卡片的账号列表来自**一份**用量文档,而这份文档的**末尾**要等一串上游读取——账号池每个成员一次 `fetchCredits`、每个账号一次 `fetchCheckinStatus`、当前账号自己再一次 `fetchCredits` + `fetchCheckinStatus`。每次上游调用超时是 `JSON_TIMEOUT_MS = 30_000`(`src/upstream.ts`)且**没有重试**,于是慢一点的上游把等待叠成 30–60 秒。更糟的是这段时间里卡片渲染的是 `signed-out` 占位(说是「未登录」并给出「重新检测账号」按钮)——**报告者截图里看到的正是这个占位**,一个已经登录、池里有一堆账号的用户被告知未登录。
|
|
11
|
+
- **为什么 4.6.7 显得快**:那版还没有账号池的逐账号读数和签到读数(`poolMembersOf` 的逐成员积分读、`checkedInToday` 的逐账号签到读都是更早的提交引入的,但**卡片等整份文档**这个形状把它们的代价直接变成了首屏代价);不是 4.9.x 新增了扇出,而是 4.9.x 的功能让这份文档变得更长。所以修法不是微调超时,而是**把首屏和计费读解耦**。
|
|
12
|
+
- **现在的形状**:
|
|
13
|
+
- **服务端**:用量路由接受 `detail=fast`(`src/status-paths.ts` 的 `withWorkBuddyRegionFastDetail` / `fastDetailOf`),快速文档**只读本地状态**——凭据用 `store.current()`(不触发 token 刷新),实时数字(`credits` / `checkin` / `credentialRejected` / `recovery`)整块省略,账号池成员取自新增的 `localMembers`(就是请求路径本来就在用的 `localPoolMembers`,**零上游调用**),签到状态取自本进程已有的测量缓存 `cachedCheckedInToday`。**不带参数的行为一字未变**:未知或拼错的 `detail` 值一律退回完整文档,绝不会变成一份更空的卡片。
|
|
14
|
+
- **客户端**:先取 `detail=fast` 把账号表格画出来,再照旧取完整文档补上积分与签到。快速文档**永远不会覆盖**已经拿到完整文档的区域(它更穷,覆盖会把已显示的列抹成「—」)。在这之前(两份都还没回来)渲染 `row.accountsLoading`「正在加载账号…」,**不再假称未登录**。
|
|
15
|
+
- **为什么快速文档仍然带账号池块**:`AccountPool` 在没有 `pool` 块时返回 `null`,账号行就来自那里——首屏要「出现各个账号」,这个块就是必须的。它现在由本地花名册生成,不再等积分读数。
|
|
16
|
+
- **测试**:`tests/issue77-fast-first-paint.spec.ts` 在**路由层**断言快速文档**一次上游调用都不发**(积分/签到客户端被换成会抛错的桩,快速文档仍然成功),以及 `detail` 的解析只认精确的 `fast`、其余值全部退回完整文档,并保留完整文档行为不变的用例。`tests/issue77-card-first-paint.spec.tsx` 在**渲染层**用「完整文档永不返回」的桩挂载真实卡片,断言账号行**照样出现**、且不出现「未登录」文案;同时断言全部未返回时显示加载行,以及迟到的快速文档不会覆盖已加载的完整文档。删掉快速路径或让卡片等完整文档,这两组都会失败——已实测确认判别力。
|
|
17
|
+
- **刻意没做的**:没有给上游调用加重试或缩短超时(那只是把 30 秒变成另一种 30 秒);没有把 `poolMembersOf` 从完整文档里拿掉(完整文档仍然需要真实积分);没有让快速文档在宿主不支持时触发那次昂贵读取(缺 `localMembers` 就省略账号池块,宁可少画一行也不拖慢首屏)。
|
|
18
|
+
|
|
19
|
+
## 4.9.2 (2026-10-19)
|
|
20
|
+
|
|
21
|
+
> 版本号是 **patch**:只改 `profiles` 里多登记一个注册表键,外加文档与注释;发往上游的请求体、路由 id、模型 id、模型选择器、目录与凭据一律未变。但**它修的是一个整区不可用的故障**——4.9.0/4.9.1 期间国际版(`workbuddy-global`)的每一个请求都在发出前失败。
|
|
22
|
+
|
|
23
|
+
### Fixes
|
|
24
|
+
|
|
25
|
+
- **issue #75:国际版整区报 `Unknown provider: workbuddy`,任何请求都不可能成功**
|
|
26
|
+
- **原来错在哪**:4.9.0 为修 issue #59 把两条路由的模型身份统一成 `workbuddy`(`WORKBUDDY_MODEL_IDENTITY`),理由是「只有让 `isSameModel` 判等,跨区的 thinking 才不会降级成正文」。当时的论证是:pi-ai 的 `getModel(provider, id)` 取 `providers.get(provider).getModels()`(`models.js:116–118`),**查表的键**与**模型上盖的 `provider` 字段**是分开的,所以盖上 `workbuddy` 无害。**这条论证只对按参数查表的那条路径成立,漏掉了真正跑聊天的那条**:`models.streamSimple(model, …)` 的第一帧就是 `requireProvider(model)` = `this.providers.get(model.provider)`(`models.js:351–355`),查的正是模型上盖的那个字段。而每条路由的注册表里只登记过它自己的路由 id(宿主 `dsh-llm-pi-ai` 对每个 profile 调一次 `models.setProvider(profile.piProvider)`,键取 **provider 对象的 `id`**)。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在一个只有 `workbuddy-global` 的注册表里查 `workbuddy` —— 抛 `ModelsError("provider", "Unknown provider: workbuddy")`,**在请求发出之前**。
|
|
27
|
+
- **为什么测试没抓住**:#59 配的守卫测试断言的是 `getModel(provider, id)` 这种**按参数**的查表,恰好是不看盖上字段的那一种形状;于是它可以全绿,而聊天路径已经整区死掉。报错文案里也不含 "WorkBuddy" 字样,用户看到的是宿主状态一个无关的 provider 名。
|
|
28
|
+
- **现在的形状**:新增 `modelIdentityKeysOf(providerId, identity)`(`src/adapter.ts`),返回一条路由要在自己的注册表里登记的全部键。每条路由把**同一个** shim 的 provider **按两个 id 各建一个对象**登记:路由 id(`workbuddy-global`,含它自己的 shim 地址、目录、凭据)+共享身份(`workbuddy`)。国内版路由 id 与身份相同,去重后仍只登记一个。这段构造抽成 `buildProfiles()`,初始发布与 `invalidate()`(目录刷新后重建)**共用同一份**——旧代码在 `invalidate()` 里按单键重建,刷新一次就会把国际版重新打回故障。
|
|
29
|
+
- **一个必须说破的细节**:`Models.setProvider(provider)` 存的是 `this.providers.set(provider.id, provider)`(`models.js:35–38`),键取的是**provider 对象的 `id`**,不是 `profiles` 映射的键。因此「把同一个 provider 对象发布两次」是**静默无效**的,必须给身份那个键一个 `id` 改为身份的 provider 对象。`ResolvedPiAiProviderProfile.provider` 上游注释称自己就是「`Models` 集合的键」,只有 `piProvider.id === profile.provider` 时才成立——按对象拆开后这个不变式回来了。
|
|
30
|
+
- **顺带覆盖的第二处失败点**:`Models.getAuth(providerOrModel)` 同样用 `providers.get(providerOrModel.provider)` 取 provider(`models.js:275–280`,宿主侧 `:334–341`),修之前国际版即使过了 `requireProvider` 也会以 `Provider is not configured` 倒在取凭据一步;同一个键一并覆盖。
|
|
31
|
+
- **刻意没做的**:报告者提议的一行改法(`modelIdentity = options.modelIdentity ?? providerId`)**不能采纳**——它把身份拆回按路由区分,等于回退 #59(跨区 thinking 又整批降级成正文、计费跳变重现)。也没必要把身份做成「第二条 provider 条目」:`Models.providers` 本来就是 `Map`,多一个键不影响路由集合,模型选择器、目录、凭据、`registerAdapter` 的 routes 数组全都没有变化。上游的真修法仍是让 `isSameModel` 认「provider 等价组」声明(`earendil-works/pi#6167`)。
|
|
32
|
+
- **测试**:新增 `tests/issue75-global-provider-dispatch.spec.ts`,不用 `profiles()` 的字面量断言,而是**照宿主的构造方式**建注册表(`createModels()` + 每个 profile 一次 `setProvider`)再走 `streamSimple`。其中包含:国际版真实服务的模型能在国际版注册表里被 `requireProvider` 解析且拿到**国际版自己的** `baseUrl`(不是国内版的);国内版不被连累;**一个反证用例**手工重建 4.9.1 的单键注册表,断言它仍然失败于 `Unknown provider: workbuddy`,防止上面几条变成空转通过;`invalidate()` 之后上述性质仍在;以及 #59 的两条性质(两边盖同一个身份、跨区 thinking 仍是 `thinking`)不回退。把 `buildProfiles` 临时改回单键重跑,这组测试会以 `ModelsError: Unknown provider: workbuddy` 失败——已实测确认判别力。
|
|
33
|
+
- **文档**:`README.md` 的 issue #59 那一段原文把上述错误论证当成了依据,已改正并写明 #75 的成因与 4.9.2 的做法;`docs/audit/ISSUE59-OPTIONS.md` 的「O1′ SHIPPED」一节补了「#75 CORRECTION」,说明当时为什么论证错了、以及为什么「按你推理的那条查表路径写的测试,无法证伪从同一条路径得出的结论」。
|
|
34
|
+
|
|
3
35
|
## 4.9.1 (2026-10-19)
|
|
4
36
|
|
|
5
37
|
> 版本号是 **patch**:只改账号池里两枚「当前使用」徽章的配色,外加一处 CHANGELOG 排序修正;不改请求、响应、计费、排序规则或任何配置项。发往上游的请求体与 4.9.0 逐字节相同。
|
package/README.md
CHANGED
|
@@ -369,7 +369,7 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
|
|
|
369
369
|
- **切换区域会让上下文「变大」,甚至一换区就 `code 11115` 超限——不是网关多算你的钱**:同一份历史在两个区域的计费口径不同,根子在宿主内置的 `@earendil-works/pi-ai` 重放历史的方式。它给每条 assistant 消息算一次 `isSameModel`(`transform-messages.js:68`,判据是 provider **且** api **且** model id 三者全等):不等的消息,其 thinking 会被**降级成普通正文**(`:88–96`,并不是丢弃),而正文是网关会数的,thinking 走的是另一个字段(字段名两边都是 `reasoning_content`)。**这一点已由 issue #59 报告者的同区增量实测证实**:`reasoning` 不计费,且同一侧自己的工具结果块按本插件实测的 455.5 tok/1k 就吃掉了它全部计费量的 97.7%。本插件把国内版与国际版注册成**两条 provider 路由**(`workbuddy` / `workbuddy-global`,见[工作原理](#工作原理)),而它们是同一个 model id——于是**换一次区,另一侧产生的全部 thinking 就整批从 thinking 桶挪进正文桶**。这就是 issue #59 里「同一份对话历史,国际网关算 1.374M、国内网关算 708K(1.94×)」的成因;反方向切回来则是同一公式的 0.696×。
|
|
370
370
|
- 由此带来三个用户可见的后果:换区后 token 计数可能跳 ±30%~94%,**跳多少只取决于另一侧 thinking 的字符数,与历史总长无关**;在**同一个区域内换模型**也会触发同样的降级(`isSameModel` 的 model 那条腿),所以不换区也可能看到计数变化;跳变叠加在宿主当前的压缩阈值(`min(0.8×窗口, 窗口−128000−65536)`)上,就会让本来不超限的会话一换区立刻撞上 `code 11115 prompt is too long`。
|
|
371
371
|
- **这是宿主与 pi-ai 之间的一处错配,不是本插件的 bug**:不跨 provider/模型重放 thinking 这件事本身是对的(别家模型的思考过程对当前模型没有上下文价值),错在把 `provider` 当作「是不是同一个模型」的必要条件,而这里两个 provider 字符串指的是**同一个模型的两个接入区域**。
|
|
372
|
-
- **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim
|
|
372
|
+
- **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim、目录与凭据,所以一侧故障不会带走另一侧,模型选择器也仍是两组。**4.9.0/4.9.1 的这版修复漏了一半,国际版整区不可用(issue #75)**:上面的说法只对 `getModel(provider, id)` 这一条按**参数**查表的路径成立;真正跑聊天的 `models.streamSimple(model, …)` 第一步是 `requireProvider(model)` = `providers.get(model.provider)`(`models.js:351–355`),查的是**模型上盖的那个字段**。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在只注册了 `workbuddy-global` 的注册表里查不到,**每一个请求都在发出前就抛 `Unknown provider: workbuddy`**——一次也会失败,且报错里不带任何 WorkBuddy 字样,看起来像宿主坏了。**4.9.2 起改为:每条路由把自己的注册表同时登记上「路由 id」和「共享身份」两个键**(`src/adapter.ts` 的 `modelIdentityKeysOf`),模型上盖的身份仍然两边相同(#59 不回退),而 `requireProvider` 在国际版自己的注册表里也能查到、并拿到**国际版自己的** provider 对象(含它自己的 shim 地址与凭据)。pi-ai 的注册表本来就是 `Map`(`models.js:37` 的 `providers.set(provider.id, provider)`,键取的是 **provider 对象的 `id`**,不是 profiles 映射的键),所以这是注册表原生支持的事;**路由集合、模型选择器、目录与凭据都没有变化**,多出来的那个键不出现在任何 UI 里。回归测试 `tests/issue75-global-provider-dispatch.spec.ts` 走的是宿主的真实构造(`createModels` + `setProvider` + `streamSimple`),并保留一个反证用例固定住旧的失败文案。详见 `docs/audit/ISSUE59-OPTIONS.md` 的「#75 CORRECTION」一节。
|
|
373
373
|
- 升级后仍可能看到计数变化的部分:**在同一区域内换模型**仍会降级(`model` 那条腿是真的换了模型,thinking 签名未必对新模型有效,保留才是错的);**升级之前就录在历史里的消息**带着旧的按区域 id,重放时仍按旧规则降级——这种历史只会越聊越少,不会反过来影响新录的消息。
|
|
374
374
|
- 真正的修法仍在上游(让 `isSameModel` 认「provider 等价组」声明,而不是让插件去伪装自己的身份);插件侧的兜底是 4.8.0 起的 `src/context-rescue.ts`——**一旦上游确认超限,就按块折叠历史后重发**,而不是把宿主要压缩的整段历史原样重放一遍(原样重放正是「压缩请求自己超限 → 压缩死锁」的成因)。想亲眼看到这件事发生在哪条消息上:设 `WORKBUDDY_BODY_REPORT` 抓一次 body 组成报告,它只记计数、不记正文。
|
|
375
375
|
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-
|
|
2
|
+
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BHaBzzOr.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
|
@@ -123,6 +123,33 @@ window.__ModuleLoader__.load({
|
|
|
123
123
|
return `${path}?${WORKBUDDY_REGION_PARAM}=${region}`;
|
|
124
124
|
}
|
|
125
125
|
/**
|
|
126
|
+
* Query parameter selecting how much of a status document the Host assembles.
|
|
127
|
+
*
|
|
128
|
+
* The card's first paint only needs what local state already knows; live
|
|
129
|
+
* figures (credit, check-in, per-account readings) arrive through the SAME
|
|
130
|
+
* route once they have been measured. See {@link WORKBUDDY_DETAIL_FAST}.
|
|
131
|
+
*/
|
|
132
|
+
const WORKBUDDY_DETAIL_PARAM = "detail";
|
|
133
|
+
/**
|
|
134
|
+
* The `detail` value asking for the first-paint document.
|
|
135
|
+
*
|
|
136
|
+
* Absent means the full document, which is what every earlier caller asks for
|
|
137
|
+
* and what the route keeps answering — the parameter only ever ADDS a cheap
|
|
138
|
+
* mode, it never changes an existing one.
|
|
139
|
+
*/
|
|
140
|
+
const WORKBUDDY_DETAIL_FAST = "fast";
|
|
141
|
+
/**
|
|
142
|
+
* Address one region's status route for its first-paint document.
|
|
143
|
+
*
|
|
144
|
+
* The Host answers this from local state only (no upstream read, no token
|
|
145
|
+
* refresh), so a slow billing endpoint cannot decide when the account list
|
|
146
|
+
* appears. The card paints it first and then upgrades the same snapshot from
|
|
147
|
+
* the full document.
|
|
148
|
+
*/
|
|
149
|
+
function withWorkBuddyRegionFastDetail(path, region) {
|
|
150
|
+
return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_DETAIL_PARAM}=${WORKBUDDY_DETAIL_FAST}`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
126
153
|
* Narrow a settings value to the `regions` map. Accepts EITHER the whole
|
|
127
154
|
* settings section (the Host's resolved `Config`) OR the `regions` map itself,
|
|
128
155
|
* and unwraps the former. This tolerance is deliberate: passing the whole
|
|
@@ -4612,23 +4639,16 @@ window.__ModuleLoader__.load({
|
|
|
4612
4639
|
/**
|
|
4613
4640
|
* Last-known usage per region, so tab dots survive tab switches.
|
|
4614
4641
|
*
|
|
4615
|
-
*
|
|
4616
|
-
*
|
|
4617
|
-
*
|
|
4618
|
-
*
|
|
4642
|
+
* EMPTY until a real document lands (issue #77). It used to be seeded with a
|
|
4643
|
+
* `signed-out` placeholder, which was then rendered: for as long as the usage
|
|
4644
|
+
* route took to answer — and that wait is exactly what #77 reports — a user
|
|
4645
|
+
* with several signed-in accounts was told they were not signed in, with a
|
|
4646
|
+
* "detect accounts" button as the only way out. An absent region now renders
|
|
4647
|
+
* the loading line instead, and the first paint itself arrives from the
|
|
4648
|
+
* `detail=fast` document (local state only, no upstream call), so the rows
|
|
4649
|
+
* appear before anything billing-side has answered.
|
|
4619
4650
|
*/
|
|
4620
|
-
const [statusByRegion, setStatusByRegion] = (0, react.useState)({
|
|
4621
|
-
cn: {
|
|
4622
|
-
status: "signed-out",
|
|
4623
|
-
accounts: [],
|
|
4624
|
-
selectionExplicit: false
|
|
4625
|
-
},
|
|
4626
|
-
global: {
|
|
4627
|
-
status: "signed-out",
|
|
4628
|
-
accounts: [],
|
|
4629
|
-
selectionExplicit: false
|
|
4630
|
-
}
|
|
4631
|
-
});
|
|
4651
|
+
const [statusByRegion, setStatusByRegion] = (0, react.useState)({});
|
|
4632
4652
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
4633
4653
|
const [settingsRevision, setSettingsRevision] = (0, react.useState)(0);
|
|
4634
4654
|
/** Per-region unsaved model edits; a draft on one tab is never dropped by
|
|
@@ -4763,19 +4783,86 @@ window.__ModuleLoader__.load({
|
|
|
4763
4783
|
return;
|
|
4764
4784
|
}
|
|
4765
4785
|
}, [t]);
|
|
4786
|
+
/**
|
|
4787
|
+
* FIRST PAINT (issue #77): the same usage document, built from LOCAL state
|
|
4788
|
+
* only — no billing request, no per-account check-in read.
|
|
4789
|
+
*
|
|
4790
|
+
* Why this exists. The card used to paint nothing until ONE monolithic
|
|
4791
|
+
* document landed, and that document's tail awaited an upstream call per
|
|
4792
|
+
* account (credits for every pool member, check-in for every account, plus
|
|
4793
|
+
* the selected account's own two). Each of those has a 30-second timeout and
|
|
4794
|
+
* no retry, so a slow or throttled upstream stacked the waits and the panel
|
|
4795
|
+
* stayed empty for the reported 30–60 seconds — showing a "not signed in"
|
|
4796
|
+
* placeholder the whole time, because that is what an absent document
|
|
4797
|
+
* rendered as.
|
|
4798
|
+
*
|
|
4799
|
+
* The Host answers `detail=fast` from the credential store, the local pool
|
|
4800
|
+
* roster and the measurement caches, so this is one local round trip. It
|
|
4801
|
+
* carries the whole account table (the row list lives in the `pool` block),
|
|
4802
|
+
* which is the part the user came for; credits, check-in and the selected
|
|
4803
|
+
* account's live figures arrive with the full document that follows.
|
|
4804
|
+
*
|
|
4805
|
+
* Deliberately a SEPARATE callback instead of a `detail` argument on
|
|
4806
|
+
* `refreshUsage`: the ordering guard inside that callback is pinned by a
|
|
4807
|
+
* source test that counts its writes and freshness checks, and a second
|
|
4808
|
+
* write path in there would either break the pin or hide a real regression
|
|
4809
|
+
* behind it. Here the rules are simple and independent — this response never
|
|
4810
|
+
* downgrades a region that has already answered in full.
|
|
4811
|
+
*/
|
|
4812
|
+
const refreshUsageFast = (0, react.useCallback)(async (region, signal) => {
|
|
4813
|
+
const stale = usageGuard.current.begin(region);
|
|
4814
|
+
try {
|
|
4815
|
+
const response = await fetch(withWorkBuddyRegionFastDetail(WORKBUDDY_USAGE_PATH, region), {
|
|
4816
|
+
headers: { accept: "application/json" },
|
|
4817
|
+
credentials: "same-origin",
|
|
4818
|
+
...signal === void 0 ? {} : { signal }
|
|
4819
|
+
});
|
|
4820
|
+
if (!response.ok) return;
|
|
4821
|
+
const usage = await response.json().catch(() => void 0);
|
|
4822
|
+
if (usage === void 0) return;
|
|
4823
|
+
if (stale()) return;
|
|
4824
|
+
if (!mounted.current || signal?.aborted === true) return;
|
|
4825
|
+
setStatusByRegion((prev) => {
|
|
4826
|
+
const previous = prev[region];
|
|
4827
|
+
if (previous !== void 0 && previous.status === "signed-in") return prev;
|
|
4828
|
+
return {
|
|
4829
|
+
...prev,
|
|
4830
|
+
[region]: usage
|
|
4831
|
+
};
|
|
4832
|
+
});
|
|
4833
|
+
} catch {}
|
|
4834
|
+
}, []);
|
|
4766
4835
|
(0, react.useEffect)(() => {
|
|
4767
4836
|
if (!open) return;
|
|
4768
4837
|
const controller = new AbortController();
|
|
4769
|
-
|
|
4838
|
+
(async () => {
|
|
4839
|
+
await Promise.all(WORKBUDDY_REGIONS.map(async (region) => {
|
|
4840
|
+
await refreshUsageFast(region, controller.signal);
|
|
4841
|
+
}));
|
|
4842
|
+
if (controller.signal.aborted) return;
|
|
4843
|
+
for (const region of WORKBUDDY_REGIONS) refreshUsage(region, controller.signal);
|
|
4844
|
+
})();
|
|
4770
4845
|
return () => {
|
|
4771
4846
|
controller.abort();
|
|
4772
4847
|
};
|
|
4773
4848
|
}, [
|
|
4774
4849
|
open,
|
|
4775
4850
|
activeRegion,
|
|
4776
|
-
refreshUsage
|
|
4851
|
+
refreshUsage,
|
|
4852
|
+
refreshUsageFast
|
|
4777
4853
|
]);
|
|
4778
|
-
|
|
4854
|
+
/**
|
|
4855
|
+
* The document this tab is rendering, or `undefined` before ANY has landed.
|
|
4856
|
+
*
|
|
4857
|
+
* The distinction is the point of issue #77: an absent document used to be
|
|
4858
|
+
* rendered as the `signed-out` placeholder below, so for the whole time the
|
|
4859
|
+
* usage route was out the card told a signed-in user they were not signed in
|
|
4860
|
+
* and offered "detect accounts" as the only remedy. `loading` now gates that
|
|
4861
|
+
* branch, and the placeholder is only a safe shape for the reads below it.
|
|
4862
|
+
*/
|
|
4863
|
+
const loaded = statusByRegion[activeRegion];
|
|
4864
|
+
const loading = loaded === void 0;
|
|
4865
|
+
const status = loaded ?? {
|
|
4779
4866
|
status: "signed-out",
|
|
4780
4867
|
accounts: [],
|
|
4781
4868
|
selectionExplicit: false
|
|
@@ -5795,7 +5882,12 @@ window.__ModuleLoader__.load({
|
|
|
5795
5882
|
})
|
|
5796
5883
|
})
|
|
5797
5884
|
] }) : null,
|
|
5798
|
-
status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.
|
|
5885
|
+
status.status === "signed-out" && loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
5886
|
+
className: "dsm-workbuddy-usage-text",
|
|
5887
|
+
role: "status",
|
|
5888
|
+
children: t("row.accountsLoading")
|
|
5889
|
+
}) : null,
|
|
5890
|
+
status.status === "signed-out" && !loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
5799
5891
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
5800
5892
|
className: "dsm-workbuddy-usage-text",
|
|
5801
5893
|
children: signedOutText(notice, t)
|
|
@@ -6854,6 +6946,7 @@ window.__ModuleLoader__.load({
|
|
|
6854
6946
|
"row.accountsHint": "Choose from locally detected sign-ins. Tokens are never shown or saved in DSH settings.",
|
|
6855
6947
|
"row.accountsRescan": "Detect accounts again",
|
|
6856
6948
|
"row.accountsScanning": "Detecting…",
|
|
6949
|
+
"row.accountsLoading": "Loading your accounts…",
|
|
6857
6950
|
"row.accountsWriteFailed": "The account change was not saved: {message} — the active profile's configuration 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 that file and try again (see docs/WINDOWS.md in the plugin repo for the exact path).",
|
|
6858
6951
|
"row.selectionLostHint": "The saved account no longer exists locally.",
|
|
6859
6952
|
"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 a current one from the list above. Signing in again in the desktop app will not fix this.",
|
|
@@ -7166,6 +7259,7 @@ window.__ModuleLoader__.load({
|
|
|
7166
7259
|
"row.accountsHint": "选择本机检测到的登录账号;Token 不会显示,也不会保存到 DSH 设置。",
|
|
7167
7260
|
"row.accountsRescan": "重新检测账号",
|
|
7168
7261
|
"row.accountsScanning": "正在检测…",
|
|
7262
|
+
"row.accountsLoading": "正在加载账号…",
|
|
7169
7263
|
"row.accountsWriteFailed": "账号改动未保存:{message} —— 当前 profile 的配置文件写入失败(Windows 上可能被杀毒软件或同步盘占用)。当前仍是原来保存的选择,请先关闭占用该文件的程序再重试(文件的确切路径见插件仓库的 docs/WINDOWS.md)。",
|
|
7170
7264
|
"row.selectionLostHint": "已保存的账号在本机已不存在。",
|
|
7171
7265
|
"row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方列表中重新选择一个当前账号。重新登录桌面端 App 无法修复此问题。",
|
|
@@ -3966,7 +3966,7 @@ function generationOf(version) {
|
|
|
3966
3966
|
//#endregion
|
|
3967
3967
|
//#region src/version.ts
|
|
3968
3968
|
/** The npm package version this build was produced from. */
|
|
3969
|
-
const WORKBUDDY_CONNECT_VERSION = "4.9.
|
|
3969
|
+
const WORKBUDDY_CONNECT_VERSION = "4.9.3";
|
|
3970
3970
|
//#endregion
|
|
3971
3971
|
//#region src/host-heartbeat.ts
|
|
3972
3972
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -1284,6 +1284,10 @@ interface WorkBuddyAdapterOptions {
|
|
|
1284
1284
|
* Model identity stamped on every model this route serves; defaults to
|
|
1285
1285
|
* {@link WORKBUDDY_MODEL_IDENTITY}. Tests override it to model a transcript
|
|
1286
1286
|
* recorded by a host that still stamped per-region ids.
|
|
1287
|
+
*
|
|
1288
|
+
* It also decides the extra registry key this adapter publishes alongside
|
|
1289
|
+
* its route (issue #75) — see {@link modelIdentityKeysOf}, which is what
|
|
1290
|
+
* keeps an overridden stamp resolvable exactly like the production one.
|
|
1287
1291
|
*/
|
|
1288
1292
|
modelIdentity?: string;
|
|
1289
1293
|
/** Resolve the durable attachment service at request time, when present. */
|
|
@@ -3075,6 +3079,23 @@ interface WorkBuddyPoolDeps {
|
|
|
3075
3079
|
};
|
|
3076
3080
|
/** One region's CHECKED accounts, plus their credits and last measurements. */
|
|
3077
3081
|
members(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolMember[]>;
|
|
3082
|
+
/**
|
|
3083
|
+
* One region's CHECKED accounts from LOCAL state only — the first-paint
|
|
3084
|
+
* source (issue #77).
|
|
3085
|
+
*
|
|
3086
|
+
* Cheap by contract in the strongest sense: no upstream read at all. It
|
|
3087
|
+
* returns the saved members with whatever credit reading a previous full
|
|
3088
|
+
* document already recorded (a snapshot, possibly minutes old, possibly
|
|
3089
|
+
* absent), so the card can paint the account table before any billing
|
|
3090
|
+
* endpoint answers. `credits` being absent is normal here and renders as "—",
|
|
3091
|
+
* which is why {@link WorkBuddyWebPoolAccount} makes every figure optional.
|
|
3092
|
+
*
|
|
3093
|
+
* A Host built without it degrades to omitting the pool block from a
|
|
3094
|
+
* first-paint document (rather than paying {@link members} on that path):
|
|
3095
|
+
* the card then paints its account list from the full document, exactly as
|
|
3096
|
+
* before this contract existed.
|
|
3097
|
+
*/
|
|
3098
|
+
localMembers?(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolMember[]>;
|
|
3078
3099
|
/**
|
|
3079
3100
|
* The checked accounts that actually RESOLVE to a local sign-in.
|
|
3080
3101
|
*
|
|
@@ -3122,6 +3143,16 @@ interface WorkBuddyPoolDeps {
|
|
|
3122
3143
|
* Only the CN region has a check-in to report; see {@link checkinSupported}.
|
|
3123
3144
|
*/
|
|
3124
3145
|
checkedInToday?(region: WorkBuddyRegion): Promise<Readonly<Record<string, boolean>>>;
|
|
3146
|
+
/**
|
|
3147
|
+
* Today's check-in state, from the cells already measured this process.
|
|
3148
|
+
*
|
|
3149
|
+
* The first-paint counterpart of {@link checkedInToday}: reads the Host's
|
|
3150
|
+
* measurement cache and nothing else, so a first paint never waits on one
|
|
3151
|
+
* upstream request per account. An account it has not measured is simply
|
|
3152
|
+
* absent, which the card renders as unknown — the same meaning as a failed
|
|
3153
|
+
* per-account read in the full document.
|
|
3154
|
+
*/
|
|
3155
|
+
cachedCheckedInToday?(region: WorkBuddyRegion): Readonly<Record<string, boolean>>;
|
|
3125
3156
|
/**
|
|
3126
3157
|
* Every local sign-in the pool does NOT cover, so the card can offer them to
|
|
3127
3158
|
* check in. Absent means the card lists only pool members.
|
|
@@ -3168,14 +3199,36 @@ interface WorkBuddyPoolDeps {
|
|
|
3168
3199
|
/** The region's live catalog, used to resolve the free target model. */
|
|
3169
3200
|
catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
|
|
3170
3201
|
}
|
|
3202
|
+
/**
|
|
3203
|
+
* How much live measurement a status document carries (issue #77).
|
|
3204
|
+
*
|
|
3205
|
+
* `full` is the original answer: the selected account's credit and check-in
|
|
3206
|
+
* plus every pool account's credit and check-in, all read from upstream now.
|
|
3207
|
+
* It is what the card's 60-second poll, the composer panel and every
|
|
3208
|
+
* measurement-driven control expect.
|
|
3209
|
+
*
|
|
3210
|
+
* `fast` is the FIRST PAINT. It answers from local state only — no token
|
|
3211
|
+
* refresh, no billing endpoint, no check-in endpoint — so the account list can
|
|
3212
|
+
* render in the first round trip and cannot be held up by a slow or
|
|
3213
|
+
* rate-limited upstream. Live figures are absent from it by construction; the
|
|
3214
|
+
* card then asks for the full document and upgrades the same snapshot.
|
|
3215
|
+
*/
|
|
3216
|
+
type WorkBuddyStatusDetail = 'full' | 'fast';
|
|
3171
3217
|
/**
|
|
3172
3218
|
* Assemble one region's card document. `region` is the tab the card is on;
|
|
3173
3219
|
* the region-scoped store already answers with only that region's accounts,
|
|
3174
3220
|
* so the document's model slots and account list are that region's by
|
|
3175
3221
|
* construction. Sign-in state is read-only; credit is a live billing answer
|
|
3176
3222
|
* whose failure degrades to `creditsError` rather than failing the document.
|
|
3223
|
+
*
|
|
3224
|
+
* `options.detail` selects the first-paint variant: it uses the credential
|
|
3225
|
+
* already known locally instead of resolving (resolving may refresh a token
|
|
3226
|
+
* over the network) and skips {@link liveFigures} entirely, so nothing about
|
|
3227
|
+
* the account list depends on how fast a billing endpoint answers.
|
|
3177
3228
|
*/
|
|
3178
|
-
declare function workBuddyWebStatus(deps: WorkBuddyStatusRouteOptions, region: WorkBuddyRegion
|
|
3229
|
+
declare function workBuddyWebStatus(deps: WorkBuddyStatusRouteOptions, region: WorkBuddyRegion, options?: {
|
|
3230
|
+
detail?: WorkBuddyStatusDetail;
|
|
3231
|
+
}): Promise<WorkBuddyWebUsage>;
|
|
3179
3232
|
/**
|
|
3180
3233
|
* Mount the read-only routes on a context where `webServer` is available.
|
|
3181
3234
|
* The caller uses `ctx.inject(['webServer'], ...)`, so Desktop startup order
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BHaBzzOr.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -119,25 +119,60 @@ const WORKBUDDY_PROVIDERS = {
|
|
|
119
119
|
* byte-identical history, and a compression request that could then never fit
|
|
120
120
|
* the window it was computed for.
|
|
121
121
|
*
|
|
122
|
-
*
|
|
123
|
-
* `
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
122
|
+
* The stamp is NOT inert, and reading it as inert is what broke issue #75.
|
|
123
|
+
* `provider` is the KEY pi-ai dispatches on: `requireProvider` resolves
|
|
124
|
+
* `this.providers.get(model.provider)` and throws
|
|
125
|
+
* `Unknown provider: ${model.provider}` when it is absent (`models.js:410–416`
|
|
126
|
+
* on the host's 1.1.0, `:351–357` on the 0.85.1 this repo resolves), and
|
|
127
|
+
* `getAuth` resolves the same key, answering `Provider is not configured`
|
|
128
|
+
* instead (`:334–341` and `:429`; `:275–280` and `:366` on 0.85.1). Both sit on
|
|
129
|
+
* the LIVE chat path: `dsh-llm-pi-ai` streams through
|
|
130
|
+
* `snapshot.models.streamSimple(model, …)` (`lib/index.js:1983`), which calls
|
|
131
|
+
* `requireProvider` before a byte is sent (`models.js:454–459`). Each region
|
|
132
|
+
* builds its OWN registry from its own adapter — `current()` registers
|
|
133
|
+
* `profile.piProvider` per profile (`lib/index.js:1852–1862`) — so the
|
|
134
|
+
* `workbuddy-global` region registered only `workbuddy-global` and could not
|
|
135
|
+
* dispatch a model stamped `workbuddy`: every request failed with
|
|
136
|
+
* `Unknown provider: workbuddy` before reaching the gateway.
|
|
137
|
+
* {@link modelIdentityKeysOf} is the correction: the identity has to be a
|
|
138
|
+
* registry key of its own in every region, or the stamp cannot be shared.
|
|
139
|
+
*
|
|
140
|
+
* Lookup by ROUTE is a separate question and stays intact: `getModel(provider,
|
|
141
|
+
* id)` is `getModels(provider).find(...)` (`models.js:116–118`), i.e. it goes
|
|
142
|
+
* through the map key, while the STAMPED field is whatever the provider put
|
|
143
|
+
* there. So the route stays `workbuddy`/`workbuddy-global` (each with its own
|
|
144
|
+
* shim, catalog and credential store) while every model it serves advertises
|
|
145
|
+
* THIS id. The openai-completions api reads `model.provider` only for
|
|
146
|
+
* provider-specific quirks (none of which match these ids) and as an
|
|
147
|
+
* error-message label.
|
|
148
|
+
*
|
|
149
|
+
* Deliberately NOT the same string as the global route: a message is only
|
|
136
150
|
* same-model when provider, api AND model id all match, so the shared id must
|
|
137
151
|
* not be reachable as a route a user could pick — it names one model, not one
|
|
138
|
-
* gateway.
|
|
152
|
+
* gateway. It IS reachable as a pi-ai registry key, which is the point: those
|
|
153
|
+
* are internal and never surface as a route, because `registerAdapter` takes
|
|
154
|
+
* the route list explicitly (`dsh-llm/lib/index.js:1842–1861`,
|
|
155
|
+
* `prepareRoutes` `:1867–1885`) and this plugin passes exactly the two region
|
|
156
|
+
* ids (`src/index.ts:2069–2086`).
|
|
139
157
|
*/
|
|
140
158
|
const WORKBUDDY_MODEL_IDENTITY = "workbuddy";
|
|
159
|
+
/**
|
|
160
|
+
* Registry keys an adapter serving `providerId` must publish so that every
|
|
161
|
+
* stamp its models carry resolves inside its own region (issue #75).
|
|
162
|
+
*
|
|
163
|
+
* The route id first, then the model identity, de-duplicated — route-first so
|
|
164
|
+
* `profiles()` opens with the route the adapter actually serves. CN yields one
|
|
165
|
+
* key (`workbuddy` is both its route and the shared identity) and the global
|
|
166
|
+
* region yields two (`workbuddy-global`, `workbuddy`); the two regions
|
|
167
|
+
* therefore publish different key sets, and each must publish the shared one.
|
|
168
|
+
*
|
|
169
|
+
* `identity` is a parameter because {@link WorkBuddyAdapterOptions.modelIdentity}
|
|
170
|
+
* can override the stamp: the keys have to follow the stamp, or a test that
|
|
171
|
+
* stamps a per-region id would get the production keys back.
|
|
172
|
+
*/
|
|
173
|
+
function modelIdentityKeysOf(providerId, identity = WORKBUDDY_MODEL_IDENTITY) {
|
|
174
|
+
return [...new Set([providerId, identity].filter((key) => key !== void 0))];
|
|
175
|
+
}
|
|
141
176
|
/** Region a provider route id belongs to. */
|
|
142
177
|
function regionOfProvider(provider) {
|
|
143
178
|
for (const [region, id] of Object.entries(WORKBUDDY_PROVIDERS)) if (id === provider) return region;
|
|
@@ -250,7 +285,9 @@ function workBuddyThinkingLevelMap(info) {
|
|
|
250
285
|
*
|
|
251
286
|
* `identity` — not the route id — is what `provider` carries; see
|
|
252
287
|
* {@link WORKBUDDY_MODEL_IDENTITY} for why the two must differ. The route the
|
|
253
|
-
* model is REACHED by is the registry key the adapter serves,
|
|
288
|
+
* model is REACHED by is the registry key the adapter serves, while this
|
|
289
|
+
* stamped field is what pi-ai resolves the provider BY (issue #75) — so every
|
|
290
|
+
* key in {@link modelIdentityKeysOf} must be a key this route registers.
|
|
254
291
|
*/
|
|
255
292
|
function toPiModel(info, baseUrl, identity = WORKBUDDY_MODEL_IDENTITY) {
|
|
256
293
|
const thinkingLevelMap = workBuddyThinkingLevelMap(info);
|
|
@@ -589,17 +626,21 @@ function createWorkBuddyAdapter(options) {
|
|
|
589
626
|
}),
|
|
590
627
|
getModels: () => buildModels()
|
|
591
628
|
};
|
|
592
|
-
const
|
|
593
|
-
provider:
|
|
629
|
+
const profileFor = (key) => ({
|
|
630
|
+
provider: key,
|
|
594
631
|
displayName: providerName,
|
|
595
632
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
596
633
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-connect-workbuddy retryPolicy"),
|
|
597
634
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
598
635
|
modelErrors: /* @__PURE__ */ new Map(),
|
|
599
636
|
...REQUEST_IMAGE_BUDGETS,
|
|
600
|
-
piProvider: provider
|
|
601
|
-
|
|
602
|
-
|
|
637
|
+
piProvider: key === providerId ? provider : {
|
|
638
|
+
...provider,
|
|
639
|
+
id: key
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
const buildProfiles = () => new Map(modelIdentityKeysOf(providerId, modelIdentity).map((key) => [key, profileFor(key)]));
|
|
643
|
+
let profiles = buildProfiles();
|
|
603
644
|
return {
|
|
604
645
|
adapter: new PiAiAdapter({
|
|
605
646
|
profiles: () => profiles,
|
|
@@ -608,7 +649,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
608
649
|
...resolveAttachments === void 0 ? {} : { resolveAttachments }
|
|
609
650
|
}),
|
|
610
651
|
invalidate: () => {
|
|
611
|
-
profiles =
|
|
652
|
+
profiles = buildProfiles();
|
|
612
653
|
}
|
|
613
654
|
};
|
|
614
655
|
}
|
|
@@ -4788,6 +4829,34 @@ function withWorkBuddyRegion(path, region) {
|
|
|
4788
4829
|
return `${path}?${WORKBUDDY_REGION_PARAM}=${region}`;
|
|
4789
4830
|
}
|
|
4790
4831
|
/**
|
|
4832
|
+
* Query parameter selecting how much of a status document the Host assembles.
|
|
4833
|
+
*
|
|
4834
|
+
* The card's first paint only needs what local state already knows; live
|
|
4835
|
+
* figures (credit, check-in, per-account readings) arrive through the SAME
|
|
4836
|
+
* route once they have been measured. See {@link WORKBUDDY_DETAIL_FAST}.
|
|
4837
|
+
*/
|
|
4838
|
+
const WORKBUDDY_DETAIL_PARAM = "detail";
|
|
4839
|
+
/**
|
|
4840
|
+
* The `detail` value asking for the first-paint document.
|
|
4841
|
+
*
|
|
4842
|
+
* Absent means the full document, which is what every earlier caller asks for
|
|
4843
|
+
* and what the route keeps answering — the parameter only ever ADDS a cheap
|
|
4844
|
+
* mode, it never changes an existing one.
|
|
4845
|
+
*/
|
|
4846
|
+
const WORKBUDDY_DETAIL_FAST = "fast";
|
|
4847
|
+
/**
|
|
4848
|
+
* Whether a status-route URL asks for the first-paint document.
|
|
4849
|
+
*
|
|
4850
|
+
* Only the exact `fast` value is recognized: an unknown or misspelled value
|
|
4851
|
+
* degrades to the full document, which is what every earlier caller already
|
|
4852
|
+
* gets. A typo must never produce an emptier panel than no parameter at all.
|
|
4853
|
+
*/
|
|
4854
|
+
function fastDetailOf(url) {
|
|
4855
|
+
const at = url.indexOf("?");
|
|
4856
|
+
if (at === -1) return false;
|
|
4857
|
+
return new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_DETAIL_PARAM) === WORKBUDDY_DETAIL_FAST;
|
|
4858
|
+
}
|
|
4859
|
+
/**
|
|
4791
4860
|
* Read the region parameter off a status-route URL. Absent means the domestic
|
|
4792
4861
|
* tab (`cn`); a present-but-unknown value returns undefined so the route can
|
|
4793
4862
|
* answer 400 instead of guessing.
|
|
@@ -5115,13 +5184,42 @@ async function workBuddyAccountCredits(deps, region) {
|
|
|
5115
5184
|
}));
|
|
5116
5185
|
}
|
|
5117
5186
|
/**
|
|
5187
|
+
* Read the selected account's live figures, and classify a refusal.
|
|
5188
|
+
*
|
|
5189
|
+
* Extracted so the first-paint path can omit it as a unit: the two calls here
|
|
5190
|
+
* are the ones that used to gate the account list (each up to the upstream
|
|
5191
|
+
* client's 30-second ceiling, and the pool adds one pair per member on top).
|
|
5192
|
+
*/
|
|
5193
|
+
async function liveFigures(deps, region, credential, selected) {
|
|
5194
|
+
const [creditsResult, checkinResult] = await Promise.allSettled([deps.client.fetchCredits(credential), deps.client.fetchCheckinStatus(credential)]);
|
|
5195
|
+
const rejected = [creditsResult, checkinResult].some((result) => result.status === "rejected" && isCredentialRejectedError(result.reason));
|
|
5196
|
+
const recovery = !rejected ? void 0 : await resolveCredentialRecovery({
|
|
5197
|
+
region,
|
|
5198
|
+
store: deps.store(region),
|
|
5199
|
+
...selected === void 0 ? {} : { rejectedAccountId: selected.id },
|
|
5200
|
+
probe: deps.accountUsable ?? (async () => false)
|
|
5201
|
+
});
|
|
5202
|
+
return {
|
|
5203
|
+
...creditsResult.status === "fulfilled" ? { credits: toCredits(creditsResult.value) } : { creditsError: safeMessage(creditsResult.reason) },
|
|
5204
|
+
...checkinResult.status === "fulfilled" ? { checkin: checkinResult.value } : { checkinError: safeMessage(checkinResult.reason) },
|
|
5205
|
+
...!rejected ? {} : { credentialRejected: true },
|
|
5206
|
+
...recovery === void 0 ? {} : { recovery }
|
|
5207
|
+
};
|
|
5208
|
+
}
|
|
5209
|
+
/**
|
|
5118
5210
|
* Assemble one region's card document. `region` is the tab the card is on;
|
|
5119
5211
|
* the region-scoped store already answers with only that region's accounts,
|
|
5120
5212
|
* so the document's model slots and account list are that region's by
|
|
5121
5213
|
* construction. Sign-in state is read-only; credit is a live billing answer
|
|
5122
5214
|
* whose failure degrades to `creditsError` rather than failing the document.
|
|
5215
|
+
*
|
|
5216
|
+
* `options.detail` selects the first-paint variant: it uses the credential
|
|
5217
|
+
* already known locally instead of resolving (resolving may refresh a token
|
|
5218
|
+
* over the network) and skips {@link liveFigures} entirely, so nothing about
|
|
5219
|
+
* the account list depends on how fast a billing endpoint answers.
|
|
5123
5220
|
*/
|
|
5124
|
-
async function workBuddyWebStatus(deps, region) {
|
|
5221
|
+
async function workBuddyWebStatus(deps, region, options = {}) {
|
|
5222
|
+
const fast = options.detail === "fast";
|
|
5125
5223
|
const store = deps.store(region);
|
|
5126
5224
|
const accounts = await store.accounts();
|
|
5127
5225
|
deps.regionUsable?.(region, accounts.length > 0);
|
|
@@ -5139,7 +5237,8 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
5139
5237
|
};
|
|
5140
5238
|
let credential;
|
|
5141
5239
|
try {
|
|
5142
|
-
credential = await store.resolve();
|
|
5240
|
+
credential = fast ? await store.current() : await store.resolve();
|
|
5241
|
+
if (credential === void 0) credential = await store.resolve();
|
|
5143
5242
|
} catch (error) {
|
|
5144
5243
|
const webAccounts = accounts.map(toWebAccount);
|
|
5145
5244
|
return {
|
|
@@ -5172,14 +5271,7 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
5172
5271
|
imageModelIds: [...deps.imageModelIds(region)],
|
|
5173
5272
|
offModelIds: [...deps.offModelIds(region)]
|
|
5174
5273
|
};
|
|
5175
|
-
const
|
|
5176
|
-
const rejected = [creditsResult, checkinResult].some((result) => result.status === "rejected" && isCredentialRejectedError(result.reason));
|
|
5177
|
-
const recovery = !rejected ? void 0 : await resolveCredentialRecovery({
|
|
5178
|
-
region,
|
|
5179
|
-
store: deps.store(region),
|
|
5180
|
-
...selected === void 0 ? {} : { rejectedAccountId: selected.id },
|
|
5181
|
-
probe: deps.accountUsable ?? (async () => false)
|
|
5182
|
-
});
|
|
5274
|
+
const figures = fast ? {} : await liveFigures(deps, region, credential, selected);
|
|
5183
5275
|
return {
|
|
5184
5276
|
status: "signed-in",
|
|
5185
5277
|
contextBudgets: Object.fromEntries(Object.entries(deps.contextBudgets(region)).filter(([, value]) => typeof value === "number")),
|
|
@@ -5189,18 +5281,22 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
5189
5281
|
authFile: volatileFlagOf("authFile")
|
|
5190
5282
|
},
|
|
5191
5283
|
...account,
|
|
5192
|
-
...
|
|
5193
|
-
...
|
|
5194
|
-
...!rejected ? {} : { credentialRejected: true },
|
|
5195
|
-
...recovery === void 0 ? {} : { recovery },
|
|
5196
|
-
...await poolSectionOf(deps, region)
|
|
5284
|
+
...figures,
|
|
5285
|
+
...await poolSectionOf(deps, region, fast)
|
|
5197
5286
|
};
|
|
5198
5287
|
}
|
|
5199
|
-
/**
|
|
5200
|
-
|
|
5288
|
+
/**
|
|
5289
|
+
* The pool block for a status document, or nothing when it cannot be built.
|
|
5290
|
+
*
|
|
5291
|
+
* `fast` asks for the first-paint variant: local state only, no upstream call.
|
|
5292
|
+
* A Host without a local member source then omits the block rather than paying
|
|
5293
|
+
* the full read, so a first-paint document never regresses into the expensive
|
|
5294
|
+
* path this mode exists to avoid.
|
|
5295
|
+
*/
|
|
5296
|
+
async function poolSectionOf(deps, region, fast) {
|
|
5201
5297
|
if (deps.pool === void 0) return {};
|
|
5202
5298
|
try {
|
|
5203
|
-
const pool = await workBuddyWebPool(deps, region);
|
|
5299
|
+
const pool = await workBuddyWebPool(deps, region, fast);
|
|
5204
5300
|
return pool === void 0 ? {} : { pool };
|
|
5205
5301
|
} catch {
|
|
5206
5302
|
return {};
|
|
@@ -5219,11 +5315,12 @@ async function poolSectionOf(deps, region) {
|
|
|
5219
5315
|
* SHOULD serve, `current` says who IS serving. Marking the winner as current
|
|
5220
5316
|
* would make the card claim a switch that may not have been applied yet.
|
|
5221
5317
|
*/
|
|
5222
|
-
async function workBuddyWebPool(deps, region) {
|
|
5318
|
+
async function workBuddyWebPool(deps, region, fast = false) {
|
|
5223
5319
|
const pool = deps.pool;
|
|
5224
5320
|
if (pool === void 0) return void 0;
|
|
5225
5321
|
const preferences = pool.preferences(region);
|
|
5226
|
-
const members = await pool.members(region);
|
|
5322
|
+
const members = fast ? await pool.localMembers?.(region) : await pool.members(region);
|
|
5323
|
+
if (members === void 0) return void 0;
|
|
5227
5324
|
const ranked = rankPool(members, Date.now(), preferences.orderAccountIds);
|
|
5228
5325
|
const catalog = pool.catalog?.(region) ?? [];
|
|
5229
5326
|
const target = resolveTargetModel(catalog, preferences.targetModelId);
|
|
@@ -5231,7 +5328,7 @@ async function workBuddyWebPool(deps, region) {
|
|
|
5231
5328
|
const byId = new Map(members.map((member) => [member.account.id, member]));
|
|
5232
5329
|
const rankedIds = new Set(ranked.map((row) => row.account.id));
|
|
5233
5330
|
const others = await pool.otherAccounts?.(region) ?? [];
|
|
5234
|
-
const checkedIn = await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
|
|
5331
|
+
const checkedIn = fast ? pool.cachedCheckedInToday?.(region) ?? {} : await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
|
|
5235
5332
|
const rows = [...ranked.map((row) => ({
|
|
5236
5333
|
account: row.account,
|
|
5237
5334
|
member: true,
|
|
@@ -5338,8 +5435,9 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
5338
5435
|
}
|
|
5339
5436
|
const region = requestRegion(req, res);
|
|
5340
5437
|
if (region === void 0) return;
|
|
5438
|
+
const detail = fastDetailOf(req.url ?? "") ? "fast" : "full";
|
|
5341
5439
|
try {
|
|
5342
|
-
json(res, 200, await workBuddyWebStatus(deps, region));
|
|
5440
|
+
json(res, 200, await workBuddyWebStatus(deps, region, { detail }));
|
|
5343
5441
|
} catch (error) {
|
|
5344
5442
|
json(res, 500, { error: safeMessage(error) });
|
|
5345
5443
|
}
|
|
@@ -6410,6 +6508,19 @@ function apply(ctx, config) {
|
|
|
6410
6508
|
preferences: (region) => poolPreferencesOf(current(), region),
|
|
6411
6509
|
members: (region) => poolMembersOf(region),
|
|
6412
6510
|
/**
|
|
6511
|
+
* The first-paint member list (issue #77): local state only.
|
|
6512
|
+
*
|
|
6513
|
+
* This is `localPoolMembers`, i.e. the SAME list the request path already
|
|
6514
|
+
* consults — saved members, their last credit snapshot (if it is still
|
|
6515
|
+
* fresh), and their last probe result. No upstream call, so the card's
|
|
6516
|
+
* account table can paint before any billing endpoint answers; live
|
|
6517
|
+
* figures arrive with the full document a moment later.
|
|
6518
|
+
*
|
|
6519
|
+
* A member whose credit snapshot has expired simply carries no `credits`,
|
|
6520
|
+
* which the card renders as "—" rather than as zero.
|
|
6521
|
+
*/
|
|
6522
|
+
localMembers: (region) => localPoolMembers(region),
|
|
6523
|
+
/**
|
|
6413
6524
|
* The checked accounts that resolve to a local sign-in.
|
|
6414
6525
|
*
|
|
6415
6526
|
* A local scan only — no upstream calls — so the route's empty-pool guard
|
|
@@ -6486,6 +6597,25 @@ function apply(ctx, config) {
|
|
|
6486
6597
|
}));
|
|
6487
6598
|
return Object.fromEntries(entries.filter((entry) => entry !== void 0));
|
|
6488
6599
|
},
|
|
6600
|
+
/**
|
|
6601
|
+
* Today's check-in state from the cells already measured this process.
|
|
6602
|
+
*
|
|
6603
|
+
* The first-paint counterpart (issue #77) of `checkedInToday`: it reads
|
|
6604
|
+
* `checkinStateCache` and nothing else, so painting the table never waits
|
|
6605
|
+
* on one request per account. An unmeasured account is absent, which the
|
|
6606
|
+
* card already renders as unknown rather than as "not checked in".
|
|
6607
|
+
*/
|
|
6608
|
+
cachedCheckedInToday(region) {
|
|
6609
|
+
if (!poolPreferencesOf(current(), region).enabled) return {};
|
|
6610
|
+
const now = Date.now();
|
|
6611
|
+
const state = checkinStateCache[region] ?? {};
|
|
6612
|
+
const cached = {};
|
|
6613
|
+
for (const [accountId, entry] of Object.entries(state)) {
|
|
6614
|
+
if (now - entry.atMs >= CHECKIN_CACHE_MS) continue;
|
|
6615
|
+
cached[accountId] = entry.checkedIn;
|
|
6616
|
+
}
|
|
6617
|
+
return cached;
|
|
6618
|
+
},
|
|
6489
6619
|
async currentAccountId(region) {
|
|
6490
6620
|
await applyPoolSelection(region).catch(() => void 0);
|
|
6491
6621
|
const credential = await stacks[region].store.current().catch(() => void 0);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
|
|
5
|
-
"version": "4.9.
|
|
5
|
+
"version": "4.9.3",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|