dsh-connect-workbuddy 3.0.1 → 3.1.0
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 +57 -0
- package/lib/bin.js +23 -3
- package/lib/client.js +253 -167
- package/lib/{host-heartbeat-CfXJht7R.js → host-heartbeat-DZnPggVT.js} +177 -4
- package/lib/index.d.ts +60 -2
- package/lib/index.js +149 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.1.0 (2026-10-01)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- **修复国际版每一步都 400 / 业务码 11128(`first message is not system prompt`),而国内版正常。**
|
|
8
|
+
- **现象**:用户切到 `workbuddy-global` 后,每一步都失败;同一会话里国内版 18 个 turn 全部成功。响应体里带的是 `displayMsg: "The request was blocked by security policy"`,看起来像风控,实际不是。
|
|
9
|
+
- **根因(真机复现)**:两个网关都要求会话**以 `system` 消息开头**,**国际网关强制、国内网关容忍**——这就是「只有国际版报」的全部原因。而 system 消息会在到达本插件的 shim 之前就消失:`dsh-llm-pi-ai` 把首条 `system` 消息抽成 `Context.systemPrompt`,pi-ai 只在 `if (context.systemPrompt)` 为真时才发它——**空串或空文本是 falsy**,于是它一条 system 都不发,并把 `messages` 里那条 `system` 降级成 `user`。出站因此以 user 打头。
|
|
10
|
+
- **实测矩阵**(真实国际网关,插件真实 shim + adapter 路径):首条 system 文本为空 → 线上 `["user"]` → 11128;`system=""` 且 `messages` 首条是 system → 线上 `["user","user"]` → 11128;system 是空格 `" "` → 线上 `["system","user"]` → 200。**空格能过、空串不能**,正好卡在 pi-ai 的真值判断上。
|
|
11
|
+
- **修法**(两道,都在出站前):
|
|
12
|
+
1. `src/adapter.ts` 的 `adaptLegacyPiAiContext` 把 `systemPrompt !== undefined` 改成真值判断。空串不再被当作「已经设好了」而提前返回,于是 `messages` 里真实存在的那条 system 会被折回 `systemPrompt`——**把用户真正的提示词救回来**,而不是替换掉。
|
|
13
|
+
2. 兜底:确实一条 system 都没有时,补一条最小占位(`WORKBUDDY_FALLBACK_SYSTEM_PROMPT`,定义在 `src/upstream.ts`)。同时 `prepareChatBody` 增加 `ensureSystemHead()` 作为最后一道:出站前首条不是 `system` 就补。
|
|
14
|
+
- **占位提示词刻意极短**(`You are a helpful assistant.`):它是最后手段,不是人格设定;编一段长的会悄悄改变所有受影响请求的模型行为。
|
|
15
|
+
- **与探测路径对齐**:`src/probe.ts` 早就知道这条规则(注释写着「探测请求总是带一条 system 消息,两个网关都接受」),但聊天这条路一直没有这道防护——本次补齐。
|
|
16
|
+
- **守卫**:`tests/adapter.spec.ts` 新增「空 `systemPrompt` 不吞掉真实 system 文本」与「无 system 时补占位」两例,并**修正了一条编码旧契约的用例**(原先断言「没有 system 消息时返回同一个对象」,而这正是必须改动的输入);`tests/upstream.spec.ts` 新增三例:user 打头时补占位、已是 system 打头时不重复补、无历史时一条都不补。
|
|
17
|
+
- **未验证**:生产宿主里究竟是哪条路径进入该分支(空文本被丢弃,还是 `options.system=''` 导致降级)——两者都被兜底覆盖,且都已在真实国际网关上端到端验证返回 200。
|
|
18
|
+
|
|
19
|
+
- **修复 issue #26(以及 #25 的完整根因):3.0.2 的 `adaptLegacyPiAiContext` 折叠会丢掉 system prompt 与全部工具声明——上一条只治了 400 症状,本条治根。**
|
|
20
|
+
- **现象(#26)**:升级 3.0.2 后 desktop profile 下所有 WorkBuddy 模型都无法调用工具:模型不产生 `tool_calls`,而是在正文里用 Markdown 代码块**编造**工具调用与"执行结果",`stopReason` 恒为 `stop`(报告者实测 15 个请求 `calls=0`;编造的工具名是 `Bash` 而非 DSH 真实的 `pwsh`——连工具清单都没收到)。国际版则先撞上网关校验表现为 #25 的硬 400。
|
|
21
|
+
- **根因(逐条对照两版 pi-ai 源码核实)**:pi-ai 0.87 的 `normalizeContext` 把 system prompt 与工具声明**只**放在 `messages` 首条 system 消息上(`content` 与 `toolsAdded`),顶层 `systemPrompt`/`tools` 字段就此消失;而 0.87 的 `openai-completions` **全文没有一处读顶层 `systemPrompt`**,工具只从首条 system 消息的 `toolsAdded` 取(宿主 asar 里的 0.87.1 已逐行核实)。3.0.2 的 `withLegacyContext` 按 **context 形状**判断"要修",把首条 system 消息删掉、文本折回顶层——恰好删掉了两个代次 api 各自唯一的载体:
|
|
22
|
+
- 插件解析到 **0.87**(干净安装,无磁盘 0.85 副本,#26 报告者的机器):顶层 `systemPrompt` 没人读 → **提示词丢**;`toolsAdded` 被删 → **工具丢** → 模型编造工具调用;
|
|
23
|
+
- 插件解析到 **0.85**(本机:`~/.dsh/profiles/desktop` 里的安装是指向本仓库的符号链接,嵌套 dev 依赖 0.85.1,而宿主 asar 是 0.87.1):折回的文本 0.85 认,但折叠**只搬文本不搬工具**——0.85 只读顶层 `context.tools`,而 `toolsAdded` 被丢弃 → **工具同样丢**。两个方向都是错的,区别只是症状。
|
|
24
|
+
- **为什么不能照 #26 的"最小修复"直接删掉 wrapper**:本机插件解析的正是 0.85.1,宿主却用 0.87.1 归一化 context——删掉 wrapper 会立刻复活 issue #24 的崩溃(0.85 的 `estimateMessageTokens` 对字符串 content 的 system 消息抛 `Cannot read properties of undefined (reading 'length')`,已在本机复现为流内 error、请求未发出)。报告者机器与作者机器**环境互补**,任何基于单一环境的修法都会弄坏另一台。
|
|
25
|
+
- **修法(按代次门控,而不是按 context 形状猜)**:新增 `src/pi-ai-runtime.ts`,判定**插件自己解析到的那份 pi-ai**:
|
|
26
|
+
1. **modern(0.87+)**:`withLegacyContext` 原样返回 api,context **字节级透传**——0.87 自己就能消费归一化 transcript(#26 的复现脚本证明了这条路 tools 与 prompt 都在);
|
|
27
|
+
2. **legacy(0.85)**:折叠照旧,但**把工具状态一并提升**——按 0.87 自己 `getCurrentTools` 的语义重放每条 system 消息的 `toolsRemoved`/`toolsAdded`(先删后加、按名去重),写回顶层 `context.tools`(顶层已有 `tools` 数组时不覆盖)。
|
|
28
|
+
- **检测刻意不 import pi-ai 本体**:用 `import.meta.resolve` 定位入口文件(不执行模块)+ 读取**所属 manifest 的版本号**判定代次(0.87+ → modern;peer 范围内版本与 `normalizeContext` 特征一致)。原因是 standalone CLI 必须在**市场安装**(本地无 pi-ai 副本、模块只由宿主提供)下也能跑 `doctor`——静态 import 会让它在加载期就 `ERR_MODULE_NOT_FOUND`(已用 3.0.2 官方包在裸目录复现同一现象:缺 peer 时谁都跑不起来,本次新增的这条依赖则被彻底避免)。
|
|
29
|
+
- **解析失败 = 宿主提供**(市场安装的常态):此时默认走 modern 透传,因为**会归一化 context 的宿主(0.2.0+)都提供 0.87.1**;该默认对旧宿主同样安全——旧宿主从不归一化,原生 0.85 形状在两个分支下都是恒等。
|
|
30
|
+
- **实测(本机 0.85.1,真实 api + fetch 注入捕获出站 body)**:修复前有 wrapper → `tools = MISSING`、system 文本在;修复后 → **`tools = 1`(pwsh)、首条 `system` 消息文本完整**。无 wrapper 的对照 → system 也丢、且 #24 崩溃以流内 error 出现。
|
|
31
|
+
- **doctor 可见性(#26 的附加建议)**:`doctor` 输出新增 `piAiRuntime` 块(generation / version / resolvedFrom / **resolvedLocally** / adapter 行为说明),人类可读行同步打印;legacy 时追加 hint 提示"插件解析到 0.85 而 DSH 0.2.0+ 宿主用 0.87 归一化,适配器在桥接,若工具异常请清理嵌套 pi-ai 副本"。JSON schemaVersion 2 → 3。**两种环境的真实输出已分别实测**:仓库检出(嵌套 0.85.1)→ `legacy 0.85.1 (<pnpm 真实路径>) — folds…`;市场式安装(有 peer、无 pi-ai)→ `modern, host-provided (no local copy; DSH 0.2.0+ ships 0.87.1) — passes…`。
|
|
32
|
+
- **守卫**:接线用例从一条拆成**三条**——legacy 折叠并提升工具(含 `tools` 断言)、modern **同一对象引用**透传(`toBe`,最强度断言)、默认跟随实际解析代次;新增 `tests/adapter-outgoing-body.spec.ts` 按 #26 建议的测法**直接断言出站请求体**(`body.tools` 与首条 system 文本,并在失败信息里带出流内错误);新增 `tests/pi-ai-runtime.spec.ts` 四例钉住检测自洽(generation 与模块特征一致、路径与版本来自同一份 manifest、结果缓存稳定)——**不钉具体版本号**,peer 范围内换版本时检测必须跟着走而不是被测试冻住。
|
|
33
|
+
- **对上一条(#25 修复)的关系**:`ensureSystemHead`/兜底占位保留——它们是 wire 层最后防线(空文本 system、`options.system=''` 等退化输入仍需兜底),但 400 消失后若不修本条,国际版会立刻从"报错"变成"不报错但没工具"(#26 原文预测,本机复现坐实)。
|
|
34
|
+
- **跨机真机验证(发布前)**:Windows 11 与 macOS 两台机器、国内版与国际版两个区域,均完成真实工具调用(`pwsh` 执行 `echo`,输出逐字一致;模型列出的工具名是 DSH 真名而非编造的 `Bash`)。Windows 那台先复现了 #25 的 400 报文与 #26 的「我没有可用的 shell 工具」,部署修复后同一路径通过——红绿对照。存档见 `docs/ISSUE-25-26-WINDOWS-VERIFY.md`。
|
|
35
|
+
- **本次未覆盖**:两台机器都是 junction 到仓库本体(解析到嵌套 0.85.1)→ 都走 legacy 桥接分支;**modern(市场安装、宿主提供 pi-ai)分支尚无真机端到端观测**,仅有单元测试的恒等断言与模拟安装树上的 `doctor` 选中验证。
|
|
36
|
+
|
|
37
|
+
### Features
|
|
38
|
+
|
|
39
|
+
- **模型区可折叠**:模型列表现在折在标题行下面(标题、已启用数量、「从 WorkBuddy 刷新」按钮留在外面),点标题行即可收起,把下方的账号池一次露出来。用原生 `<details>` 实现,因此键盘可达、助记语义正确,且不引入任何状态库。
|
|
40
|
+
- **标题左侧有折叠箭头**:收起时朝右、展开时旋转 90° 朝下(0.16s 过渡,与卡片既有过渡一致)。**原生三角被隐藏、改为自绘**——否则会出现两个方向相反的状态指示器。旋转由 `details[open]` 驱动,不需要额外状态,也就不可能与实际显示不一致;箭头本身是纯装饰(`<details>` 已向助记技术播报状态),故标记 `aria-hidden`。
|
|
41
|
+
- **默认展开**:这是用户原本就看得到的内容,默认折起来会让人以为它没了。
|
|
42
|
+
- **折叠状态不持久化**:卡片只挂载当前 tab,所以「记住」需要新增一个设置字段——一个纯外观偏好不值得。切 tab 或重开卡片会回到默认。
|
|
43
|
+
- **折叠态标题行会显示「有未保存的修改」**:保存/放弃按钮在折叠区里面,且只在 `dirty` 时可点;没有这个提示,用户勾了几个模型再折起来,就把自己的未保存改动藏在了折叠后面而界面上一言不发。
|
|
44
|
+
- **标题行中间加了一句淡色动作提示**(「点击展开」/「点击收起」):只有箭头时,「这里能点」这件事不够明确。提示说的是**此刻能做的那个动作**,而不是固定的「展开或收起」——后者会让读者自己去想该按哪个,正是这句提示要消除的疑问。它用与标题下方摘要行相同的淡色 token,读起来是提示而不是第二个标题;窄屏(≤760px)下隐藏,因为箭头已足以表明这是折叠控件,先让位给标题与按钮。
|
|
45
|
+
- **刷新按钮不会误触发折叠**:按钮位于 `<summary>` 内,而点击 summary 内任何位置都会激活这个 disclosure——所以 summary 的 `onClick` 对落在操作区的点击调用了 `preventDefault()`,否则「刷新模型」会顺手把列表收起来。这条有专门用例钉住。
|
|
46
|
+
|
|
47
|
+
## 3.0.2 (2026-09-30)
|
|
48
|
+
|
|
49
|
+
### Bug Fixes
|
|
50
|
+
|
|
51
|
+
- **修复 issue #24:宿主 pi-ai 0.87 下所有 WorkBuddy 模型立即失败**(`Cannot read properties of undefined (reading 'length')`)。
|
|
52
|
+
- **根因**:pi-ai 0.87 的 `normalizeContext` 把 system 提示词搬进 `messages`,成了 `{ role: 'system', content: '<字符串>' }`。而 pi-ai 0.85 的 `Message` 联合类型里**根本没有 `system` 变体**(只有 `UserMessage | AssistantMessage | ToolResultMessage`),于是它的 `estimateMessageTokens` 没有对应分支:`for (const block of message.content)` 会把**字符串逐字符**迭代,`block` 变成单个字符,`block.name.length` 抛错。崩溃发生在 `buildBaseOptions → clampMaxTokensToContext → estimateContextTokens`,即**构造请求体之前**——所以每个模型都在十几毫秒内失败,且**没有任何上游请求发出**。
|
|
53
|
+
- **修法**:新增 `adaptLegacyPiAiContext()`,在把 context 交给本插件的 pi-ai 之前,把 `messages` 里的 system 消息折回 `Context.systemPrompt`(0.85 期望的位置),并用 `withLegacyContext()` 包住 `openAICompletionsApi()` 的 `stream`/`streamSimple` 两个入口。
|
|
54
|
+
- **按形状适配,不按版本判断**:判断依据是「context 里有没有 system 消息」,不是「加载的是哪个 pi-ai 版本」。版本判断会在任一侧升级时立刻失效;本插件必须在**两代宿主**下都能活。对原生 0.85 形状(已有 `systemPrompt`)返回**同一个对象**,原路径零改动。
|
|
55
|
+
- **实测**(用插件真正加载的那份 pi-ai 0.85.1):修复前 0.87 形状 → `Cannot read properties of undefined (reading 'length')`;修复后 → `Connection error.`(走到传输层,即请求已构造完成)。旧形状恒等,`system` 用 block 内容时也能正确合并。
|
|
56
|
+
- **守卫**:`tests/adapter.spec.ts` 新增 8 例,其中**接线用例**(`vi.mock` 掉 stream API,断言 provider 收到的 context 已被折平)是必要的——只测纯函数时,把修复从 provider 上摘掉的变异**不会被打红**(已实测),等于留了一个「修复被摘掉而测试全绿」的缺口。变异验证:去掉 `withLegacyContext` → 该用例变红。
|
|
57
|
+
- **顺带核实**:宿主 asar 声明 `@earendil-works/pi-ai ^0.87.1` 且含 `normalizeContext`;磁盘上四份 pi-ai(profiles / 插件嵌套 / 仓库 / 旧安装树)**全是 0.85.1**。issue 建议的「插件包内放一份 0.87.1」绕行在本机并未生效——嵌套副本是 0.85.1,漂移依旧,只是被当前加载的旧 `dsh-llm-pi-ai`(0.1.5-rc.2,不含 `normalizeContext`)掩盖了。
|
|
58
|
+
- **peer 范围放宽**:`@earendil-works/pi-ai` 由 `>=0.85.0 <0.86.0` 改为 `>=0.85.0 <0.88.0`(peer 与 dev 两处)。原范围**不承认 0.87**,与实际已验证的兼容性不符:适配按形状生效,代码在 0.85 与 0.87 下都已验证可用。放宽后 `pnpm install` 报 "Lockfile is up to date",未产生 lockfile 变更。
|
|
59
|
+
|
|
3
60
|
## 3.0.1 (2026-09-30)
|
|
4
61
|
|
|
5
62
|
### Bug Fixes
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { E as legacyWorkbuddyOwnAuthPath, K as findWorkbuddyAppExecutable, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, V as WORKBUDDY_APP_EXECUTABLE_ENV, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-DZnPggVT.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|
|
@@ -19,7 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
*
|
|
20
20
|
* @module dsh-connect-workbuddy/bin
|
|
21
21
|
*/
|
|
22
|
-
const JSON_SCHEMA_VERSION =
|
|
22
|
+
const JSON_SCHEMA_VERSION = 3;
|
|
23
23
|
/** Both regions, in reporting order. */
|
|
24
24
|
const REGIONS = ["cn", "global"];
|
|
25
25
|
/** Region labels for human output. */
|
|
@@ -69,11 +69,29 @@ async function doctor(jsonOutput) {
|
|
|
69
69
|
accounts: await makeStore(region).accounts()
|
|
70
70
|
})));
|
|
71
71
|
const anySignedIn = regionLists.some(({ accounts }) => accounts.length > 0);
|
|
72
|
+
/**
|
|
73
|
+
* Which pi-ai copy the PROVIDER resolves, and hence which context shape its
|
|
74
|
+
* api consumes. This is the #24/#26 gate made visible: a legacy (0.85)
|
|
75
|
+
* resolution while the DSH host normalizes with 0.87 is exactly the mixed
|
|
76
|
+
* generation the adapter has to bridge, and the only way to SEE it is to
|
|
77
|
+
* report it. `resolvedLocally: false` is the normal state of a market
|
|
78
|
+
* install probed outside the host — inside DSH the host supplies pi-ai
|
|
79
|
+
* (0.2.0+ ships 0.87.1, i.e. the pass-through branch). Nothing here reads
|
|
80
|
+
* or reports key material.
|
|
81
|
+
*/
|
|
82
|
+
const piAi = piAiRuntimeInfo();
|
|
72
83
|
const report = {
|
|
73
84
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
74
85
|
package: "dsh-connect-workbuddy",
|
|
75
86
|
version: WORKBUDDY_CONNECT_VERSION,
|
|
76
87
|
node: process.version,
|
|
88
|
+
piAiRuntime: {
|
|
89
|
+
generation: piAi.generation,
|
|
90
|
+
version: piAi.version ?? (piAi.resolvedLocally ? "(unreadable)" : "(host-provided)"),
|
|
91
|
+
resolvedFrom: piAi.resolvedFrom ?? "(host-provided; not resolvable outside DSH)",
|
|
92
|
+
resolvedLocally: piAi.resolvedLocally,
|
|
93
|
+
adapterBehaviour: piAi.generation === "legacy" ? "folds 0.87 transcripts (system text + tool state) into the 0.85 context shape" : piAi.resolvedLocally ? "passes provider contexts through unchanged" : "passes provider contexts through unchanged (pi-ai comes from the DSH host; 0.2.0+ ships 0.87.1)"
|
|
94
|
+
},
|
|
77
95
|
desktopAuthFile: {
|
|
78
96
|
path: anyStore.desktopAuthPath() ?? "(no platform default; set WORKBUDDY_AUTH_FILE)",
|
|
79
97
|
dir: defaultDesktopAuthDirs()[0] ?? "(no platform default)",
|
|
@@ -120,12 +138,14 @@ async function doctor(jsonOutput) {
|
|
|
120
138
|
...anySignedIn ? [] : ["Sign in once in the WorkBuddy desktop app (either region), then run status again."],
|
|
121
139
|
...desktopPresent ? [] : [`No WorkBuddy desktop auth file at the expected path; set ${WORKBUDDY_AUTH_FILE_ENV} if it lives elsewhere.`],
|
|
122
140
|
...appExecutable === void 0 ? [`The WorkBuddy desktop app was not found, so encrypted credential fields cannot be opened; set ${WORKBUDDY_APP_EXECUTABLE_ENV} to its executable if it is installed elsewhere.`] : [],
|
|
123
|
-
...hostAlive ? [] : ["Host bundle not running in this DSH profile (or the process exited). The browser card and providers are unavailable until DSH starts the plugin."]
|
|
141
|
+
...hostAlive ? [] : ["Host bundle not running in this DSH profile (or the process exited). The browser card and providers are unavailable until DSH starts the plugin."],
|
|
142
|
+
...piAi.generation === "legacy" && piAi.resolvedLocally ? [`This plugin resolves pi-ai ${piAi.version ?? "(unreadable version)"} (the 0.85 context shape) while DSH 0.2.0+ hosts normalize with 0.87; the adapter bridges that gap, but if tools still misbehave, remove any nested pi-ai copy so the plugin resolves the host's.`] : []
|
|
124
143
|
]
|
|
125
144
|
};
|
|
126
145
|
if (jsonOutput) printJson(report);
|
|
127
146
|
else process.stdout.write([
|
|
128
147
|
`WorkBuddy Connect ${WORKBUDDY_CONNECT_VERSION} on ${process.version}`,
|
|
148
|
+
...piAi.resolvedLocally ? [`pi-ai runtime: ${piAi.generation}${piAi.version === void 0 ? "" : ` ${piAi.version}`} (${piAi.resolvedFrom}) — ${report.piAiRuntime.adapterBehaviour}`] : [`pi-ai runtime: ${piAi.generation}, host-provided (no local copy; DSH 0.2.0+ ships 0.87.1) — passes provider contexts through unchanged`],
|
|
129
149
|
`Desktop auth file: ${report.desktopAuthFile.present ? "present" : "missing"} (${report.desktopAuthFile.path})`,
|
|
130
150
|
`Encrypted-credential support: ${report.atRestDecryption.available ? "available" : "unavailable"} (${report.atRestDecryption.appExecutable})`,
|
|
131
151
|
`Host bundle: ${hostAlive ? `running (pid ${heartbeat.pid})` : heartbeat !== void 0 ? "stale heartbeat (process exited)" : "not started"}`,
|
package/lib/client.js
CHANGED
|
@@ -1647,8 +1647,34 @@ window.__ModuleLoader__.load({
|
|
|
1647
1647
|
.dsm-workbuddy-checkin-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:11px;line-height:16px;text-align:center}
|
|
1648
1648
|
@media (max-width:760px){.dsm-workbuddy-credits-panels{grid-template-columns:1fr}.dsm-workbuddy-credit-panel-total{align-items:flex-start;text-align:left}.dsm-workbuddy-credit-total-body{align-items:flex-start}}
|
|
1649
1649
|
.dsm-workbuddy-models{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
|
|
1650
|
-
.dsm-workbuddy-models-
|
|
1650
|
+
.dsm-workbuddy-models-fold{display:flex;flex-direction:column;gap:10px}
|
|
1651
|
+
/* The head doubles as the <details> summary. list-style:none plus the marker
|
|
1652
|
+
rule removes the NATIVE triangle, because the chevron below is drawn instead;
|
|
1653
|
+
without this the header would show two indicators in opposite corners. */
|
|
1654
|
+
.dsm-workbuddy-models-head{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:pointer;list-style:none;user-select:none}
|
|
1655
|
+
.dsm-workbuddy-models-head::-webkit-details-marker{display:none}
|
|
1656
|
+
.dsm-workbuddy-models-head::marker{content:""}
|
|
1657
|
+
.dsm-workbuddy-models-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:3px;border-radius:6px}
|
|
1658
|
+
.dsm-workbuddy-models-title-row{display:flex;align-items:center;gap:6px;min-width:0}
|
|
1659
|
+
/* The chevron points RIGHT when collapsed and rotates to DOWN when expanded.
|
|
1660
|
+
The rotation keys off the details element's own open attribute, so it needs
|
|
1661
|
+
no state and cannot disagree with what the browser actually shows. */
|
|
1662
|
+
.dsm-workbuddy-models-chevron{display:inline-flex;align-items:center;justify-content:center;flex:none;width:14px;height:14px;color:var(--dsw-alias-label-tertiary,#999);transition:transform .16s}
|
|
1663
|
+
/* The faint affordance hint, centred in the space between the section title and
|
|
1664
|
+
the refresh button. Same faint token as the summary line under the title, so
|
|
1665
|
+
it reads as a hint rather than as a second heading. flex:1 is what makes it
|
|
1666
|
+
land in the actual middle instead of wherever space-between drops it. */
|
|
1667
|
+
.dsm-workbuddy-models-fold-hint{flex:1 1 auto;min-width:0;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
|
|
1668
|
+
/* Too narrow to hold title + hint + button on one line: the chevron already
|
|
1669
|
+
marks the header as a disclosure, so the words are the part that can go. */
|
|
1670
|
+
@media (max-width:760px){.dsm-workbuddy-models-fold-hint{display:none}}
|
|
1671
|
+
.dsm-workbuddy-models-fold[open] .dsm-workbuddy-models-chevron{transform:rotate(90deg)}
|
|
1672
|
+
.dsm-workbuddy-models-head:hover .dsm-workbuddy-models-chevron{color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
1651
1673
|
.dsm-workbuddy-models-title{margin:0;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px}
|
|
1674
|
+
/* Unsaved-changes marker. It sits in the HEADER, outside the fold, because the
|
|
1675
|
+
save/discard buttons are inside it: without this the user could collapse the
|
|
1676
|
+
list and hide their own pending edits with nothing on screen saying so. */
|
|
1677
|
+
.dsm-workbuddy-models-dirty{margin-left:8px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1b1d22;font-size:11px;font-weight:600;line-height:16px;vertical-align:1px;white-space:nowrap}
|
|
1652
1678
|
.dsm-workbuddy-models-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
|
|
1653
1679
|
.dsm-workbuddy-model-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;overflow:hidden}
|
|
1654
1680
|
.dsm-workbuddy-model{display:grid;grid-template-columns:minmax(0,1fr);gap:7px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#232529);transition:opacity .16s}
|
|
@@ -2101,6 +2127,16 @@ window.__ModuleLoader__.load({
|
|
|
2101
2127
|
/** The region whose tab is on screen; each tab is its own provider stack. */
|
|
2102
2128
|
const [activeRegion, setActiveRegion] = (0, react.useState)("cn");
|
|
2103
2129
|
/**
|
|
2130
|
+
* Whether the model list is unfolded.
|
|
2131
|
+
*
|
|
2132
|
+
* Mirrored in state purely to supply the INITIAL value: a bare `<details>` is
|
|
2133
|
+
* COLLAPSED by default, and the list is what users already see, so unfolding
|
|
2134
|
+
* has to be the starting point. Deliberately NOT persisted — only the active
|
|
2135
|
+
* region is mounted, so remembering it would need a settings field for a
|
|
2136
|
+
* cosmetic preference; it resets when the card is closed or the tab changes.
|
|
2137
|
+
*/
|
|
2138
|
+
const [modelsOpen, setModelsOpen] = (0, react.useState)(true);
|
|
2139
|
+
/**
|
|
2104
2140
|
* Last-known usage per region, so tab dots survive tab switches.
|
|
2105
2141
|
*
|
|
2106
2142
|
* The placeholder carries `selectionExplicit: false` because that is the
|
|
@@ -2869,183 +2905,225 @@ window.__ModuleLoader__.load({
|
|
|
2869
2905
|
role: "alert",
|
|
2870
2906
|
children: t("row.requestFailedHint", { message: status.refreshError })
|
|
2871
2907
|
}),
|
|
2872
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
2908
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
2873
2909
|
className: "dsm-workbuddy-models",
|
|
2874
2910
|
"aria-label": t("row.modelsTitle"),
|
|
2875
|
-
children:
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
className: "dsm-btn dsm-btn-outline",
|
|
2889
|
-
disabled: busy,
|
|
2890
|
-
onClick: () => {
|
|
2891
|
-
refreshModels();
|
|
2892
|
-
},
|
|
2893
|
-
children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
|
|
2894
|
-
})
|
|
2895
|
-
})]
|
|
2896
|
-
}),
|
|
2897
|
-
probeError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2898
|
-
className: "dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-bad",
|
|
2899
|
-
role: "alert",
|
|
2900
|
-
children: t("row.probeError", { message: probeError })
|
|
2901
|
-
}),
|
|
2902
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2903
|
-
className: "dsm-workbuddy-model-list",
|
|
2904
|
-
children: visibleModels.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2905
|
-
className: `dsm-workbuddy-model${activeEnabledIds.has(model.id) ? "" : " dsm-workbuddy-model-disabled"}`,
|
|
2911
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
|
|
2912
|
+
className: "dsm-workbuddy-models-fold",
|
|
2913
|
+
open: modelsOpen,
|
|
2914
|
+
onToggle: (event) => {
|
|
2915
|
+
setModelsOpen(event.currentTarget.open);
|
|
2916
|
+
},
|
|
2917
|
+
children: [
|
|
2918
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
|
|
2919
|
+
className: "dsm-workbuddy-models-head",
|
|
2920
|
+
title: t("row.modelsFoldHint"),
|
|
2921
|
+
onClick: (event) => {
|
|
2922
|
+
if (event.target instanceof Element && event.target.closest(".dsm-workbuddy-models-head-actions") !== null) event.preventDefault();
|
|
2923
|
+
},
|
|
2906
2924
|
children: [
|
|
2907
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2908
|
-
className: "dsm-workbuddy-
|
|
2909
|
-
children: [
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
className: "dsm-workbuddy-model-name-rate",
|
|
2925
|
-
children: [
|
|
2926
|
-
"(",
|
|
2927
|
-
model.creditMultiplier.toFixed(2),
|
|
2928
|
-
"x)"
|
|
2929
|
-
]
|
|
2930
|
-
})]
|
|
2931
|
-
})
|
|
2932
|
-
})]
|
|
2933
|
-
}),
|
|
2934
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
2935
|
-
className: "dsm-workbuddy-model-image",
|
|
2936
|
-
title: imageCheckboxHint(model, t),
|
|
2937
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2938
|
-
type: "checkbox",
|
|
2939
|
-
checked: activeImageIds.has(model.id),
|
|
2940
|
-
disabled: !canWrite || saving,
|
|
2941
|
-
onChange: () => {
|
|
2942
|
-
toggleImage(model.id);
|
|
2943
|
-
}
|
|
2944
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelImage") })]
|
|
2945
|
-
}),
|
|
2946
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
|
|
2947
|
-
className: "dsm-workbuddy-context-budget",
|
|
2948
|
-
"aria-label": t("row.contextBudget"),
|
|
2949
|
-
children: [model.nativeContextWindow > 2e5 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2950
|
-
type: "radio",
|
|
2951
|
-
name: `context-${model.id}`,
|
|
2952
|
-
checked: (activeContextBudgets[model.id] ?? 2e5) === 2e5,
|
|
2953
|
-
disabled: !canWrite || saving,
|
|
2954
|
-
onChange: () => {
|
|
2955
|
-
setContextBudget(model.id, 2e5);
|
|
2956
|
-
}
|
|
2957
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "200K" })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2958
|
-
type: "radio",
|
|
2959
|
-
name: `context-${model.id}`,
|
|
2960
|
-
checked: model.nativeContextWindow <= 2e5 || activeContextBudgets[model.id] === model.nativeContextWindow,
|
|
2961
|
-
disabled: model.nativeContextWindow <= 2e5 || !canWrite || saving,
|
|
2962
|
-
onChange: () => {
|
|
2963
|
-
setContextBudget(model.id, model.nativeContextWindow);
|
|
2964
|
-
}
|
|
2965
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) })] })]
|
|
2966
|
-
}),
|
|
2967
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2968
|
-
className: "dsm-workbuddy-model-actions-buttons",
|
|
2969
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2970
|
-
type: "button",
|
|
2971
|
-
className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
|
|
2972
|
-
disabled: probing[model.id] === true || status.status !== "signed-in",
|
|
2973
|
-
title: t("row.probeHint"),
|
|
2974
|
-
onClick: () => {
|
|
2975
|
-
probeModels([model.id]);
|
|
2976
|
-
},
|
|
2977
|
-
children: probing[model.id] === true ? t("row.probing") : t("row.probe")
|
|
2925
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2926
|
+
className: "dsm-workbuddy-models-title-row",
|
|
2927
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2928
|
+
className: "dsm-workbuddy-models-chevron",
|
|
2929
|
+
"aria-hidden": "true",
|
|
2930
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
2931
|
+
viewBox: "0 0 12 12",
|
|
2932
|
+
width: "12",
|
|
2933
|
+
height: "12",
|
|
2934
|
+
focusable: "false",
|
|
2935
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
2936
|
+
d: "M4.5 2.5 L8 6 L4.5 9.5",
|
|
2937
|
+
fill: "none",
|
|
2938
|
+
stroke: "currentColor",
|
|
2939
|
+
strokeWidth: "1.6",
|
|
2940
|
+
strokeLinecap: "round",
|
|
2941
|
+
strokeLinejoin: "round"
|
|
2978
2942
|
})
|
|
2979
2943
|
})
|
|
2980
|
-
|
|
2944
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
|
|
2945
|
+
className: "dsm-workbuddy-models-title",
|
|
2946
|
+
children: [t("row.modelsTitle"), dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2947
|
+
className: "dsm-workbuddy-models-dirty",
|
|
2948
|
+
children: t("row.modelsDirty")
|
|
2949
|
+
}) : null]
|
|
2950
|
+
})]
|
|
2951
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2952
|
+
className: "dsm-workbuddy-models-summary",
|
|
2953
|
+
children: t("row.modelsSummary", { count: activeEnabledIds.size })
|
|
2954
|
+
})] }),
|
|
2955
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2956
|
+
className: "dsm-workbuddy-models-fold-hint",
|
|
2957
|
+
"aria-hidden": "true",
|
|
2958
|
+
children: modelsOpen ? t("row.modelsFoldHide") : t("row.modelsFoldShow")
|
|
2981
2959
|
}),
|
|
2982
2960
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2983
|
-
className: "dsm-workbuddy-
|
|
2984
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.
|
|
2985
|
-
|
|
2961
|
+
className: "dsm-workbuddy-models-head-actions",
|
|
2962
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2963
|
+
type: "button",
|
|
2964
|
+
className: "dsm-btn dsm-btn-outline",
|
|
2965
|
+
disabled: busy,
|
|
2966
|
+
onClick: () => {
|
|
2967
|
+
refreshModels();
|
|
2968
|
+
},
|
|
2969
|
+
children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
|
|
2970
|
+
})
|
|
2971
|
+
})
|
|
2972
|
+
]
|
|
2973
|
+
}),
|
|
2974
|
+
probeError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2975
|
+
className: "dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-bad",
|
|
2976
|
+
role: "alert",
|
|
2977
|
+
children: t("row.probeError", { message: probeError })
|
|
2978
|
+
}),
|
|
2979
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2980
|
+
className: "dsm-workbuddy-model-list",
|
|
2981
|
+
children: visibleModels.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2982
|
+
className: `dsm-workbuddy-model${activeEnabledIds.has(model.id) ? "" : " dsm-workbuddy-model-disabled"}`,
|
|
2983
|
+
children: [
|
|
2984
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2985
|
+
className: "dsm-workbuddy-model-head",
|
|
2986
2986
|
children: [
|
|
2987
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
2988
|
-
|
|
2989
|
-
|
|
2987
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
2988
|
+
className: "dsm-workbuddy-model-enabled",
|
|
2989
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2990
|
+
type: "checkbox",
|
|
2991
|
+
checked: activeEnabledIds.has(model.id),
|
|
2992
|
+
disabled: !canWrite || saving,
|
|
2993
|
+
onChange: () => {
|
|
2994
|
+
toggleModel(model.id);
|
|
2995
|
+
}
|
|
2996
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2997
|
+
className: "dsm-workbuddy-model-copy",
|
|
2998
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2999
|
+
className: "dsm-workbuddy-model-name",
|
|
3000
|
+
children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3001
|
+
className: "dsm-workbuddy-model-name-rate",
|
|
3002
|
+
children: [
|
|
3003
|
+
"(",
|
|
3004
|
+
model.creditMultiplier.toFixed(2),
|
|
3005
|
+
"x)"
|
|
3006
|
+
]
|
|
3007
|
+
})]
|
|
3008
|
+
})
|
|
3009
|
+
})]
|
|
3010
|
+
}),
|
|
3011
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
3012
|
+
className: "dsm-workbuddy-model-image",
|
|
3013
|
+
title: imageCheckboxHint(model, t),
|
|
3014
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
3015
|
+
type: "checkbox",
|
|
3016
|
+
checked: activeImageIds.has(model.id),
|
|
3017
|
+
disabled: !canWrite || saving,
|
|
3018
|
+
onChange: () => {
|
|
3019
|
+
toggleImage(model.id);
|
|
3020
|
+
}
|
|
3021
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelImage") })]
|
|
3022
|
+
}),
|
|
3023
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
|
|
3024
|
+
className: "dsm-workbuddy-context-budget",
|
|
3025
|
+
"aria-label": t("row.contextBudget"),
|
|
3026
|
+
children: [model.nativeContextWindow > 2e5 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
3027
|
+
type: "radio",
|
|
3028
|
+
name: `context-${model.id}`,
|
|
3029
|
+
checked: (activeContextBudgets[model.id] ?? 2e5) === 2e5,
|
|
3030
|
+
disabled: !canWrite || saving,
|
|
3031
|
+
onChange: () => {
|
|
3032
|
+
setContextBudget(model.id, 2e5);
|
|
3033
|
+
}
|
|
3034
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "200K" })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
3035
|
+
type: "radio",
|
|
3036
|
+
name: `context-${model.id}`,
|
|
3037
|
+
checked: model.nativeContextWindow <= 2e5 || activeContextBudgets[model.id] === model.nativeContextWindow,
|
|
3038
|
+
disabled: model.nativeContextWindow <= 2e5 || !canWrite || saving,
|
|
3039
|
+
onChange: () => {
|
|
3040
|
+
setContextBudget(model.id, model.nativeContextWindow);
|
|
3041
|
+
}
|
|
3042
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) })] })]
|
|
3043
|
+
}),
|
|
3044
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3045
|
+
className: "dsm-workbuddy-model-actions-buttons",
|
|
3046
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3047
|
+
type: "button",
|
|
3048
|
+
className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
|
|
3049
|
+
disabled: probing[model.id] === true || status.status !== "signed-in",
|
|
3050
|
+
title: t("row.probeHint"),
|
|
3051
|
+
onClick: () => {
|
|
3052
|
+
probeModels([model.id]);
|
|
3053
|
+
},
|
|
3054
|
+
children: probing[model.id] === true ? t("row.probing") : t("row.probe")
|
|
3055
|
+
})
|
|
3056
|
+
})
|
|
2990
3057
|
]
|
|
2991
|
-
})
|
|
3058
|
+
}),
|
|
3059
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3060
|
+
className: "dsm-workbuddy-model-details",
|
|
3061
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3062
|
+
className: "dsm-workbuddy-model-meta",
|
|
3063
|
+
children: [
|
|
3064
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelContext", { context: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) }) }),
|
|
3065
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelOutput", { output: formatCapacity(model.maxTokens, t("row.modelUnknown")) }) }),
|
|
3066
|
+
model.reasoning === void 0 || model.reasoning.supportedEfforts === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelReasoning", { efforts: model.reasoning.supportedEfforts.join(" / ") }) })
|
|
3067
|
+
]
|
|
3068
|
+
})
|
|
3069
|
+
}),
|
|
3070
|
+
(() => {
|
|
3071
|
+
const result = probes[activeRegion]?.[model.id];
|
|
3072
|
+
if (result === void 0) return null;
|
|
3073
|
+
const view = probeResultView(result, t);
|
|
3074
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
3075
|
+
className: `dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-${view.tone}`,
|
|
3076
|
+
role: "status",
|
|
3077
|
+
children: [view.text, result.message === void 0 ? null : " · " + result.message]
|
|
3078
|
+
});
|
|
3079
|
+
})()
|
|
3080
|
+
]
|
|
3081
|
+
}, model.id))
|
|
3082
|
+
}),
|
|
3083
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
3084
|
+
className: "dsm-workbuddy-model-capability-note",
|
|
3085
|
+
children: t("row.modelCapabilityPending")
|
|
3086
|
+
}),
|
|
3087
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3088
|
+
className: "dsm-workbuddy-model-actions",
|
|
3089
|
+
children: [
|
|
3090
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
|
|
3091
|
+
className: "dsm-workbuddy-usage-cheer",
|
|
3092
|
+
href: WORKBUDDY_GITHUB_URL,
|
|
3093
|
+
target: "_blank",
|
|
3094
|
+
rel: "noopener noreferrer",
|
|
3095
|
+
children: [t("row.cheer"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3096
|
+
className: "dsm-workbuddy-usage-cheer-star",
|
|
3097
|
+
"aria-hidden": "true",
|
|
3098
|
+
children: "★"
|
|
3099
|
+
})]
|
|
2992
3100
|
}),
|
|
2993
|
-
((
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3101
|
+
saveError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3102
|
+
className: "dsm-workbuddy-model-save-error",
|
|
3103
|
+
children: t("row.saveError", { message: saveError })
|
|
3104
|
+
}),
|
|
3105
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3106
|
+
className: "dsm-workbuddy-model-actions-buttons",
|
|
3107
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3108
|
+
type: "button",
|
|
3109
|
+
className: "dsm-btn dsm-btn-outline",
|
|
3110
|
+
disabled: !dirty || saving || poolBusy || togglingRegion !== void 0,
|
|
3111
|
+
onClick: discardModels,
|
|
3112
|
+
children: t("row.discard")
|
|
3113
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3114
|
+
type: "button",
|
|
3115
|
+
className: "dsm-btn dsm-btn-primary",
|
|
3116
|
+
disabled: !dirty || saving || poolBusy || togglingRegion !== void 0 || activeEnabledIds.size === 0,
|
|
3117
|
+
onClick: () => {
|
|
3118
|
+
saveModels();
|
|
3119
|
+
},
|
|
3120
|
+
children: saving ? t("row.saving") : t("row.save")
|
|
3121
|
+
})]
|
|
3122
|
+
})
|
|
3003
3123
|
]
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
className: "dsm-workbuddy-model-capability-note",
|
|
3008
|
-
children: t("row.modelCapabilityPending")
|
|
3009
|
-
}),
|
|
3010
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3011
|
-
className: "dsm-workbuddy-model-actions",
|
|
3012
|
-
children: [
|
|
3013
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
|
|
3014
|
-
className: "dsm-workbuddy-usage-cheer",
|
|
3015
|
-
href: WORKBUDDY_GITHUB_URL,
|
|
3016
|
-
target: "_blank",
|
|
3017
|
-
rel: "noopener noreferrer",
|
|
3018
|
-
children: [t("row.cheer"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3019
|
-
className: "dsm-workbuddy-usage-cheer-star",
|
|
3020
|
-
"aria-hidden": "true",
|
|
3021
|
-
children: "★"
|
|
3022
|
-
})]
|
|
3023
|
-
}),
|
|
3024
|
-
saveError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3025
|
-
className: "dsm-workbuddy-model-save-error",
|
|
3026
|
-
children: t("row.saveError", { message: saveError })
|
|
3027
|
-
}),
|
|
3028
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3029
|
-
className: "dsm-workbuddy-model-actions-buttons",
|
|
3030
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3031
|
-
type: "button",
|
|
3032
|
-
className: "dsm-btn dsm-btn-outline",
|
|
3033
|
-
disabled: !dirty || saving || poolBusy || togglingRegion !== void 0,
|
|
3034
|
-
onClick: discardModels,
|
|
3035
|
-
children: t("row.discard")
|
|
3036
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3037
|
-
type: "button",
|
|
3038
|
-
className: "dsm-btn dsm-btn-primary",
|
|
3039
|
-
disabled: !dirty || saving || poolBusy || togglingRegion !== void 0 || activeEnabledIds.size === 0,
|
|
3040
|
-
onClick: () => {
|
|
3041
|
-
saveModels();
|
|
3042
|
-
},
|
|
3043
|
-
children: saving ? t("row.saving") : t("row.save")
|
|
3044
|
-
})]
|
|
3045
|
-
})
|
|
3046
|
-
]
|
|
3047
|
-
})
|
|
3048
|
-
]
|
|
3124
|
+
})
|
|
3125
|
+
]
|
|
3126
|
+
})
|
|
3049
3127
|
}),
|
|
3050
3128
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountPool, {
|
|
3051
3129
|
t,
|
|
@@ -3157,6 +3235,10 @@ window.__ModuleLoader__.load({
|
|
|
3157
3235
|
"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.",
|
|
3158
3236
|
"row.modelsTitle": "Models",
|
|
3159
3237
|
"row.modelsSummary": "{count} enabled",
|
|
3238
|
+
"row.modelsDirty": "unsaved changes",
|
|
3239
|
+
"row.modelsFoldHint": "Expand or collapse the model list",
|
|
3240
|
+
"row.modelsFoldShow": "Click to expand",
|
|
3241
|
+
"row.modelsFoldHide": "Click to collapse",
|
|
3160
3242
|
"row.modelsRefresh": "Refresh from WorkBuddy",
|
|
3161
3243
|
"row.modelsRefreshing": "Refreshing models…",
|
|
3162
3244
|
"row.discard": "Discard changes",
|
|
@@ -3350,6 +3432,10 @@ window.__ModuleLoader__.load({
|
|
|
3350
3432
|
"row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方列表中重新选择一个当前账号。重新登录桌面端 App 无法修复此问题。",
|
|
3351
3433
|
"row.modelsTitle": "模型",
|
|
3352
3434
|
"row.modelsSummary": "已启用 {count} 个",
|
|
3435
|
+
"row.modelsDirty": "有未保存的修改",
|
|
3436
|
+
"row.modelsFoldHint": "展开或收起模型列表",
|
|
3437
|
+
"row.modelsFoldShow": "点击展开",
|
|
3438
|
+
"row.modelsFoldHide": "点击收起",
|
|
3353
3439
|
"row.modelsRefresh": "从 WorkBuddy 刷新",
|
|
3354
3440
|
"row.modelsRefreshing": "正在刷新模型…",
|
|
3355
3441
|
"row.discard": "放弃修改",
|
|
@@ -6,6 +6,7 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
|
6
6
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
7
7
|
import { execFile, execFileSync } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
9
10
|
//#region src/at-rest.ts
|
|
10
11
|
/**
|
|
11
12
|
* WorkBuddy desktop "at-rest" credential decryption.
|
|
@@ -535,9 +536,53 @@ function billingHeaders(credential) {
|
|
|
535
536
|
return headers;
|
|
536
537
|
}
|
|
537
538
|
/**
|
|
539
|
+
* Stand-in system message for a request that reached the wire carrying none.
|
|
540
|
+
*
|
|
541
|
+
* Not a stylistic default: both WorkBuddy gateways want the conversation to
|
|
542
|
+
* OPEN with a system message, and the international one enforces it — a
|
|
543
|
+
* user-first body there is refused with business code 11128
|
|
544
|
+
* (`first message is not system prompt`), which the gateway surfaces as
|
|
545
|
+
* "blocked by security policy". The domestic gateway tolerates the same body,
|
|
546
|
+
* so the fault only ever shows up on the international route (issue: a global
|
|
547
|
+
* model failing every step while the CN one is fine).
|
|
548
|
+
*
|
|
549
|
+
* A system message can go missing before this module ever sees the body:
|
|
550
|
+
* `dsh-llm-pi-ai` folds a leading `system` message into `Context.systemPrompt`
|
|
551
|
+
* and pi-ai only emits that prompt `if (context.systemPrompt)` — so an EMPTY
|
|
552
|
+
* prompt emits no system message at all, and pi-ai demotes any `system` entry
|
|
553
|
+
* left in `messages` to `user`. By the time the shim holds the JSON, the real
|
|
554
|
+
* prompt is no longer recoverable, and a minimal placeholder is strictly better
|
|
555
|
+
* than a guaranteed 400.
|
|
556
|
+
*
|
|
557
|
+
* Deliberately tiny: this is a last-resort placeholder, not a persona. Inventing
|
|
558
|
+
* a longer one would quietly change model behaviour on every affected request.
|
|
559
|
+
*/
|
|
560
|
+
const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
561
|
+
/**
|
|
562
|
+
* Make the conversation open with a system message, prepending the fallback
|
|
563
|
+
* when nothing else supplies one.
|
|
564
|
+
*
|
|
565
|
+
* `developer` is normalized to `system` first (the gateways reject
|
|
566
|
+
* `developer`), so an ordinary DSH request already satisfies this and the
|
|
567
|
+
* function is a no-op for it.
|
|
568
|
+
*/
|
|
569
|
+
function ensureSystemHead(obj) {
|
|
570
|
+
const messages = obj["messages"];
|
|
571
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
572
|
+
const head = messages[0];
|
|
573
|
+
if (typeof head !== "object" || head === null || Array.isArray(head)) return;
|
|
574
|
+
const role = head["role"];
|
|
575
|
+
if (typeof role === "string" && role.trim().toLowerCase() === "system") return;
|
|
576
|
+
messages.unshift({
|
|
577
|
+
role: "system",
|
|
578
|
+
content: WORKBUDDY_FALLBACK_SYSTEM_PROMPT
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
538
582
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
539
|
-
* force `stream: true` (the upstream rejects non-streaming)
|
|
540
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400)
|
|
583
|
+
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
584
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
585
|
+
* and guarantee a leading system message.
|
|
541
586
|
*/
|
|
542
587
|
function prepareChatBody(source) {
|
|
543
588
|
let body;
|
|
@@ -554,6 +599,7 @@ function prepareChatBody(source) {
|
|
|
554
599
|
const message = value;
|
|
555
600
|
if (message["role"] === "developer") message["role"] = "system";
|
|
556
601
|
}
|
|
602
|
+
ensureSystemHead(obj);
|
|
557
603
|
normalizeToolChoice(obj);
|
|
558
604
|
return JSON.stringify(obj);
|
|
559
605
|
}
|
|
@@ -2284,9 +2330,136 @@ var WorkBuddyCatalog = class {
|
|
|
2284
2330
|
}
|
|
2285
2331
|
};
|
|
2286
2332
|
//#endregion
|
|
2333
|
+
//#region src/pi-ai-runtime.ts
|
|
2334
|
+
/**
|
|
2335
|
+
* Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
|
|
2336
|
+
*
|
|
2337
|
+
* WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
|
|
2338
|
+
* peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
|
|
2339
|
+
* environment provides — and the two generations consume DIFFERENT context
|
|
2340
|
+
* shapes:
|
|
2341
|
+
*
|
|
2342
|
+
* - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
|
|
2343
|
+
* 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
|
|
2344
|
+
* declarations live ONLY on that leading system message; the top-level
|
|
2345
|
+
* `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
|
|
2346
|
+
* back.
|
|
2347
|
+
* - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
|
|
2348
|
+
* `system` message inside `messages` at all: its token estimator crashes on
|
|
2349
|
+
* one (issue #24), and its wire converter silently drops it.
|
|
2350
|
+
*
|
|
2351
|
+
* Which copy resolves is NOT predictable from the host version alone: a
|
|
2352
|
+
* nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
|
|
2353
|
+
* symlinked checkout does exactly that), while a clean market install has no
|
|
2354
|
+
* local copy at all and resolves the host's (issue #26's reporter). The
|
|
2355
|
+
* adapter therefore gates on what THIS plugin resolves.
|
|
2356
|
+
*
|
|
2357
|
+
* HOW it is detected — deliberately WITHOUT importing pi-ai:
|
|
2358
|
+
* `import.meta.resolve` locates the entry file (executing nothing), the
|
|
2359
|
+
* package manifest sitting beside it names the version, and the generation is
|
|
2360
|
+
* decided from that version (0.87+ → modern). A static import would drag
|
|
2361
|
+
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
2362
|
+
* installs where NO local pi-ai exists and the module is only provided inside
|
|
2363
|
+
* the DSH host — a static import there is a load-time crash. Resolution
|
|
2364
|
+
* itself does not need the module: within the peer range, the manifest
|
|
2365
|
+
* version and the `normalizeContext` feature (present from 0.87 on, not
|
|
2366
|
+
* before) agree.
|
|
2367
|
+
*
|
|
2368
|
+
* When resolution FAILS — the normal state of a market install probed outside
|
|
2369
|
+
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
2370
|
+
* Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
|
|
2371
|
+
* 0.87.1, so the reported generation defaults to `modern` (pass-through).
|
|
2372
|
+
* That default is safe on older hosts too: they never normalize, and a native
|
|
2373
|
+
* 0.85-shaped context is an identity under BOTH branches of the adapter gate.
|
|
2374
|
+
*
|
|
2375
|
+
* `doctor` prints the resolved version and path so mixed-generation setups —
|
|
2376
|
+
* the silent kind that produced #24 and #26 — become visible on demand.
|
|
2377
|
+
*
|
|
2378
|
+
* @module dsh-connect-workbuddy/pi-ai-runtime
|
|
2379
|
+
*/
|
|
2380
|
+
let cached;
|
|
2381
|
+
/** Resolve (once) which pi-ai copy this plugin runs against. */
|
|
2382
|
+
function piAiRuntimeInfo() {
|
|
2383
|
+
cached ??= piAiRuntimeFor(resolveModuleFile());
|
|
2384
|
+
return cached;
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* The decision table for one resolution result, exported so the branch that
|
|
2388
|
+
* only ever runs on OTHER machines (a market install with no local pi-ai) is
|
|
2389
|
+
* pinned by tests rather than by this comment.
|
|
2390
|
+
*
|
|
2391
|
+
* `undefined` means nothing local resolved — inside DSH the host supplies the
|
|
2392
|
+
* module, and every host that normalizes contexts (0.2.0+, via 0.87.1) ships
|
|
2393
|
+
* the modern generation, so that is the default. Older hosts never normalize,
|
|
2394
|
+
* and their native 0.85-shaped context is an identity under either gate
|
|
2395
|
+
* branch, so the default cannot hurt them.
|
|
2396
|
+
*/
|
|
2397
|
+
function piAiRuntimeFor(resolvedFrom) {
|
|
2398
|
+
if (resolvedFrom === void 0) return {
|
|
2399
|
+
generation: "modern",
|
|
2400
|
+
resolvedFrom: void 0,
|
|
2401
|
+
version: void 0,
|
|
2402
|
+
resolvedLocally: false
|
|
2403
|
+
};
|
|
2404
|
+
const version = readVersion(resolvedFrom);
|
|
2405
|
+
return {
|
|
2406
|
+
generation: generationOf(version),
|
|
2407
|
+
resolvedFrom,
|
|
2408
|
+
version,
|
|
2409
|
+
resolvedLocally: true
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* The file the main entry resolved to; undefined when resolution itself fails.
|
|
2414
|
+
*
|
|
2415
|
+
* `import.meta.resolve` (not `require.resolve`): pi-ai's exports map exposes
|
|
2416
|
+
* "." under the `import` condition only, so the CJS resolver refuses the bare
|
|
2417
|
+
* specifier outright — the ESM resolver is the one this plugin actually
|
|
2418
|
+
* imports with, so it is also the one whose answer is meaningful here.
|
|
2419
|
+
*/
|
|
2420
|
+
function resolveModuleFile() {
|
|
2421
|
+
try {
|
|
2422
|
+
return fileURLToPath(import.meta.resolve("@earendil-works/pi-ai"));
|
|
2423
|
+
} catch {
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* The version of the package owning `moduleFile`. pi-ai's exports map does
|
|
2429
|
+
* not expose `./package.json`, so the manifest is found by walking up from
|
|
2430
|
+
* the resolved entry — `dist/index.js` sits directly inside the package —
|
|
2431
|
+
* and claiming it only when its `name` matches, so a same-named ancestor
|
|
2432
|
+
* (a pnpm virtual-store parent, for instance) cannot be misread.
|
|
2433
|
+
*/
|
|
2434
|
+
function readVersion(moduleFile) {
|
|
2435
|
+
if (moduleFile === void 0) return void 0;
|
|
2436
|
+
let directory = dirname(moduleFile);
|
|
2437
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
2438
|
+
try {
|
|
2439
|
+
const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
|
2440
|
+
if (parsed.name === "@earendil-works/pi-ai" && typeof parsed.version === "string") return parsed.version;
|
|
2441
|
+
} catch {}
|
|
2442
|
+
const parent = dirname(directory);
|
|
2443
|
+
if (parent === directory) return void 0;
|
|
2444
|
+
directory = parent;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* The generation a manifest version belongs to. Within the peer range
|
|
2449
|
+
* (<0.88.0) the split is exactly `0.87+` versus earlier; an unreadable
|
|
2450
|
+
* version conservatively reports `legacy` (and doctor shows it as
|
|
2451
|
+
* unreadable, so the guess is visible rather than silent).
|
|
2452
|
+
*/
|
|
2453
|
+
function generationOf(version) {
|
|
2454
|
+
if (version === void 0) return "legacy";
|
|
2455
|
+
const match = /^0\.(\d+)\./u.exec(version);
|
|
2456
|
+
if (match === null) return "legacy";
|
|
2457
|
+
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
2458
|
+
}
|
|
2459
|
+
//#endregion
|
|
2287
2460
|
//#region src/version.ts
|
|
2288
2461
|
/** The npm package version this build was produced from. */
|
|
2289
|
-
const WORKBUDDY_CONNECT_VERSION = "3.0
|
|
2462
|
+
const WORKBUDDY_CONNECT_VERSION = "3.1.0";
|
|
2290
2463
|
//#endregion
|
|
2291
2464
|
//#region src/host-heartbeat.ts
|
|
2292
2465
|
/**
|
|
@@ -2444,4 +2617,4 @@ async function clearHostHeartbeat() {
|
|
|
2444
2617
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
2445
2618
|
}
|
|
2446
2619
|
//#endregion
|
|
2447
|
-
export {
|
|
2620
|
+
export { workbuddyAppExecutableCandidates as $, CREDENTIAL_REJECTED_CODE as A, regionOf as B, defaultDesktopAuthPath as C, parseWorkBuddyAuth as D, legacyWorkbuddyOwnAuthPath as E, isCredentialRejectedError as F, fetchAtRestKeyPayload as G, clearAtRestKeyCache as H, parseCreditMultiplier as I, isWorkbuddyBundle as J, findWorkbuddyAppExecutable as K, parseReasoning as L, WorkBuddyCredentialRejectedError as M, WorkBuddyUpstreamClient as N, workbuddyAccountId as O, classifyUpstreamError as P, readAtRestKey as Q, parseUpstreamModel as R, defaultDesktopAuthDirs as S, isEncryptedCredentialError as T, deriveAtRestKey as U, WORKBUDDY_APP_EXECUTABLE_ENV as V, deriveAtRestKeyId as W, macosNestedAppBundles as X, macosBundleExecutable as Y, openEncryptedField as Z, WORKBUDDY_AUTH_FILE_ENV as _, readHostHeartbeat as a, authFileName as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, WorkBuddyCatalog as f, WORKBUDDY_AUTH_FILENAME as g, ENCRYPTED_CREDENTIAL_CODE as h, processStartTimeMs as i, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as j, workbuddyOwnAuthPath as k, piAiRuntimeInfo as l, fallbackModelsFor as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, deriveCatalog as p, isEncryptedFieldWrapper as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, WorkBuddyCredentialStore as v, hasEncryptedCredentialFields as w, defaultDesktopAuthCandidates as x, WorkBuddyEncryptedCredentialError as y, prepareChatBody as z };
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import "@earendil-works/pi-ai";
|
|
2
3
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
3
4
|
import { Context } from "@deepseek-ai/cordis";
|
|
4
5
|
import { SettingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
@@ -568,8 +569,9 @@ declare function classifyUpstreamError(status: number, body: string): UpstreamEr
|
|
|
568
569
|
declare function regionOf(domain: string): WorkBuddyRegion;
|
|
569
570
|
/**
|
|
570
571
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
571
|
-
* force `stream: true` (the upstream rejects non-streaming)
|
|
572
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400)
|
|
572
|
+
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
573
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
574
|
+
* and guarantee a leading system message.
|
|
573
575
|
*/
|
|
574
576
|
declare function prepareChatBody(source: string): string;
|
|
575
577
|
/** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
|
|
@@ -804,6 +806,56 @@ interface WorkBuddyShimOptions {
|
|
|
804
806
|
*/
|
|
805
807
|
declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddyShim;
|
|
806
808
|
//#endregion
|
|
809
|
+
//#region src/pi-ai-runtime.d.ts
|
|
810
|
+
/**
|
|
811
|
+
* Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
|
|
812
|
+
*
|
|
813
|
+
* WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
|
|
814
|
+
* peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
|
|
815
|
+
* environment provides — and the two generations consume DIFFERENT context
|
|
816
|
+
* shapes:
|
|
817
|
+
*
|
|
818
|
+
* - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
|
|
819
|
+
* 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
|
|
820
|
+
* declarations live ONLY on that leading system message; the top-level
|
|
821
|
+
* `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
|
|
822
|
+
* back.
|
|
823
|
+
* - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
|
|
824
|
+
* `system` message inside `messages` at all: its token estimator crashes on
|
|
825
|
+
* one (issue #24), and its wire converter silently drops it.
|
|
826
|
+
*
|
|
827
|
+
* Which copy resolves is NOT predictable from the host version alone: a
|
|
828
|
+
* nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
|
|
829
|
+
* symlinked checkout does exactly that), while a clean market install has no
|
|
830
|
+
* local copy at all and resolves the host's (issue #26's reporter). The
|
|
831
|
+
* adapter therefore gates on what THIS plugin resolves.
|
|
832
|
+
*
|
|
833
|
+
* HOW it is detected — deliberately WITHOUT importing pi-ai:
|
|
834
|
+
* `import.meta.resolve` locates the entry file (executing nothing), the
|
|
835
|
+
* package manifest sitting beside it names the version, and the generation is
|
|
836
|
+
* decided from that version (0.87+ → modern). A static import would drag
|
|
837
|
+
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
838
|
+
* installs where NO local pi-ai exists and the module is only provided inside
|
|
839
|
+
* the DSH host — a static import there is a load-time crash. Resolution
|
|
840
|
+
* itself does not need the module: within the peer range, the manifest
|
|
841
|
+
* version and the `normalizeContext` feature (present from 0.87 on, not
|
|
842
|
+
* before) agree.
|
|
843
|
+
*
|
|
844
|
+
* When resolution FAILS — the normal state of a market install probed outside
|
|
845
|
+
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
846
|
+
* Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
|
|
847
|
+
* 0.87.1, so the reported generation defaults to `modern` (pass-through).
|
|
848
|
+
* That default is safe on older hosts too: they never normalize, and a native
|
|
849
|
+
* 0.85-shaped context is an identity under BOTH branches of the adapter gate.
|
|
850
|
+
*
|
|
851
|
+
* `doctor` prints the resolved version and path so mixed-generation setups —
|
|
852
|
+
* the silent kind that produced #24 and #26 — become visible on demand.
|
|
853
|
+
*
|
|
854
|
+
* @module dsh-connect-workbuddy/pi-ai-runtime
|
|
855
|
+
*/
|
|
856
|
+
/** The resolved pi-ai generation, by context-shape capability. */
|
|
857
|
+
type PiAiGeneration = 'modern' | 'legacy';
|
|
858
|
+
//#endregion
|
|
807
859
|
//#region src/adapter.d.ts
|
|
808
860
|
/** Provider route this bundle owns for the domestic (CN) gateway. */
|
|
809
861
|
declare const WORKBUDDY_PROVIDER = "workbuddy";
|
|
@@ -828,6 +880,12 @@ interface WorkBuddyAdapterOptions {
|
|
|
828
880
|
displayName?: string;
|
|
829
881
|
/** Resolve the durable attachment service at request time, when present. */
|
|
830
882
|
resolveAttachments?: () => AttachmentStore | undefined;
|
|
883
|
+
/**
|
|
884
|
+
* Override the pi-ai generation gate (see {@link piAiRuntimeInfo}); tests
|
|
885
|
+
* inject both branches because this checkout only ever RESOLVES one of
|
|
886
|
+
* them. Production callers leave it unset.
|
|
887
|
+
*/
|
|
888
|
+
piAiGeneration?: PiAiGeneration;
|
|
831
889
|
}
|
|
832
890
|
/** What {@link createWorkBuddyAdapter} hands back. */
|
|
833
891
|
interface WorkBuddyAdapter {
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { $ as workbuddyAppExecutableCandidates, A as CREDENTIAL_REJECTED_CODE, B as regionOf, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as isCredentialRejectedError, G as fetchAtRestKeyPayload, H as clearAtRestKeyCache, I as parseCreditMultiplier, J as isWorkbuddyBundle, K as findWorkbuddyAppExecutable, L as parseReasoning, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as readAtRestKey, R as parseUpstreamModel, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as deriveAtRestKey, V as WORKBUDDY_APP_EXECUTABLE_ENV, W as deriveAtRestKeyId, X as macosNestedAppBundles, Y as macosBundleExecutable, Z as openEncryptedField, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as isEncryptedFieldWrapper, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as prepareChatBody } from "./host-heartbeat-DZnPggVT.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
@@ -213,6 +213,152 @@ function toPiModel(info, baseUrl, providerId) {
|
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
/**
|
|
216
|
+
* Rewrite a pi-ai 0.87-shaped transcript into the 0.85 shape this plugin's
|
|
217
|
+
* pi-ai understands — prompt AND tools.
|
|
218
|
+
*
|
|
219
|
+
* WHY THIS EXISTS (issue #24). pi-ai 0.87's `normalizeContext` moves the system
|
|
220
|
+
* prompt INTO `messages` as `{ role: 'system', content: '<string>' }`. pi-ai
|
|
221
|
+
* 0.85's own `Message` union has no `system` variant
|
|
222
|
+
* (`UserMessage | AssistantMessage | ToolResultMessage`), so its
|
|
223
|
+
* `estimateMessageTokens` has no branch for one: `for (const block of
|
|
224
|
+
* message.content)` iterates the CONTENT STRING character by character, `block`
|
|
225
|
+
* is a single character, and `block.name.length` throws
|
|
226
|
+
* `Cannot read properties of undefined (reading 'length')`.
|
|
227
|
+
*
|
|
228
|
+
* That crash happens inside the library, in `buildBaseOptions ->
|
|
229
|
+
* clampMaxTokensToContext -> estimateContextTokens`, i.e. BEFORE any request is
|
|
230
|
+
* built — so every model fails instantly and no upstream traffic is sent. A
|
|
231
|
+
* host running 0.87 hands us the normalized transcript while our own provider
|
|
232
|
+
* is 0.85, and the plugin cannot patch the library. What it CAN do is not hand
|
|
233
|
+
* a 0.87 transcript to a 0.85 API object.
|
|
234
|
+
*
|
|
235
|
+
* The 0.87 transcript carries MORE than the prompt on that leading system
|
|
236
|
+
* message: `toolsAdded` (and `toolsRemoved`) are the ONLY place the tool
|
|
237
|
+
* declarations live — `normalizeContext` deleted the top-level `tools` field,
|
|
238
|
+
* and 0.85's api reads tools ONLY from `context.tools`. So folding the text
|
|
239
|
+
* without promoting the tool state would trade issue #24's loud crash for
|
|
240
|
+
* issue #26's silent one: models that can never emit a `toolCall` because the
|
|
241
|
+
* request never declared any tools. The fold here promotes both, replaying
|
|
242
|
+
* every system message's `toolsRemoved`/`toolsAdded` in order — exactly the
|
|
243
|
+
* merge pi-ai 0.87's own `getCurrentTools` performs — and leaves an existing
|
|
244
|
+
* top-level `tools` array untouched.
|
|
245
|
+
*
|
|
246
|
+
* Deliberately SHAPE-based within the legacy branch, not version-based: it
|
|
247
|
+
* asks "does this context carry a system message inside `messages`?" rather
|
|
248
|
+
* than "which pi-ai version is loaded?". A version check would be wrong the
|
|
249
|
+
* moment either side moves. Which BRANCH runs at all IS generation-gated —
|
|
250
|
+
* see {@link withLegacyContext}: a modern (0.87+) api consumes this very
|
|
251
|
+
* transcript natively, and folding for it would destroy its only carrier of
|
|
252
|
+
* prompt and tools (issues #25/#26).
|
|
253
|
+
*
|
|
254
|
+
* Returns the SAME object when there is nothing to adapt — in particular when
|
|
255
|
+
* `systemPrompt` is already set (the native 0.85 shape), so the ordinary path
|
|
256
|
+
* is byte-for-byte untouched.
|
|
257
|
+
*
|
|
258
|
+
* Two guards beyond the 0.87 fold, both about a request reaching the upstream
|
|
259
|
+
* with NO system message — which the international gateway refuses outright
|
|
260
|
+
* (business code 11128) while the domestic one tolerates:
|
|
261
|
+
*
|
|
262
|
+
* - an EMPTY `systemPrompt` is treated as absent rather than "already set".
|
|
263
|
+
* pi-ai emits the prompt under `if (context.systemPrompt)`, so an empty one
|
|
264
|
+
* produces no system message at all and pi-ai demotes any `system` entry still
|
|
265
|
+
* in `messages` to `user`. Falling through here lets a real system message
|
|
266
|
+
* that IS present in `messages` be folded up and preserved.
|
|
267
|
+
* - when nothing supplies a prompt, {@link WORKBUDDY_FALLBACK_SYSTEM_PROMPT} is
|
|
268
|
+
* used, because a placeholder beats a guaranteed 400. A system message whose
|
|
269
|
+
* text is empty but which carries `toolsAdded` still folds its TOOLS up; the
|
|
270
|
+
* placeholder for the missing prompt then comes from `ensureSystemHead` at
|
|
271
|
+
* the wire (the last of the three layers).
|
|
272
|
+
*/
|
|
273
|
+
function adaptLegacyPiAiContext(context) {
|
|
274
|
+
if (context === null || typeof context !== "object") return context;
|
|
275
|
+
if (context.systemPrompt) return context;
|
|
276
|
+
const messages = Array.isArray(context.messages) ? context.messages : void 0;
|
|
277
|
+
if (messages === void 0) return context;
|
|
278
|
+
const systemTexts = [];
|
|
279
|
+
const rest = [];
|
|
280
|
+
const toolState = /* @__PURE__ */ new Map();
|
|
281
|
+
let sawSystemMessage = false;
|
|
282
|
+
for (const message of messages) {
|
|
283
|
+
if (message?.role !== "system") {
|
|
284
|
+
rest.push(message);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
sawSystemMessage = true;
|
|
288
|
+
const text = systemTextOf(message);
|
|
289
|
+
if (text !== "") systemTexts.push(text);
|
|
290
|
+
replayToolState(message, toolState);
|
|
291
|
+
}
|
|
292
|
+
const head = rest[0];
|
|
293
|
+
const needsFallback = !sawSystemMessage && head !== void 0 && head.role !== "system";
|
|
294
|
+
if (!sawSystemMessage && !needsFallback) return context;
|
|
295
|
+
const systemPrompt = systemTexts.length > 0 ? systemTexts.join("\n\n") : sawSystemMessage ? void 0 : WORKBUDDY_FALLBACK_SYSTEM_PROMPT;
|
|
296
|
+
const tools = Array.isArray(context.tools) ? void 0 : [...toolState.values()];
|
|
297
|
+
return {
|
|
298
|
+
...context,
|
|
299
|
+
...systemPrompt === void 0 ? {} : { systemPrompt },
|
|
300
|
+
...tools !== void 0 && tools.length > 0 ? { tools } : {},
|
|
301
|
+
messages: rest
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Replay one 0.87 system message's tool deltas into `state`, the same merge
|
|
306
|
+
* pi-ai 0.87's `getCurrentTools` performs: removals first, then additions,
|
|
307
|
+
* keyed by name so a mid-conversation redeclaration replaces the original.
|
|
308
|
+
*/
|
|
309
|
+
function replayToolState(message, state) {
|
|
310
|
+
const removed = message.toolsRemoved;
|
|
311
|
+
if (Array.isArray(removed)) for (const tool of removed) {
|
|
312
|
+
const name = tool?.name;
|
|
313
|
+
if (typeof name === "string") state.delete(name);
|
|
314
|
+
}
|
|
315
|
+
const added = message.toolsAdded;
|
|
316
|
+
if (Array.isArray(added)) for (const tool of added) {
|
|
317
|
+
const name = tool?.name;
|
|
318
|
+
if (typeof name === "string") state.set(name, tool);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** The text of one system message, whether it is a string or text blocks. */
|
|
322
|
+
function systemTextOf(message) {
|
|
323
|
+
const content = message.content;
|
|
324
|
+
if (typeof content === "string") return content;
|
|
325
|
+
if (!Array.isArray(content)) return "";
|
|
326
|
+
const texts = [];
|
|
327
|
+
for (const block of content) {
|
|
328
|
+
if (typeof block !== "object" || block === null) continue;
|
|
329
|
+
const candidate = block;
|
|
330
|
+
if (candidate.type === "text" && typeof candidate.text === "string") texts.push(candidate.text);
|
|
331
|
+
}
|
|
332
|
+
return texts.join("\n\n");
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Wrap one pi-ai API module so both stream entry points receive a context this
|
|
336
|
+
* build can actually consume (see {@link adaptLegacyPiAiContext}) — but only
|
|
337
|
+
* when the module RESOLVED for this plugin is the legacy generation.
|
|
338
|
+
*
|
|
339
|
+
* The gate is the plugin's own module, resolved via {@link piAiRuntimeInfo}
|
|
340
|
+
* (manifest version of the copy this plugin resolves — no pi-ai import): a
|
|
341
|
+
* modern (0.87+) api consumes the normalized
|
|
342
|
+
* transcript natively — its request builder reads the prompt from the leading
|
|
343
|
+
* system message and the tools from that message's `toolsAdded`, and never
|
|
344
|
+
* reads the top-level `systemPrompt`/`tools` fields at all. Folding for it
|
|
345
|
+
* would delete the only carrier of both (issues #25/#26: the international
|
|
346
|
+
* gateway answers the prompt-less body with 400 / 11128, the domestic one
|
|
347
|
+
* accepts it and the model fabricates tool calls it can never actually make).
|
|
348
|
+
* The unwrapped modern api is returned AS-IS — byte-for-byte pass-through.
|
|
349
|
+
*
|
|
350
|
+
* The deferred-fetch entry points take no context and are passed through as-is
|
|
351
|
+
* in both branches.
|
|
352
|
+
*/
|
|
353
|
+
function withLegacyContext(api, generation) {
|
|
354
|
+
if (generation === "modern") return api;
|
|
355
|
+
return {
|
|
356
|
+
...api,
|
|
357
|
+
stream: (model, context, options) => api.stream(model, adaptLegacyPiAiContext(context), options),
|
|
358
|
+
streamSimple: (model, context, options) => api.streamSimple(model, adaptLegacyPiAiContext(context), options)
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
216
362
|
* Assemble the adapter. The provider's `getModels` reads the live catalog,
|
|
217
363
|
* and every model's `baseUrl` is re-resolved per read so the shim's
|
|
218
364
|
* ephemeral port applies from the first snapshot after startup.
|
|
@@ -221,6 +367,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
221
367
|
const { shim, store, catalog, resolveAttachments } = options;
|
|
222
368
|
const providerId = options.provider ?? "workbuddy";
|
|
223
369
|
const providerName = options.displayName ?? "WorkBuddy";
|
|
370
|
+
const generation = options.piAiGeneration ?? piAiRuntimeInfo().generation;
|
|
224
371
|
const buildModels = () => {
|
|
225
372
|
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
226
373
|
return catalog.current().map((info) => toPiModel(info, baseUrl, providerId));
|
|
@@ -240,7 +387,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
240
387
|
}
|
|
241
388
|
} },
|
|
242
389
|
models: buildModels(),
|
|
243
|
-
api: openAICompletionsApi()
|
|
390
|
+
api: withLegacyContext(openAICompletionsApi(), generation)
|
|
244
391
|
}),
|
|
245
392
|
getModels: () => buildModels()
|
|
246
393
|
};
|
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": "3.0
|
|
5
|
+
"version": "3.1.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
|
|
92
92
|
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
93
93
|
"@deepseek-ai/schemastery": ">=3.18.1-rc.1 <4.0.0 || >=3.18.2 <4.0.0",
|
|
94
|
-
"@earendil-works/pi-ai": ">=0.85.0 <0.
|
|
94
|
+
"@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
|
|
95
95
|
"react": "^18.2.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
|
|
111
111
|
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
112
112
|
"@deepseek-ai/schemastery": ">=3.18.1 <4.0.0 || >=3.18.2 <4.0.0",
|
|
113
|
-
"@earendil-works/pi-ai": ">=0.85.0 <0.
|
|
113
|
+
"@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
|
|
114
114
|
"@types/node": "^22.20.0",
|
|
115
115
|
"@types/react": "~18.3.31",
|
|
116
116
|
"@types/react-dom": "18.3.7",
|