dsh-connect-workbuddy 4.3.4 → 4.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +279 -47
- package/lib/{host-heartbeat-DkdNZKKY.js → host-heartbeat-CBFAiYPg.js} +32 -8
- package/lib/index.d.ts +4 -3
- package/lib/index.js +57 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.4.2 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **DSH 0.2.1-alpha.2 下 workbuddy 模型工具调用全失效:把 pi-ai 主版本 1 判为 modern。**(PR #52,issues #24/#25/#26 的同源延伸)
|
|
8
|
+
- **现象**:DSH `0.2.1-alpha.2` 起,**所有** workbuddy / workbuddy-global 模型都无法调用工具(本地模型与 deepseek 账号组不受影响),且**界面上没有任何报错**。会话事件流停在「只有思考块」→「整轮 completed」,全程没有 `tool-call` 事件;模型有时还会把 DSML 标记当**正文**吐出来。
|
|
9
|
+
- **根因**:`src/pi-ai-runtime.ts` 的 `generationOf()` 用 `/^0\.(\d+)\./u` 解析版本,**只认 `0.x`**。DSH 0.2.1-alpha.2 随包的是 `@earendil-works/pi-ai@1.0.2`,`'1.0.2'` 匹配不到 → 返回 `'legacy'`。`legacy` 会让 `withLegacyContext()` 用 `adaptLegacyPiAiContext()` 把上下文折成 pi-ai **0.85** 形状:system 消息抽出去当 `systemPrompt`,工具声明搬到**顶层 `tools`**。而 pi-ai 1.x 从 `context.messages` 里 system 消息的 `toolsAdded` 读工具(`resolveTranscriptTools` / `getCurrentTools`),**不看顶层 `tools`**——请求里一个工具声明都没有,模型自然发不出 `toolCall`。国内网关容忍无 system 的请求,模型就把 DSML 写进正文。更隐蔽的是 shim 的 DSML 恢复也一并失效(`gate.declaredNames` 为空 → 整段禁用),所以整套缺陷**完全静默**。
|
|
10
|
+
- **为什么在作者 checkout 上不复现**:源码注释里写着 "this checkout resolves 0.85.1 today"——解析到 0.85.1 时 `legacy` 分支是**正确**的。另外 `doctor` 在宿主**之外**跑会显示 `modern`,那是 `resolvedLocally: false` 的默认值,与宿主内的真实判定无关。
|
|
11
|
+
- **修法**:只特判**主版本 1**,`>= 87` 的 minor 判定保持不变。已逐包对照 **pi-ai 0.87.1、1.0.2、1.1.0、1.2.0** 四个版本:三者的 `openai-completions` 都从 `context.messages` 调 `resolveTranscriptTools()`,`getCurrentTools()` 都读 system 消息的 `toolsAdded`/`toolsRemoved`,契约一致。**刻意是 `=== 1` 而不是 `>= 1`**:未核验未来 2.x 的上下文契约,不替未验证的版本做兼容承诺;测试里 `2.0.0` 明确断言仍为 `legacy`。
|
|
12
|
+
- **顺带记录的边界事实**(本版**未**改动 peer 范围):宿主 `dsh-app-boot` 的 `evaluatePluginCompatibility()` 只校验 `@deepseek-ai/dsh*` 命名空间,所以 `"@earendil-works/pi-ai": ">=0.85.0 <0.88.0"` 这条 peer **从不参与校验**,插件会对着越界的 1.x 静默运行。peer 范围的取舍留给维护者单独决定。
|
|
13
|
+
- **守卫**:`tests/pi-ai-runtime.spec.ts` 的版本→代际用例改为数据驱动,并用临时目录里伪造的 `package.json` 驱动 `piAiRuntimeFor()`,**不依赖本机解析到哪个版本**,因此在解析到 0.85.1 的机器上也能跑。覆盖 `0.85.0/0.85.1/0.86.3 → legacy`、`0.87.0/0.87.1/1.0.0/1.0.2/1.1.0 → modern`、`2.0.0 → legacy`、不可解析版本保守判 `legacy`。变异验证(还原修复)后恰好 4 个 1.x 用例变红,其余 13 个不变。
|
|
14
|
+
|
|
15
|
+
- **上游流被截断时,恢复层把补写的 `[DONE]` 粘在半截帧之后,客户端 SSE 解析崩溃(PI_AI_ERROR)。**(PR #54,issue #53)
|
|
16
|
+
- **现象**:对话中途偶发整轮直接失败,报错文本 `Unexpected non-whitespace character after JSON at position 319 (line 2 column 1)`,宿主归到兜底码 `PI_AI_ERROR`。该码**不在宿主的原地重试集**里(只认 `EMPTY_RESPONSE`/`RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`),所以一旦出现,这一轮直接结束、不重试——用户看到的就是「突然断了」。已确认与具体模型、工作区无关,且**紧跟在一次 transport 故障之后**。
|
|
17
|
+
- **根因**:是恢复层自己制造了非法的 SSE 帧,不是上游发的。两处叠加——① `consume()` 把流尾巴的残余帧直接透传,不补帧分隔空行;② `finish()` 补写合成终止帧时,没有确保前面有帧分隔。于是客户端实际收到的是**同一个 SSE 事件里的两条 `data:` 行**(中间只有一个 `\n`,不是空行)。openai SDK 的 `SSEDecoder` 会把一个事件内的多条 `data:` 行用 `\n` 拼起来再解析,拼出来就是「一个完整 JSON + 换行 + 第二个 JSON」,`JSON.parse` 在第二行行首抛错。**报错位置恰好是 319,因为第一段 chunk 的长度正好是 318**——这不是巧合。
|
|
18
|
+
- **修法**:把「每个帧必须自带空行终止符」变成不变量,而不是在 `finish()` 里特判——`RecoveryStream` 有 6 处直接 `res.write`,逐个补分隔容易漏,而 `handleFrame` 里 JSON 解析失败的原帧透传路径同样会写出不带空行的帧。现在类内所有帧写出收敛到单一出口 `emitFrame()`,由它补齐帧尾;`consume()` 对 `carry` 剩下的半帧先归一化再交给 `handleFrame`。**缺换行和缺空行是两种情形**:前者补一个 `\n` 只结束「行」不结束「事件」,必须补 `\n\n` 才能让随后的 `[DONE]` 不粘上去。
|
|
19
|
+
- **顺带修掉一个静默丢内容**:`finish()` **不**补 `[DONE]` 时,半截帧没有空行收尾,SDK 永远等不到分隔,最后那一小段内容被静默丢弃。归一化帧尾后这段内容能正常 dispatch 出来。
|
|
20
|
+
- **不影响普通路径**:`push()` 切出的帧本就带终止符,经 `emitFrame` 原样通过,仍是 byte-for-byte 透传——「帧顺序永不改变」的既有承诺不受影响。
|
|
21
|
+
- **守卫**:新增 `tests/issue53-sse-terminator.spec.ts`,断言在**字节层**:按 SSE 规范切事件,每个事件内多条 `data:` 行拼接后必须是单个合法 JSON。缺陷本质是字节粘连,只有字节级断言能抓住它。变异验证(还原两处修复)后 3 个缺陷用例全部变红,「完整流字节不变」的守卫用例保持绿色——说明没有过度归一化。
|
|
22
|
+
|
|
23
|
+
- **保存/删除/导入时的忙碌遮罩:改用宿主主题令牌,spinner 换成宿主同款弧线。**
|
|
24
|
+
- **现象**:遮罩卡片是一块近黑的板子,上面的字(尤其是副标题「完成后列表会立即刷新。」)几乎看不清;spinner 的转法也和外壳里其他转圈明显不同拍。
|
|
25
|
+
- **根因**:三处都出在自造值上。① 卡片底色用 `--dsw-alias-bg-primary`,而这个变量在宿主的令牌集里**根本不存在**(宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay` 等),于是一路回退到硬编码的近黑 `#1b1c20`。② 遮罩底色写死 `rgba(0,0,0,.38)`,既不跟随主题、也没有宿主的模糊。③ 副标题用 `label-tertiary`——那是宿主最暗的一档(亮色 `neutral-bluish-600`、暗色 `neutral-bluish-400`),给元数据用的,不该承载一句要等的人必须读的话。
|
|
26
|
+
- **修法**:卡片改用宿主给弹层用的 `--dsw-alias-bg-layer-2`(亮色白 / 暗色 `neutral-bluish-850`,各自成立);遮罩改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`(亮 `.24` / 暗 `.5`)+ `--dsw-mask-blur`,与外壳里其他遮罩一致;副标题改用 `label-secondary`。阴影也从手写的 `0 8px 28px rgba(0,0,0,.35)` 换成宿主的 `--dsw-elevation-prominent`。
|
|
27
|
+
- **spinner**:原本是 18px 的 CSS border 环、`.7s` 匀速转,比宿主自己的状态转圈(`_dsh-state-dot-spin`,SVG 弧线 + dash 呼吸、`1.5s`)快了近一倍。比起形状本身,**这个速度差才是它看着廉价的主因**。现在改为同款弧线几何与 `1.5s` 节奏,`currentColor` 继承所在层级,reduced-motion 降级为静态弧线(与宿主降级方式一致),并补 `aria-hidden` 免得 `role=status` 之外多报一次。
|
|
28
|
+
- **顺带记录一个会静默损坏产物的坑**:`WORKBUDDY_CARD_CSS` 是模板字符串,注释里写反引号会**提前截断整个样式表**,而 typecheck 与 build **都不报错**(截断后的尾部仍是合法 TS,被当成一堆散落的表达式语句)。本次改注释时真的踩到了。新增一条守卫:按顶层语句数断言(正常 2 条变量声明;一旦截断会炸成几十条表达式语句)。变异验证:塞回一个反引号后该守卫变红,恢复后全绿。
|
|
29
|
+
- **守卫**:`tests/client-host-contract.spec.ts` 由 8 例增至 10 例。
|
|
30
|
+
|
|
31
|
+
### Docs
|
|
32
|
+
|
|
33
|
+
- 无。本版含上述三处修复与配套测试。
|
|
34
|
+
|
|
35
|
+
## 4.4.1 (2026-10-12)
|
|
36
|
+
|
|
37
|
+
### Fixes
|
|
38
|
+
|
|
39
|
+
- **登录成功后「正在添加账号…」的遮罩不再无限转圈;导入的遮罩同样有上限。**(issue #51)
|
|
40
|
+
- **现象**:报告者描述「登录成功后这里会转很久,等一分钟也没等完,每次都是直接退出 DSH 重新打开」,截图里账号表**已经有三条账号、其中一条正在服务**,而遮罩仍停在「正在添加账号…|完成后列表会立即刷新。」。这两个事实合起来就是全部分析:凭据在 Host 答 `done` **之前**就已写入,所以表格有数据说明**登录本身是成功的**,卡住的是它的下一步。
|
|
41
|
+
- **根因**:`OAuthSignIn` 的 `done` 分支里,`setPhase({ kind: 'syncing' })` 之后跟着一句 `await onAdded?.()`,而遮罩的显示条件正是 `phase.kind === 'syncing'`。也就是说**遮罩唯一的退出条件是一个 promise 落地**——既没有超时,也没有 `catch`。重读(卡片那次同源 usage 请求)只要永不 settle,`phase` 就永远停在 `syncing`。上一段的 `waiting` 阶段**有**自己的本地超时,`syncing` 两者都没有,这是本缺陷的形状。
|
|
42
|
+
- **为什么代码评审看不见**:这是本次修复中最值得记下的一点。`phase` 是轮询 effect 的依赖项,`setPhase({ kind: 'syncing' })` 会让该 effect 重跑,React **先执行上一次的 cleanup**,而 cleanup 会把旧闭包的 `cancelled` 置为 `true`。React 在 `await` 恢复之前就提交了这次 cleanup,所以**恢复后总会看到 `cancelled === true` 并直接 return**。于是——**只有当重读快到不与 React 提交相交时**(即最快的那条路径),它才在 cleanup 之前落地、通过守卫并显示成功行。**慢到跨过一次 React 提交的顺序(也就是报告者遇到的那种)反而必然失败。缺陷藏在它自己的常见路径背后**,这也是第一版「把竞态写在原处」的修法**实测无效**的原因(不是推断,是跑出来的)。
|
|
43
|
+
- **修法**:把这个等待搬进一个以 `phase` 为键的独立 effect。它的 cleanup 只在阶段**离开** `syncing` 时才跑,也就是在它已经做完事之后,因此不存在自我取消。新加的账号名走 `addedAccountName` state(而不是 ref),这样第二次登录(再添一个账号)会正确地重新计时,而不是复用上一轮的闭包值。
|
|
44
|
+
- **两个出口都报成功,且刻意不做区分**:账号**确实**已经添加(Host 在答 `done` 之前就存好了凭据),所以诚实的说法是成功;超时只是不让屏幕无限期地声称相反。真正失败的 usage 路由仍由卡片自己以 `refreshError` 如实报告——那部分数据的拥有者是卡片,不是这里。
|
|
45
|
+
- **导入路径一并设上限**:`AccountTransfer` 的 `await onChanged?.()` 是同一形态、同一个遮罩,唯一的区别是它没有把 `phase` 放进任何 effect 依赖,所以原地加超时即可生效。两条路径共用同一个 `settlesWithin` 实现,正是为了不再各自漂移。**一个上限值 `5_000ms`**:远长于一次健康的回环重读,又远短于轮询节奏给人的预期。
|
|
46
|
+
- **守卫**:新增 `tests/issue51-sync-veil.spec.tsx`(5 例)。断言的是**可观察结果**而不是实现细节——重读仍在 pending 时遮罩必须已经落下、成功行必须已经出现;快路径不得因此被拖到超时;重读**抛错**按成功处理(它若逃逸进轮询的 `catch` 会重试轮询并再次报告同一个已添加的账号);只在超时窗口内推进时钟时遮罩必须仍在。
|
|
47
|
+
- **变异验证**:把 `src/client/OAuthSignIn.tsx` 改回原来的 `await onAdded?.()` 并删掉新 effect(即完全恢复旧逻辑),5 例中**3 例变红**;改回修复版 5 例全绿。这条记录有意写下来,因为**第一版修法本身是无效的**——只有经过这次还原才确认了守卫真的能抓住它。
|
|
48
|
+
- **测试基建注意**:`tests/pool-render-helpers.tsx` 的 `settle()` 用 `setTimeout` 排空异步工作,而假时钟会冻结它,所以本文件不能沿用 `settle()`(会一直挂到测试超时,表现为「找不到按钮」这类级联报错)。本文件改用 `act` + `advanceTimersByTimeAsync` 手动驱动时钟。另:登录轮询间隔是 `OAUTH_POLL_INTERVAL_MS = 2000`,**不是**卡片 usage 的一分钟——写作过程中一度按 60 秒推进时钟,等价于把整个流程跑完,因此这一处边界在测试里以注释标明。
|
|
49
|
+
- **未改动**:`window.open('')` 的占位窗口与「被拦则降级为弹窗内手动链接」这套行为一字未动。报告者说的「从来没有成功打开过浏览器」在该桌面外壳里 `window.open` 返回 `null`,走的是**已文档化的降级路径**,功能不损失(弹窗里始终有可复制的链接与「重新打开页面」按钮);把它改成别的行为属于另一个议题,不在本次范围内。
|
|
50
|
+
|
|
51
|
+
## 4.4.0 (2026-10-12)
|
|
52
|
+
|
|
53
|
+
### Features
|
|
54
|
+
|
|
55
|
+
- **「在对话框显示积分」开关移进「账号池」标题行,少占一行。**
|
|
56
|
+
- **改动**:该开关原本单独占一行(在 tab 栏下方),一个复选框换来一整行高度。现在它与「账号池」标题在同一行:标题在左,开关紧随其后,右侧仍是「当前账号」徽标。**账号池区块不会因此变高**。
|
|
57
|
+
- **没有采用「干脆删掉这行、只在别处留开关」的做法**:`AccountPool` 在宿主不提供 pool 状态时(旧宿主,或该路由缺字段)会 `return null`,把开关交给它会让**整个设置项随之消失**——一个设置控件不能因为另一个区块没渲染就不可达。因此有两条渲染点,**同一时刻只有一条生效**:有 pool 时进标题行,没有 pool 时回落到它自己那行(`dsm-workbuddy-credits-switch-row`,间距与移动前一致)。守卫按**祖先关系**断言(开关在 `.dsm-workbuddy-pool-head` 之内,且回落行不存在),而不是只数复选框个数——两条同时渲染也能通过「存在性」断言,却把省下的那一行又还了回去。
|
|
58
|
+
- **开关不因区域被关闭而隐藏**:区域关掉时它照旧渲染为**禁用**状态。藏起来会让用户看不到「为什么侧边栏没有那行」,而卡片自己的提示指向的是供应商复选框,不是一个凭空消失的控件。
|
|
59
|
+
- **文案随位置微调(不是为了好看)**:标签从「在对话框显示 WB CN 积分 / Show WB CN credits in the composer」缩到「对话框显示 WB CN 积分 / WB CN credits in composer」,省掉的是「在」与「Show」这类可以由上下文承接的词,**区域区分保留**——开关现在与「账号池」同排、不再紧贴 tab 栏,丢掉区域名会让 CN 与国际版两个开关在文案上无法区分。完整解释仍在 `title` 提示里,一字未减。
|
|
60
|
+
- **徽标改用 `margin-left:auto` 而非整行的 `space-between`**:三个子元素下 `space-between` 会把开关甩到中间而不是贴着标题;只有两个子元素(无开关)时 `margin-left:auto` 让徽标仍留在右边缘,与改动前一致。
|
|
61
|
+
- **联动清理**:`AccountPool` 新增 `headerExtra` 插槽(与既有 `accountTools` 同款「传现成节点」约定),开关的写入路径、禁用规则与「与卡片其他区域写入者串行」的逻辑**全部留在卡片里**——它们依赖设置 scope 与保存状态,搬进池区块等于把同一份写入与串行化逻辑再实现一遍。
|
|
62
|
+
- **守卫**:`tests/card-credits-switch.spec.tsx` 由 11 例增至 14 例,新增「开关在账号池标题行内且回落行不存在」「没有 pool 时回落到自己那行」「标签仍带区域名」三条。**变异验证**:把开关改回「永远单独成行」→ 第一条变红(`expected null not to be null`,即标题行里找不到开关)。
|
|
63
|
+
|
|
3
64
|
## 4.3.4 (2026-10-12)
|
|
4
65
|
|
|
5
66
|
### Fixes
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, 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, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-CBFAiYPg.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
|
@@ -668,6 +668,45 @@ window.__ModuleLoader__.load({
|
|
|
668
668
|
//#endregion
|
|
669
669
|
//#region src/client/BusyOverlay.tsx
|
|
670
670
|
/**
|
|
671
|
+
* The shared busy veil's spinner: an arc that breathes while it turns.
|
|
672
|
+
*
|
|
673
|
+
* Deliberately the host's own state-spinner shape (dsh's `_dsh-state-dot-spin`)
|
|
674
|
+
* rather than a border-ring: a two-tone CSS ring at .7s read as a loading
|
|
675
|
+
* indicator from another decade and was out of step with every spinner in the
|
|
676
|
+
* shell. The motion lives in CSS (`dsm-workbuddy-veil-spin` /
|
|
677
|
+
* `-dash`), so this element only describes the geometry; `currentColor` lets it
|
|
678
|
+
* inherit whatever tier it sits in.
|
|
679
|
+
*
|
|
680
|
+
* `aria-hidden` because the veil's `role="status"` already announces the
|
|
681
|
+
* message — a second announcement from a decorative arc is noise.
|
|
682
|
+
*/
|
|
683
|
+
function VeilSpinner() {
|
|
684
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
685
|
+
className: "dsm-workbuddy-veil-spinner",
|
|
686
|
+
viewBox: "0 0 20 20",
|
|
687
|
+
"aria-hidden": "true",
|
|
688
|
+
focusable: "false",
|
|
689
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
690
|
+
className: "dsm-workbuddy-veil-spinner-track",
|
|
691
|
+
cx: "10",
|
|
692
|
+
cy: "10",
|
|
693
|
+
r: "8",
|
|
694
|
+
fill: "none",
|
|
695
|
+
stroke: "currentColor",
|
|
696
|
+
strokeWidth: "2"
|
|
697
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
698
|
+
className: "dsm-workbuddy-veil-spinner-arc",
|
|
699
|
+
cx: "10",
|
|
700
|
+
cy: "10",
|
|
701
|
+
r: "8",
|
|
702
|
+
fill: "none",
|
|
703
|
+
stroke: "currentColor",
|
|
704
|
+
strokeWidth: "2",
|
|
705
|
+
strokeLinecap: "round"
|
|
706
|
+
})]
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
671
710
|
* The veil. Deliberately NOT a modal dialog: it carries no focusable control
|
|
672
711
|
* and no dismissal — there is nothing for the user to decide, only a step to
|
|
673
712
|
* wait through. `aria-busy` plus a polite live region state that to screen
|
|
@@ -683,10 +722,7 @@ window.__ModuleLoader__.load({
|
|
|
683
722
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
684
723
|
className: "dsm-workbuddy-veil-card",
|
|
685
724
|
children: [
|
|
686
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
687
|
-
className: "dsm-workbuddy-veil-spinner",
|
|
688
|
-
"aria-hidden": "true"
|
|
689
|
-
}),
|
|
725
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(VeilSpinner, {}),
|
|
690
726
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
691
727
|
className: "dsm-workbuddy-veil-text",
|
|
692
728
|
children: message
|
|
@@ -1177,7 +1213,7 @@ window.__ModuleLoader__.load({
|
|
|
1177
1213
|
* section implying the feature exists.
|
|
1178
1214
|
*/
|
|
1179
1215
|
function AccountPool(props) {
|
|
1180
|
-
const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
|
|
1216
|
+
const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools, headerExtra } = props;
|
|
1181
1217
|
const [draft, setDraft] = (0, react.useState)(null);
|
|
1182
1218
|
const [saving, setSaving] = (0, react.useState)(false);
|
|
1183
1219
|
const [saveError, setSaveError] = (0, react.useState)(void 0);
|
|
@@ -1614,7 +1650,7 @@ window.__ModuleLoader__.load({
|
|
|
1614
1650
|
visible: true,
|
|
1615
1651
|
message: t("row.poolSaving"),
|
|
1616
1652
|
hint: t("row.veilHintList")
|
|
1617
|
-
}) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
|
|
1653
|
+
}) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), headerExtra === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-head-extra" }, headerExtra), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
|
|
1618
1654
|
className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
|
|
1619
1655
|
value: currentAccount?.accountId ?? "",
|
|
1620
1656
|
disabled: selectingAccount === true,
|
|
@@ -1893,7 +1929,53 @@ window.__ModuleLoader__.load({
|
|
|
1893
1929
|
function elapsedSeconds(sinceMs, nowMs) {
|
|
1894
1930
|
return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
|
|
1895
1931
|
}
|
|
1896
|
-
/**
|
|
1932
|
+
/**
|
|
1933
|
+
* How long the `syncing` phase may hold the veil before it lets go (issue #51).
|
|
1934
|
+
*
|
|
1935
|
+
* Shorter than a poll interval would be pointless; a few seconds is far longer
|
|
1936
|
+
* than a healthy loopback re-read takes, and anything past that is
|
|
1937
|
+
* indistinguishable from "never".
|
|
1938
|
+
*
|
|
1939
|
+
* What sits below this decision is an asymmetry worth spelling out. The
|
|
1940
|
+
* `waiting` phase has its own local deadline (see the waiting-clock effect), so
|
|
1941
|
+
* a login whose `loginId` died still ends. The `syncing` phase reached the veil
|
|
1942
|
+
* and awaited the card's re-read with NEITHER a deadline NOR a `catch` — and the
|
|
1943
|
+
* veil's visibility is `phase.kind === 'syncing'` and nothing else. So a re-read
|
|
1944
|
+
* that never settled held the veil up forever, while the credential was already
|
|
1945
|
+
* in the vault: reopening the card (or force-quitting DSH) showed the account,
|
|
1946
|
+
* which is exactly the "half usable" report — sign-in succeeded, the screen
|
|
1947
|
+
* never said so.
|
|
1948
|
+
*/
|
|
1949
|
+
const SYNC_SETTLE_TIMEOUT_MS = 5e3;
|
|
1950
|
+
/**
|
|
1951
|
+
* Await `work` but give up after `timeoutMs`; resolves `false` on timeout.
|
|
1952
|
+
*
|
|
1953
|
+
* The timer is cleared in a `finally`, so the common case (the re-read settles
|
|
1954
|
+
* first) leaves no pending timer holding the event loop — the reason this is a
|
|
1955
|
+
* race with an explicit cleanup rather than `Promise.race` alone.
|
|
1956
|
+
*
|
|
1957
|
+
* The timeout arm never rejects: a re-read that FAILS is not a failed sign-in,
|
|
1958
|
+
* and the caller must not be able to confuse the two. Rejection and timeout
|
|
1959
|
+
* stand for the same thing here — "the list did not prove it refreshed" — so
|
|
1960
|
+
* they take the same path.
|
|
1961
|
+
*
|
|
1962
|
+
* EXPORTED because `AccountTransfer`'s veil awaits the same card re-read and had
|
|
1963
|
+
* the same unbounded wait; sharing one implementation is what stops the two
|
|
1964
|
+
* veils from drifting apart again.
|
|
1965
|
+
*/
|
|
1966
|
+
async function settlesWithin(work, timeoutMs) {
|
|
1967
|
+
let timer;
|
|
1968
|
+
try {
|
|
1969
|
+
return await Promise.race([Promise.resolve(work).then(() => true, () => false), new Promise((resolve) => {
|
|
1970
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1971
|
+
})]);
|
|
1972
|
+
} finally {
|
|
1973
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Copy the sign-in URL to the clipboard; true on success.
|
|
1978
|
+
*/
|
|
1897
1979
|
async function copyUrl(url) {
|
|
1898
1980
|
try {
|
|
1899
1981
|
await navigator.clipboard.writeText(url);
|
|
@@ -1913,6 +1995,17 @@ window.__ModuleLoader__.load({
|
|
|
1913
1995
|
const [copied, setCopied] = (0, react.useState)(false);
|
|
1914
1996
|
const [pollTick, setPollTick] = (0, react.useState)(0);
|
|
1915
1997
|
const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
|
|
1998
|
+
/**
|
|
1999
|
+
* The account the Host just added, held for the sync effect.
|
|
2000
|
+
*
|
|
2001
|
+
* The poll that answers `done` names the account; the effect that ends the
|
|
2002
|
+
* `syncing` phase needs that name to write the success line, and it cannot
|
|
2003
|
+
* read it off the phase because the phase is what it is about to replace.
|
|
2004
|
+
* State rather than a ref: the effect is keyed on it, so a second sign-in
|
|
2005
|
+
* (adding a second account) re-arms the deadline properly instead of reusing
|
|
2006
|
+
* a stale closure value.
|
|
2007
|
+
*/
|
|
2008
|
+
const [addedAccountName, setAddedAccountName] = (0, react.useState)("");
|
|
1916
2009
|
const mounted = (0, react.useRef)(true);
|
|
1917
2010
|
(0, react.useEffect)(() => {
|
|
1918
2011
|
mounted.current = true;
|
|
@@ -1983,13 +2076,7 @@ window.__ModuleLoader__.load({
|
|
|
1983
2076
|
kind: "syncing",
|
|
1984
2077
|
accountName
|
|
1985
2078
|
});
|
|
1986
|
-
|
|
1987
|
-
if (cancelled || !mounted.current) return;
|
|
1988
|
-
setPhase({
|
|
1989
|
-
kind: "finished",
|
|
1990
|
-
message: "added",
|
|
1991
|
-
accountName
|
|
1992
|
-
});
|
|
2079
|
+
setAddedAccountName(accountName);
|
|
1993
2080
|
}
|
|
1994
2081
|
} catch {
|
|
1995
2082
|
setPollTick((tick) => tick + 1);
|
|
@@ -2007,6 +2094,69 @@ window.__ModuleLoader__.load({
|
|
|
2007
2094
|
region
|
|
2008
2095
|
]);
|
|
2009
2096
|
/**
|
|
2097
|
+
* End the `syncing` phase — on the re-read, or on the deadline, whichever
|
|
2098
|
+
* comes first (issue #51).
|
|
2099
|
+
*
|
|
2100
|
+
* ## Why this is a separate effect and not an `await` in the poll
|
|
2101
|
+
*
|
|
2102
|
+
* The first attempt at this fix put the race inside the poll's `done` branch:
|
|
2103
|
+
*
|
|
2104
|
+
* setPhase({ kind: 'syncing', accountName })
|
|
2105
|
+
* await settlesWithin(onAdded?.(), SYNC_SETTLE_TIMEOUT_MS)
|
|
2106
|
+
* if (cancelled || !mounted.current) return
|
|
2107
|
+
* setPhase({ kind: 'finished', … })
|
|
2108
|
+
*
|
|
2109
|
+
* and it did not work — measured, not assumed. `phase` is a dependency of the
|
|
2110
|
+
* poll effect, so `setPhase({kind:'syncing'})` re-runs that effect, and React
|
|
2111
|
+
* runs the PREVIOUS cleanup first. That cleanup sets the old closure's
|
|
2112
|
+
* `cancelled = true`. React commits the cleanup before the `await` it is
|
|
2113
|
+
* inside ever resumes, so the resume always sees `cancelled === true` and
|
|
2114
|
+
* returns: the veil stayed up exactly as it had before.
|
|
2115
|
+
*
|
|
2116
|
+
* This also explains why the defect was invisible in review. A FAST re-read
|
|
2117
|
+
* resolves inside the same microtask batch, before React commits the cleanup,
|
|
2118
|
+
* so it passes the guard and the success line appears. Only a re-read slow
|
|
2119
|
+
* enough to cross a React commit — i.e. the reported case — loses the race
|
|
2120
|
+
* against its own cleanup. The bug hid behind its own common path.
|
|
2121
|
+
*
|
|
2122
|
+
* Running the wait in an effect keyed on the phase sidesteps the whole
|
|
2123
|
+
* interaction: this effect's own cleanup runs when the phase LEAVES
|
|
2124
|
+
* `syncing`, which is after it has already done its job, so there is no
|
|
2125
|
+
* self-cancellation to lose.
|
|
2126
|
+
*
|
|
2127
|
+
* ## The two outcomes
|
|
2128
|
+
*
|
|
2129
|
+
* Both end in `finished`/`added` and the difference is deliberately not shown
|
|
2130
|
+
* to the user. The account IS added — the Host stored the credential before it
|
|
2131
|
+
* answered the poll — so the honest report is success either way; the deadline
|
|
2132
|
+
* only stops the screen from claiming otherwise indefinitely. (`refreshUsage`
|
|
2133
|
+
* already surfaces a failed re-read as `refreshError` on the card itself, so
|
|
2134
|
+
* a genuinely broken usage route is still reported, by the component that
|
|
2135
|
+
* owns that data.)
|
|
2136
|
+
*/
|
|
2137
|
+
(0, react.useEffect)(() => {
|
|
2138
|
+
if (phase.kind !== "syncing") return;
|
|
2139
|
+
let cancelled = false;
|
|
2140
|
+
const finish = () => {
|
|
2141
|
+
if (cancelled || !mounted.current) return;
|
|
2142
|
+
setPhase({
|
|
2143
|
+
kind: "finished",
|
|
2144
|
+
message: "added",
|
|
2145
|
+
accountName: addedAccountName
|
|
2146
|
+
});
|
|
2147
|
+
};
|
|
2148
|
+
settlesWithin(Promise.resolve(onAdded?.()), SYNC_SETTLE_TIMEOUT_MS).then(finish);
|
|
2149
|
+
const timer = setTimeout(finish, SYNC_SETTLE_TIMEOUT_MS);
|
|
2150
|
+
return () => {
|
|
2151
|
+
cancelled = true;
|
|
2152
|
+
clearTimeout(timer);
|
|
2153
|
+
};
|
|
2154
|
+
}, [
|
|
2155
|
+
addedAccountName,
|
|
2156
|
+
onAdded,
|
|
2157
|
+
phase
|
|
2158
|
+
]);
|
|
2159
|
+
/**
|
|
2010
2160
|
* Open a placeholder window WITHOUT `noopener`, and deliberately so.
|
|
2011
2161
|
*
|
|
2012
2162
|
* `noopener` is the correct feature for a link whose destination never needs
|
|
@@ -2227,6 +2377,17 @@ window.__ModuleLoader__.load({
|
|
|
2227
2377
|
*
|
|
2228
2378
|
* @module dsh-connect-workbuddy/client/AccountTransfer
|
|
2229
2379
|
*/
|
|
2380
|
+
/**
|
|
2381
|
+
* How long the veil may wait on the card's re-read before it comes down.
|
|
2382
|
+
*
|
|
2383
|
+
* Same ceiling and same reasoning as the sign-in path's
|
|
2384
|
+
* `SYNC_SETTLE_TIMEOUT_MS` (issue #51): the veil is the only feedback this
|
|
2385
|
+
* operation gives, so its exit condition must not be a promise that can stay
|
|
2386
|
+
* pending. The `refreshing` phase has no other way out — no deadline, no
|
|
2387
|
+
* failure branch — so an unsettled re-read left the operator staring at
|
|
2388
|
+
* 「正在导入…」 over an import that had already succeeded.
|
|
2389
|
+
*/
|
|
2390
|
+
const REFRESH_SETTLE_TIMEOUT_MS = 5e3;
|
|
2230
2391
|
/** Build the default export file name: workbuddy-accounts-YYYY-MM-DD.json. */
|
|
2231
2392
|
function exportFileName() {
|
|
2232
2393
|
const pad = (value) => String(value).padStart(2, "0");
|
|
@@ -2385,7 +2546,7 @@ window.__ModuleLoader__.load({
|
|
|
2385
2546
|
kind: "refreshing",
|
|
2386
2547
|
message
|
|
2387
2548
|
});
|
|
2388
|
-
await onChanged?.();
|
|
2549
|
+
await settlesWithin(Promise.resolve(onChanged?.()), REFRESH_SETTLE_TIMEOUT_MS);
|
|
2389
2550
|
if (!mounted.current) return;
|
|
2390
2551
|
setPhase({
|
|
2391
2552
|
kind: "done",
|
|
@@ -2665,10 +2826,17 @@ window.__ModuleLoader__.load({
|
|
|
2665
2826
|
.dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
|
|
2666
2827
|
.dsm-workbuddy-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
2667
2828
|
.dsm-workbuddy-tab-switch input:disabled{opacity:.4;cursor:default}
|
|
2668
|
-
/* One sidebar-credit switch, belonging to the ACTIVE tab's region. It
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2829
|
+
/* One sidebar-credit switch, belonging to the ACTIVE tab's region. It rides in
|
|
2830
|
+
the account-pool heading row (dsm-workbuddy-pool-head-extra) so one
|
|
2831
|
+
checkbox no longer costs a full line of vertical space; the tab bar already
|
|
2832
|
+
says which region is in scope, so it still reads as this tab's property
|
|
2833
|
+
rather than as a shared pair of settings. No vertical margin: the heading row
|
|
2834
|
+
owns the spacing. */
|
|
2835
|
+
.dsm-workbuddy-credits-switch{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
2836
|
+
/* The standalone fallback site: used only when the account-pool section did not
|
|
2837
|
+
render (no Host pool state), so there is no heading row to share. This is the
|
|
2838
|
+
spacing the control carried before it moved beside that heading. */
|
|
2839
|
+
.dsm-workbuddy-credits-switch-row{display:flex;align-items:center;margin:8px 0 2px}
|
|
2672
2840
|
.dsm-workbuddy-credits-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
2673
2841
|
.dsm-workbuddy-credits-switch input:disabled{opacity:.4;cursor:default}
|
|
2674
2842
|
.dsm-workbuddy-tab-off{opacity:.55}
|
|
@@ -2754,8 +2922,15 @@ window.__ModuleLoader__.load({
|
|
|
2754
2922
|
|
|
2755
2923
|
/* ---- Account pool ---- */
|
|
2756
2924
|
.dsm-workbuddy-pool{display:flex;flex-direction:column;gap:12px;margin:0;padding:16px 0 4px;border-top:1px solid var(--dsw-alias-border-l2,#36373b)}
|
|
2757
|
-
|
|
2758
|
-
|
|
2925
|
+
/* Title, then the inline extra (the sidebar-credit switch), then the
|
|
2926
|
+
serving-account badge on the far edge. The badge is pushed right with
|
|
2927
|
+
margin-left:auto rather than the row using space-between: with three
|
|
2928
|
+
children the latter would strand the switch in the middle instead of binding
|
|
2929
|
+
it to the title, and with only two (no switch) margin-left:auto keeps the
|
|
2930
|
+
badge on the right edge exactly as before. */
|
|
2931
|
+
.dsm-workbuddy-pool-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
2932
|
+
.dsm-workbuddy-pool-head-extra{display:inline-flex;align-items:center}
|
|
2933
|
+
.dsm-workbuddy-pool-current-badge{margin-left:auto;padding:2px 10px;border-radius:999px;background:rgba(86,134,254,.16);color:var(--dsw-alias-brand-primary,#5686fe);font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
|
|
2759
2934
|
/* Sits under the header block, before the batch buttons: account discovery is
|
|
2760
2935
|
the first step of using the pool, so it reads above the actions it enables. */
|
|
2761
2936
|
/* Manual-mode account picker: shares the settings row layout (.pool-set), and
|
|
@@ -2916,17 +3091,48 @@ window.__ModuleLoader__.load({
|
|
|
2916
3091
|
"here is why nothing moved yet". Fixed positioning so it covers the card
|
|
2917
3092
|
regardless of which section started the operation, and a semi-transparent
|
|
2918
3093
|
backdrop so the table stays visible underneath — the user is watching a
|
|
2919
|
-
specific row change, and hiding it would defeat the point.
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
.
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
3094
|
+
specific row change, and hiding it would defeat the point.
|
|
3095
|
+
|
|
3096
|
+
The mask and the card surface are the HOST's own tokens, not this file's
|
|
3097
|
+
invention. --dsw-alias-bg-mask-1 + --dsw-mask-blur is exactly what the
|
|
3098
|
+
host's own overlays use (see dsh-client-ui-settings-general's Dws9Sa_mask),
|
|
3099
|
+
so the veil now dims the way every other overlay in the shell does instead
|
|
3100
|
+
of laying its own black wash on top. The previous hardcoded rgba(0,0,0,.38)
|
|
3101
|
+
was tuned against neither theme: too weak next to the host's .24 mask in
|
|
3102
|
+
light mode, and it ignored the host's blur entirely.
|
|
3103
|
+
|
|
3104
|
+
bg-layer-2 (not bg-primary, which does not exist in the host's token set
|
|
3105
|
+
at all and therefore always fell through to this file's near-black #1b1c20)
|
|
3106
|
+
is the same surface the host puts behind its panels — that near-black fallback
|
|
3107
|
+
is what made the veil read as a black slab with unreadable text on it. */
|
|
3108
|
+
.dsm-workbuddy-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.32));backdrop-filter:var(--dsw-mask-blur);cursor:progress}
|
|
3109
|
+
/* --dsw-elevation-prominent is the host's own panel shadow (a hairline stroke
|
|
3110
|
+
plus two soft layers); the previous 0 8px 28px rgba(0,0,0,.35) was a
|
|
3111
|
+
hand-rolled drop shadow that read as a heavy grey box. */
|
|
3112
|
+
.dsm-workbuddy-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#0000001a);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:var(--dsw-elevation-prominent,0 8px 28px rgba(0,0,0,.35))}
|
|
3113
|
+
.dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#0f1115);text-align:center}
|
|
3114
|
+
/* label-secondary, not label-tertiary: this line carries the operation's
|
|
3115
|
+
detail and must stay legible. tertiary is the host's dimmest tier
|
|
3116
|
+
(neutral-bluish-600 on light, neutral-bluish-400 on dark) and is meant
|
|
3117
|
+
for metadata, not for a sentence the user has to read while waiting. */
|
|
3118
|
+
.dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center}
|
|
3119
|
+
/* The spinner proves the UI is alive when the row under it has not changed yet
|
|
3120
|
+
— which is precisely the confusion the veil exists to remove. It follows the
|
|
3121
|
+
host's own state spinner (dsh's _dsh-state-dot-spin): an SVG arc whose
|
|
3122
|
+
dash breathes while it turns, currentColor so it inherits the tier it sits
|
|
3123
|
+
in, and the same 1.5s beat. The previous 18px border-ring at .7s was roughly
|
|
3124
|
+
twice the host's speed and visibly out of step with every other spinner in
|
|
3125
|
+
the shell — that mismatch, more than the shape itself, is what made it look
|
|
3126
|
+
cheap.
|
|
3127
|
+
|
|
3128
|
+
Reduced-motion users get the static arc rather than a frozen ring, matching
|
|
3129
|
+
how the host degrades its own spinner. */
|
|
3130
|
+
.dsm-workbuddy-veil-spinner{width:20px;height:20px;color:var(--dsw-alias-label-secondary,#61666b);animation:dsm-workbuddy-veil-spin 1.5s linear infinite}
|
|
3131
|
+
.dsm-workbuddy-veil-spinner-track{opacity:.25}
|
|
3132
|
+
.dsm-workbuddy-veil-spinner-arc{stroke-dasharray:12 150;animation:dsm-workbuddy-veil-dash 1.5s ease-in-out infinite}
|
|
2928
3133
|
@keyframes dsm-workbuddy-veil-spin{to{transform:rotate(360deg)}}
|
|
2929
|
-
@
|
|
3134
|
+
@keyframes dsm-workbuddy-veil-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}to{stroke-dasharray:12 150;stroke-dashoffset:0}}
|
|
3135
|
+
@media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}.dsm-workbuddy-veil-spinner-arc{animation:none;stroke-dasharray:18 150;stroke-dashoffset:-3}}
|
|
2930
3136
|
`;
|
|
2931
3137
|
/**
|
|
2932
3138
|
* Composer credit readout styles, injected by the readout component itself.
|
|
@@ -3979,6 +4185,39 @@ window.__ModuleLoader__.load({
|
|
|
3979
4185
|
accountName: account.accountName
|
|
3980
4186
|
}))
|
|
3981
4187
|
})] });
|
|
4188
|
+
/**
|
|
4189
|
+
* Sidebar credit line switch — ONE control, for the ACTIVE tab's region only.
|
|
4190
|
+
*
|
|
4191
|
+
* The two regions are independent providers with independent accounts, so
|
|
4192
|
+
* each tab owns its own switch and they are never shown as a pair: this one
|
|
4193
|
+
* writes `regions.<activeRegion>.showCreditsInMainUi`, which decides only
|
|
4194
|
+
* whether THAT region's line appears in the sidebar. That matches how every
|
|
4195
|
+
* other control in this card is scoped to the selected tab (account, pool,
|
|
4196
|
+
* model list), and it means a CN-only user is never shown a switch for an
|
|
4197
|
+
* account they do not have. An earlier revision rendered both boxes side by
|
|
4198
|
+
* side under the tab bar regardless of the active tab; that read as one
|
|
4199
|
+
* shared setting and was replaced.
|
|
4200
|
+
*
|
|
4201
|
+
* RENDERED BESIDE THE POOL HEADING, not on its own row. It used to occupy a
|
|
4202
|
+
* full line of vertical space for a single checkbox; the pool heading already
|
|
4203
|
+
* had a title-left / badge-right row, so the control fits there without the
|
|
4204
|
+
* block growing. It is handed to `AccountPool` as a ready-made node purely so
|
|
4205
|
+
* the two can share a flex row — the write path, the disabled rules and the
|
|
4206
|
+
* serialization against the card's other region writers all stay here, where
|
|
4207
|
+
* the settings scope and the save state live.
|
|
4208
|
+
*/
|
|
4209
|
+
const creditsSwitch = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
4210
|
+
className: "dsm-workbuddy-credits-switch",
|
|
4211
|
+
title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
|
|
4212
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
4213
|
+
type: "checkbox",
|
|
4214
|
+
checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
|
|
4215
|
+
disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
|
|
4216
|
+
onChange: (event) => {
|
|
4217
|
+
toggleCredits(activeRegion, event.target.checked);
|
|
4218
|
+
}
|
|
4219
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
|
|
4220
|
+
});
|
|
3982
4221
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
3983
4222
|
className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
|
|
3984
4223
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
@@ -4063,18 +4302,6 @@ window.__ModuleLoader__.load({
|
|
|
4063
4302
|
className: "dsm-workbuddy-tab-off-notice",
|
|
4064
4303
|
children: t("row.tabOffNotice")
|
|
4065
4304
|
}) : null,
|
|
4066
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
4067
|
-
className: "dsm-workbuddy-credits-switch",
|
|
4068
|
-
title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
|
|
4069
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
4070
|
-
type: "checkbox",
|
|
4071
|
-
checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
|
|
4072
|
-
disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
|
|
4073
|
-
onChange: (event) => {
|
|
4074
|
-
toggleCredits(activeRegion, event.target.checked);
|
|
4075
|
-
}
|
|
4076
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
|
|
4077
|
-
}),
|
|
4078
4305
|
status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? status.status === "signed-in" && status.credentialRejected === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
4079
4306
|
className: "dsm-workbuddy-usage-hint",
|
|
4080
4307
|
children: t("row.reloginHint")
|
|
@@ -4131,11 +4358,16 @@ window.__ModuleLoader__.load({
|
|
|
4131
4358
|
},
|
|
4132
4359
|
selectingAccount: switchingAccount,
|
|
4133
4360
|
accountTools,
|
|
4361
|
+
...status.pool === void 0 ? {} : { headerExtra: creditsSwitch },
|
|
4134
4362
|
siblingBusy: saving || togglingRegion !== void 0,
|
|
4135
4363
|
onBusyChange: setPoolBusy,
|
|
4136
4364
|
onSaved: async () => await refreshUsage(activeRegion) !== void 0,
|
|
4137
4365
|
onRefresh: () => refreshUsage(activeRegion)
|
|
4138
4366
|
}),
|
|
4367
|
+
status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4368
|
+
className: "dsm-workbuddy-credits-switch-row",
|
|
4369
|
+
children: creditsSwitch
|
|
4370
|
+
}) : null,
|
|
4139
4371
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
4140
4372
|
className: "dsm-workbuddy-models",
|
|
4141
4373
|
"aria-label": t("row.modelsTitle"),
|
|
@@ -5234,8 +5466,8 @@ window.__ModuleLoader__.load({
|
|
|
5234
5466
|
"row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
|
|
5235
5467
|
"row.poolRowRemoving": "Removing the account…",
|
|
5236
5468
|
"row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
|
|
5237
|
-
"row.showCreditsCn": "
|
|
5238
|
-
"row.showCreditsGlobal": "
|
|
5469
|
+
"row.showCreditsCn": "WB CN credits in composer",
|
|
5470
|
+
"row.showCreditsGlobal": "WB AI credits in composer",
|
|
5239
5471
|
"row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
|
|
5240
5472
|
"row.showCreditsGlobalHint": "Show the international (WB AI) credit balance in the composer toolbar, but only while this session uses a WB AI model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
|
|
5241
5473
|
"composer.pointsCn": "WB CN",
|
|
@@ -5494,8 +5726,8 @@ window.__ModuleLoader__.load({
|
|
|
5494
5726
|
"row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
|
|
5495
5727
|
"row.poolRowRemoving": "正在删除账号…",
|
|
5496
5728
|
"row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
|
|
5497
|
-
"row.showCreditsCn": "
|
|
5498
|
-
"row.showCreditsGlobal": "
|
|
5729
|
+
"row.showCreditsCn": "对话框显示 WB CN 积分",
|
|
5730
|
+
"row.showCreditsGlobal": "对话框显示 WB AI 积分",
|
|
5499
5731
|
"row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
|
|
5500
5732
|
"row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
|
|
5501
5733
|
"composer.pointsCn": "WB CN",
|
|
@@ -3611,9 +3611,10 @@ var WorkBuddyCatalog = class {
|
|
|
3611
3611
|
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
3612
3612
|
* installs where NO local pi-ai exists and the module is only provided inside
|
|
3613
3613
|
* the DSH host — a static import there is a load-time crash. Resolution
|
|
3614
|
-
* itself does not need the module:
|
|
3615
|
-
*
|
|
3616
|
-
*
|
|
3614
|
+
* itself does not need the module: the manifest version and the
|
|
3615
|
+
* `normalizeContext` feature (present from 0.87 on, not before) agree across
|
|
3616
|
+
* `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
|
|
3617
|
+
* transcript, so the split is `>= 0.87` rather than "within 0.x".
|
|
3617
3618
|
*
|
|
3618
3619
|
* When resolution FAILS — the normal state of a market install probed outside
|
|
3619
3620
|
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
@@ -3695,13 +3696,36 @@ function readVersion(moduleFile) {
|
|
|
3695
3696
|
}
|
|
3696
3697
|
}
|
|
3697
3698
|
/**
|
|
3698
|
-
* The generation a manifest version belongs to.
|
|
3699
|
-
*
|
|
3700
|
-
*
|
|
3701
|
-
*
|
|
3699
|
+
* The generation a manifest version belongs to.
|
|
3700
|
+
*
|
|
3701
|
+
* `0.87+` consumes normalized transcripts natively; earlier releases want the
|
|
3702
|
+
* 0.85 shape, so within the declared peer range the split is exactly `0.87+`
|
|
3703
|
+
* versus earlier. An unreadable version conservatively reports `legacy` (and
|
|
3704
|
+
* doctor shows it as unreadable, so the guess is visible rather than silent).
|
|
3705
|
+
*
|
|
3706
|
+
* The 1.x line MUST land on `modern`, and it is not a hypothetical: DSH
|
|
3707
|
+
* 0.2.1-alpha.2 ships `@earendil-works/pi-ai@1.0.2`, while npm currently
|
|
3708
|
+
* publishes 1.1.0. Both 0.87.1 and 1.0.2/1.1.0 read tools from the same place
|
|
3709
|
+
* (`context.messages` → the leading system message's `toolsAdded`; see
|
|
3710
|
+
* pi-ai's `resolveTranscriptTools`). Matching only `0.<minor>.` classified
|
|
3711
|
+
* 1.0.2 as `legacy`, so `withLegacyContext` folded the transcript into the
|
|
3712
|
+
* 0.85 shape — which moves the tool declarations to a top-level `tools` field
|
|
3713
|
+
* pi-ai 1.x never reads. Every request then went upstream with NO tools
|
|
3714
|
+
* declared: no model could emit a `toolCall`, and the domestic gateway, which
|
|
3715
|
+
* tolerates a prompt-less body, had the model write DSML markup into the answer
|
|
3716
|
+
* text instead. The host's compatibility gate does not catch this: it only
|
|
3717
|
+
* evaluates peers in the `@deepseek-ai/dsh*` namespace, so an out-of-range
|
|
3718
|
+
* `@earendil-works/pi-ai` peer is never enforced.
|
|
3719
|
+
*
|
|
3720
|
+
* Deliberately match major 1 only. This establishes compatibility for the
|
|
3721
|
+
* observed 1.x line (including current 1.1.0) without assuming an uninspected
|
|
3722
|
+
* future major such as 2.x keeps the same transcript contract. The declared
|
|
3723
|
+
* peer range remains the plugin's supported-version boundary.
|
|
3702
3724
|
*/
|
|
3703
3725
|
function generationOf(version) {
|
|
3704
3726
|
if (version === void 0) return "legacy";
|
|
3727
|
+
const major = /^(\d+)\./u.exec(version);
|
|
3728
|
+
if (major !== null && Number(major[1]) === 1) return "modern";
|
|
3705
3729
|
const match = /^0\.(\d+)\./u.exec(version);
|
|
3706
3730
|
if (match === null) return "legacy";
|
|
3707
3731
|
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
@@ -3709,7 +3733,7 @@ function generationOf(version) {
|
|
|
3709
3733
|
//#endregion
|
|
3710
3734
|
//#region src/version.ts
|
|
3711
3735
|
/** The npm package version this build was produced from. */
|
|
3712
|
-
const WORKBUDDY_CONNECT_VERSION = "4.
|
|
3736
|
+
const WORKBUDDY_CONNECT_VERSION = "4.4.2";
|
|
3713
3737
|
//#endregion
|
|
3714
3738
|
//#region src/host-heartbeat.ts
|
|
3715
3739
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -1112,9 +1112,10 @@ declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddySh
|
|
|
1112
1112
|
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
1113
1113
|
* installs where NO local pi-ai exists and the module is only provided inside
|
|
1114
1114
|
* the DSH host — a static import there is a load-time crash. Resolution
|
|
1115
|
-
* itself does not need the module:
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1115
|
+
* itself does not need the module: the manifest version and the
|
|
1116
|
+
* `normalizeContext` feature (present from 0.87 on, not before) agree across
|
|
1117
|
+
* `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
|
|
1118
|
+
* transcript, so the split is `>= 0.87` rather than "within 0.x".
|
|
1118
1119
|
*
|
|
1119
1120
|
* When resolution FAILS — the normal state of a market install probed outside
|
|
1120
1121
|
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, 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 isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, 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-CBFAiYPg.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1308,6 +1308,15 @@ function readBody(req) {
|
|
|
1308
1308
|
* Once a native `delta.tool_calls` appears, recovery switches off for the
|
|
1309
1309
|
* rest of the response — two live call channels for one answer is worse
|
|
1310
1310
|
* than either one alone.
|
|
1311
|
+
*
|
|
1312
|
+
* 5. EVERY FRAME LEAVES SELF-CONTAINED. An SSE event ends at a blank line,
|
|
1313
|
+
* and a frame written without its terminator merges into the NEXT event
|
|
1314
|
+
* the client decodes — whose `data:` lines are then joined and parsed as
|
|
1315
|
+
* one document (issue #53). Every write therefore goes through
|
|
1316
|
+
* {@link emitFrame}, which completes the terminator; `push()` cuts frames
|
|
1317
|
+
* that already carry it, and the one source of unterminated bytes — the
|
|
1318
|
+
* half-frame left in `carry` when the upstream dies — is normalized in
|
|
1319
|
+
* {@link consume}.
|
|
1311
1320
|
*/
|
|
1312
1321
|
var RecoveryStream = class {
|
|
1313
1322
|
res;
|
|
@@ -1382,7 +1391,7 @@ var RecoveryStream = class {
|
|
|
1382
1391
|
finish_reason: ""
|
|
1383
1392
|
};
|
|
1384
1393
|
this.windowOpened = true;
|
|
1385
|
-
this.
|
|
1394
|
+
this.emitFrame(contentFrame(chunk, choiceRecord, notice));
|
|
1386
1395
|
}
|
|
1387
1396
|
/** True when a call or real prose has reached the client. */
|
|
1388
1397
|
get delivered() {
|
|
@@ -1392,6 +1401,43 @@ var RecoveryStream = class {
|
|
|
1392
1401
|
get residueSeen() {
|
|
1393
1402
|
return this.sawResidue;
|
|
1394
1403
|
}
|
|
1404
|
+
/**
|
|
1405
|
+
* The single exit every byte of a FRAME passes through.
|
|
1406
|
+
*
|
|
1407
|
+
* SSE dispatches one event per blank-line-terminated run of `data:` lines.
|
|
1408
|
+
* A frame written WITHOUT its terminator leaves the client's decoder
|
|
1409
|
+
* waiting for a blank line that never arrives, so the NEXT write is
|
|
1410
|
+
* appended to the SAME event — and a client that joins the several `data:`
|
|
1411
|
+
* lines of one event with `\n` and parses the result as one JSON document
|
|
1412
|
+
* (openai's `SSEDecoder` does exactly that) then reads two payloads as one
|
|
1413
|
+
* and fails. Issue #53 is that failure: the upstream died between a `data:`
|
|
1414
|
+
* line and its blank line, `consume()` forwarded the half-frame verbatim,
|
|
1415
|
+
* and `finish()` glued its synthetic `[DONE]` onto it, producing
|
|
1416
|
+
* `data: {...}\ndata: [DONE]\n\n` — parsed as one event whose data is
|
|
1417
|
+
* `"{...}\n[DONE]"`, which throws "Unexpected non-whitespace character
|
|
1418
|
+
* after JSON at position N". The whole turn is then classified as a
|
|
1419
|
+
* non-retryable error, so the user simply sees the answer stop.
|
|
1420
|
+
*
|
|
1421
|
+
* Completing the terminator here fixes both halves at once: the truncated
|
|
1422
|
+
* frame becomes a self-contained event, and the `[DONE]` that follows can no
|
|
1423
|
+
* longer be joined to it. It is idempotent for the frames `push()` cuts —
|
|
1424
|
+
* those always carry the terminator already, so they pass through
|
|
1425
|
+
* byte-for-byte, which keeps guarantee 1 (frame order is never changed)
|
|
1426
|
+
* intact.
|
|
1427
|
+
*
|
|
1428
|
+
* Note that a missing NEWLINE is also completed: `data: {…}` cut mid-line
|
|
1429
|
+
* needs the full `\n\n`, not a single `\n`, to close the event. Such a frame
|
|
1430
|
+
* is upstream-corrupted JSON that cannot be repaired here; closing it at
|
|
1431
|
+
* least keeps the corruption confined to its own event instead of taking
|
|
1432
|
+
* the terminator down with it.
|
|
1433
|
+
*/
|
|
1434
|
+
emitFrame(frame) {
|
|
1435
|
+
if (frame.endsWith("\n\n")) {
|
|
1436
|
+
this.res.write(frame);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
this.res.write(frame.endsWith("\n") ? `${frame}\n` : `${frame}\n\n`);
|
|
1440
|
+
}
|
|
1395
1441
|
/** Record that text the gates refused was seen (markup residue, not prose). */
|
|
1396
1442
|
noteResidue(seen) {
|
|
1397
1443
|
this.sawResidue ||= seen;
|
|
@@ -1407,7 +1453,10 @@ var RecoveryStream = class {
|
|
|
1407
1453
|
}
|
|
1408
1454
|
const tail = this.carry;
|
|
1409
1455
|
this.carry = "";
|
|
1410
|
-
if (tail !== "")
|
|
1456
|
+
if (tail !== "") {
|
|
1457
|
+
const lineTerminated = tail.endsWith("\n");
|
|
1458
|
+
this.handleFrame(lineTerminated ? `${tail}\n` : `${tail}\n\n`);
|
|
1459
|
+
}
|
|
1411
1460
|
}
|
|
1412
1461
|
/**
|
|
1413
1462
|
* Close the response: emit the block still unfinished at end-of-stream, then
|
|
@@ -1420,7 +1469,7 @@ var RecoveryStream = class {
|
|
|
1420
1469
|
this.emitContent(flushed.text);
|
|
1421
1470
|
}
|
|
1422
1471
|
this.closeWindow();
|
|
1423
|
-
if (!this.sawDone && this.res.writable) this.
|
|
1472
|
+
if (!this.sawDone && this.res.writable) this.emitFrame("data: [DONE]\n\n");
|
|
1424
1473
|
if (this.res.writable) this.res.end();
|
|
1425
1474
|
}
|
|
1426
1475
|
/** Drop held bytes without writing them (used when a retry replaced them). */
|
|
@@ -1498,7 +1547,7 @@ var RecoveryStream = class {
|
|
|
1498
1547
|
this.windowOpened = true;
|
|
1499
1548
|
this.flushNotice();
|
|
1500
1549
|
this.closeWindow();
|
|
1501
|
-
this.
|
|
1550
|
+
this.emitFrame(frame);
|
|
1502
1551
|
this.wroteContent = true;
|
|
1503
1552
|
return;
|
|
1504
1553
|
}
|
|
@@ -1509,7 +1558,7 @@ var RecoveryStream = class {
|
|
|
1509
1558
|
this.send(callsFrame(chunk, choiceRecord, outcome.calls), false);
|
|
1510
1559
|
this.wroteContent = true;
|
|
1511
1560
|
this.closeWindow();
|
|
1512
|
-
if (outcome.text !== "") this.
|
|
1561
|
+
if (outcome.text !== "") this.emitFrame(contentFrame(chunk, choiceRecord, outcome.text));
|
|
1513
1562
|
return;
|
|
1514
1563
|
}
|
|
1515
1564
|
this.noteResidue(!outcome.prose && outcome.text !== "");
|
|
@@ -1573,7 +1622,7 @@ var RecoveryStream = class {
|
|
|
1573
1622
|
/** Queue a frame while the window is open, or write it once it has closed. */
|
|
1574
1623
|
send(frame, final) {
|
|
1575
1624
|
if (this.windowClosed || !this.windowOpened) {
|
|
1576
|
-
this.
|
|
1625
|
+
this.emitFrame(frame);
|
|
1577
1626
|
return;
|
|
1578
1627
|
}
|
|
1579
1628
|
const entry = {
|
|
@@ -1597,7 +1646,7 @@ var RecoveryStream = class {
|
|
|
1597
1646
|
this.windowClosed = true;
|
|
1598
1647
|
const pending = this.held;
|
|
1599
1648
|
this.held = [];
|
|
1600
|
-
for (const entry of pending) this.
|
|
1649
|
+
for (const entry of pending) this.emitFrame(entry.frame);
|
|
1601
1650
|
}
|
|
1602
1651
|
};
|
|
1603
1652
|
/** The SSE `data:` payload of one frame, or nothing when it carries none. */
|
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.
|
|
5
|
+
"version": "4.4.2",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|