dsh-llm-workbuddy 0.1.16 → 0.1.17
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 +50 -0
- package/README.md +80 -19
- package/lib/client.js +51 -14
- package/lib/index.js +328 -28
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@
|
|
|
4
4
|
每次发版请同步 `package.json` 的 `version` 并打一个 `git tag`(如 `v0.1.13`),
|
|
5
5
|
在 GitHub 创建 Release 时本文件即为更新说明来源。
|
|
6
6
|
|
|
7
|
+
## [0.1.17] - 2026-09-11
|
|
8
|
+
|
|
9
|
+
修复社区反馈的两个 issue([#3](https://github.com/zdk119746/dsh-llm-workbuddy/issues/3)、
|
|
10
|
+
[#4](https://github.com/zdk119746/dsh-llm-workbuddy/issues/4))。
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **登录态永远判定为有效,且「登录」按钮无法恢复(issue #3)**。
|
|
14
|
+
代理(workbuddy2api)写入的 `~/.codebuddy-session.json` 里**没有
|
|
15
|
+
`auth.expiresAt`**,而插件把"字段缺失"当作"未过期",于是:
|
|
16
|
+
`authenticated` 恒为 `true`、`POST /api/workbuddy/login` 恒返回
|
|
17
|
+
`alreadyLoggedIn`、诊断面板的「登录状态/登录令牌」恒显示"有效";一旦 token
|
|
18
|
+
真失效,用户点登录没有任何反应,只能回终端手跑脚本。
|
|
19
|
+
现改为**多源推导过期时间**(按优先级):
|
|
20
|
+
1. `auth.expiresAt`(本插件脚本写入;秒/毫秒自动归一化);
|
|
21
|
+
2. `refreshToken` 的 JWT `exp`——代理会自动 refresh,所以 refreshToken 的
|
|
22
|
+
有效期才是会话真正的边界;
|
|
23
|
+
3. `accessToken` 的 JWT `exp`(解码 payload,不校验签名);
|
|
24
|
+
4. `auth.expiresIn` + 会话文件 mtime(兜底,只会低估、不会高估)。
|
|
25
|
+
全部失败时返回 `expiryKnown: false` + `reloginRecommended: true`,
|
|
26
|
+
**不再假装有效**。`/status` 与诊断面板都会带上 `expiresAtSource` 说明来源。
|
|
27
|
+
- **`authUrl` 永远为 `null`,点「登录」在新标签页打不开任何页面(issue #3 相关)**。
|
|
28
|
+
登录路由 `spawn` 后**立刻** `res.end()`,而设备流链接是子进程稍后才打印到
|
|
29
|
+
stdout 的,因此返回的 `authUrl` 恒为 `null`,Web 登录实际上从来没成功打开过
|
|
30
|
+
授权页。现改为等待链接出现(或脚本早退/15 秒超时)再应答,失败时返回结构化
|
|
31
|
+
`error`,前端弹窗提示而不是静默无反应。子进程 stdout 在应答后继续被 drain,
|
|
32
|
+
避免缓冲写满阻塞。
|
|
33
|
+
- **「🎁 签到」必然 404,且每天写入一条失败记录(issue #4)**。
|
|
34
|
+
插件请求代理的 `/checkin-status` 与 `/checkin`,但 workbuddy2api **从未实现**
|
|
35
|
+
这两个端点(已核对 PyPI 2.0.0–2.0.4 与 `main` / `dsh` / `codex` 全部分支),
|
|
36
|
+
且 `autoCheckin` 默认 `true`,于是每天 10 点后必然失败一次并把失败结果持久化。
|
|
37
|
+
现改为**探测 + 优雅降级**:`/checkin-status` 返回 404/405 时判定该代理
|
|
38
|
+
**不支持签到**,隐藏签到入口、自动签到直接跳过(**不写任何状态**)。探测结果
|
|
39
|
+
缓存 30 分钟,`/status` 通过 `checkin.supported` 三态(`true`/`false`/`null`)
|
|
40
|
+
暴露给胶囊。代理补齐端点后入口会自动出现,无需升级插件。
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- **🔑 重新登录(强制重登)**:`POST /api/workbuddy/login?force=1` 忽略本地会话
|
|
44
|
+
判定直接重跑设备流;胶囊**始终**提供该入口(不做条件显示),「判定错误 →
|
|
45
|
+
无法恢复」的耦合从此断开。
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
- 诊断面板新增「令牌过期」(含来源)与重新登录提示;`login` 失败会弹窗说明原因。
|
|
49
|
+
- **README 代理安装指引更正**:原先要求"必须用 GitHub 主分支源码、不能用 PyPI
|
|
50
|
+
2.0.3",实测 PyPI **2.0.4**(2026-09-10 发布)已包含该登录修复(
|
|
51
|
+
`X-Product-Code` / `_enterprise_headers` / `X-Domain` 均在包内),且 GitHub
|
|
52
|
+
`main` 与 2.0.4 **内容完全一致**;而 2.0.3 确实缺少该修复。现改为推荐
|
|
53
|
+
`uv tool install -U workbuddy2api`(`>= 2.0.4`),源码方式作为等价备选。
|
|
54
|
+
- 诊断面板的「重启命令」同步改为 `uv tool install` 方式。
|
|
55
|
+
- README 补充「登录态判定」章节与签到依赖说明。
|
|
56
|
+
|
|
7
57
|
## [0.1.16] - 2026-09-10
|
|
8
58
|
|
|
9
59
|
### Fixed
|
package/README.md
CHANGED
|
@@ -62,13 +62,23 @@
|
|
|
62
62
|
1. **代理必须独立安装并运行**:本插件**不打包**第三方代理 workbuddy2api。
|
|
63
63
|
请在你的机器上单独安装并启动它(见下方「安装代理」),它把 WorkBuddy/CodeBuddy
|
|
64
64
|
的私有协议转成标准 OpenAI chat-completions 格式。
|
|
65
|
-
2.
|
|
66
|
-
|
|
65
|
+
2. 代理版本需 **`workbuddy2api >= 2.0.4`**(推荐直接从 PyPI 装,
|
|
66
|
+
见下方「安装代理」):
|
|
67
|
+
- **2.0.3 及更早不可用**:缺少 `X-Product-Code` / Genie-IDE 等请求头与
|
|
68
|
+
企业认证头(`_enterprise_headers` / `X-Domain`),登录会在最后一步 401;
|
|
69
|
+
- **2.0.4 已包含该修复**("修复登录账户轮询与企业认证 headers 传递"),
|
|
70
|
+
登录成功;GitHub `main` 当前与 2.0.4 内容一致,两者皆可。
|
|
67
71
|
3. 登录使用**插件内置的 `login_workbuddy.py`**(本包自带,依赖系统 `python3`,
|
|
68
72
|
纯标准库,Python 3.7+ 即可),**不需要**代理自带的 `--login`(VSCode platform
|
|
69
73
|
会 401)。
|
|
70
74
|
4. 插件装入 profile 后需要**重启 `dsh web`** 才能加载新的 bundle(包括本小部件)。
|
|
71
75
|
|
|
76
|
+
> **关于「🎁 签到」**:该功能依赖代理提供 `/v1/checkin-status` 与 `/v1/checkin`
|
|
77
|
+
> 转发。截至 `workbuddy2api` 2.0.4,**代理并未实现这两个端点**(任何分支/PyPI
|
|
78
|
+
> 版本都没有)。插件会自动探测:探测到 404 时隐藏签到入口、并且不再每天自动
|
|
79
|
+
> 尝试,因此不会产生无效的失败记录。若将来代理补齐了这两个端点,签到入口会
|
|
80
|
+
> 自动重新出现,无需升级插件。
|
|
81
|
+
|
|
72
82
|
---
|
|
73
83
|
|
|
74
84
|
## 安装插件
|
|
@@ -102,24 +112,35 @@ CLI 会把依赖写进 profile 并把 `dsh-llm-workbuddy` 追加到 `dsh.profile
|
|
|
102
112
|
|
|
103
113
|
### 全新机器 / 标准安装(任何能跑 Python 的机器)
|
|
104
114
|
|
|
105
|
-
别人或你自己在**新机器**上用 npm
|
|
115
|
+
别人或你自己在**新机器**上用 npm 版插件时,推荐直接用 PyPI 装(版本 `>= 2.0.4`):
|
|
106
116
|
|
|
107
117
|
```sh
|
|
108
118
|
# 1. 安装 uv(本机 Python 工具,若已装可跳过)
|
|
109
119
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
110
120
|
|
|
111
|
-
# 2.
|
|
112
|
-
|
|
113
|
-
cd workbuddy2api
|
|
121
|
+
# 2. 安装代理(PyPI,需 >= 2.0.4;2.0.3 及更早缺登录所需请求头)
|
|
122
|
+
uv tool install -U workbuddy2api
|
|
114
123
|
|
|
115
124
|
# 3. 启动代理(监听 127.0.0.1:8787;先完成下方「登录」后再真正调用模型)
|
|
125
|
+
workbuddy2api --desensitize \
|
|
126
|
+
--session-file ~/.codebuddy-session.json \
|
|
127
|
+
--log-file ~/.codebuddy-proxy.jsonl
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
<details>
|
|
131
|
+
<summary>或者:从 GitHub 源码运行(等价,main 与 2.0.4 内容一致)</summary>
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
git clone https://github.com/hawklithm/workbuddy2api.git
|
|
135
|
+
cd workbuddy2api
|
|
116
136
|
uv run python -u -m codebuddy_proxy --desensitize \
|
|
117
137
|
--session-file ~/.codebuddy-session.json \
|
|
118
138
|
--log-file ~/.codebuddy-proxy.jsonl
|
|
119
139
|
```
|
|
140
|
+
</details>
|
|
120
141
|
|
|
121
|
-
>
|
|
122
|
-
>
|
|
142
|
+
> 之后任何时候想重启代理,重跑上面第 3 条命令即可(用 `uv tool` 安装时直接跑
|
|
143
|
+
> `workbuddy2api ...`)。若提示 `address already in use`,先停掉旧代理:
|
|
123
144
|
> `lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill`,再重跑。
|
|
124
145
|
|
|
125
146
|
### 本仓库本地开发(可选快捷方式)
|
|
@@ -161,12 +182,34 @@ curl http://127.0.0.1:8787/v1/models # 模型列表(含 glm-5.2 / deepseek-v4-
|
|
|
161
182
|
解析它打印的设备流链接(`authUrl`)返回给前端;前端用 `window.open` 在**新标签页**
|
|
162
183
|
打开该登录页。
|
|
163
184
|
4. 在新标签页用 WorkBuddy / CodeBuddy 账号(腾讯账号)完成扫码/授权。
|
|
164
|
-
5. 胶囊会自动从每 5 秒轮询加快到每 2 秒(最多 30
|
|
165
|
-
|
|
166
|
-
|
|
185
|
+
5. 胶囊会自动从每 5 秒轮询加快到每 2 秒(最多 30 次),一旦会话的过期时间
|
|
186
|
+
(见下方「登录态判定」)显示仍有效且含 `accessToken`,就切回绿态,
|
|
187
|
+
显示账号昵称。
|
|
167
188
|
|
|
168
189
|
整个过程**不需要离开浏览器、不需要回终端**。
|
|
169
190
|
|
|
191
|
+
> **🔑 重新登录(强制重登)**:胶囊 **⚙️ → 🔑 重新登录** 会带 `?force=1` 调用
|
|
192
|
+
> 登录接口,**忽略本地会话判定**直接重跑设备流。该入口**始终可用**:本地判定
|
|
193
|
+
> 依赖从会话文件推导的过期时间,只是启发式(见下),token 被提前吊销时它仍会
|
|
194
|
+
> 显示"有效",此时普通「登录」只会拿到 `alreadyLoggedIn`。不做条件显示是为了
|
|
195
|
+
> 避免"判定错误 → 无法恢复"的耦合;判定不可靠时 title 会提示"当前建议执行"。
|
|
196
|
+
|
|
197
|
+
### 登录态判定(插件如何判断"是否已登录")
|
|
198
|
+
|
|
199
|
+
代理写入的 `~/.codebuddy-session.json` **没有 `expiresAt` 字段**,所以插件按以下
|
|
200
|
+
优先级推导过期时间,而不是把"字段缺失"当作"永不过期":
|
|
201
|
+
|
|
202
|
+
| 优先级 | 来源 | 说明 |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| 1 | `auth.expiresAt` | 本插件自带 `login_workbuddy.py` 写入;秒/毫秒都会归一化 |
|
|
205
|
+
| 2 | `refreshToken` 的 JWT `exp` | 代理会自动 refresh accessToken,因此 **refreshToken 的有效期才是会话真正的边界** |
|
|
206
|
+
| 3 | `accessToken` 的 JWT `exp` | 解码 JWT payload(不校验签名,只读声明) |
|
|
207
|
+
| 4 | `auth.expiresIn` + 会话文件 mtime | 兜底;只会低估有效期,不会高估 |
|
|
208
|
+
|
|
209
|
+
若以上都无法得出结果,接口会返回 `expiryKnown: false` 并给出
|
|
210
|
+
`reloginRecommended: true`(而不是假装有效);`expiresAtSource` 会说明用的是
|
|
211
|
+
哪一来源,🔍 诊断面板里也会显示。
|
|
212
|
+
|
|
170
213
|
### 方式 B(传统):在终端手动跑
|
|
171
214
|
|
|
172
215
|
使用本包内置的登录脚本(纯标准库,依赖系统 `python3`):
|
|
@@ -197,10 +240,13 @@ Web 登录后终端脚本也读得到同一份会话。
|
|
|
197
240
|
|
|
198
241
|
| 方法 + 路径 | 行为 |
|
|
199
242
|
|---|---|
|
|
200
|
-
| `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile
|
|
201
|
-
| `POST /api/workbuddy/login` |
|
|
202
|
-
| `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
|
|
243
|
+
| `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`),**多源推导**会话过期时间(`expiresAt` → refreshToken/accessToken 的 JWT `exp` → `expiresIn`+mtime)判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 `{ sessionFile, authenticated, expiresAt, expiresAtSource, expiryKnown, expired, tokenPresent, reloginRecommended, account, proxyUp, tokenValid, loginScriptAvailable, checkin: { supported, autoEnabled, handledToday, lastResult } }`;非 GET 返回 405 |
|
|
244
|
+
| `POST /api/workbuddy/login` | 已有有效会话且未指定 `?force=1` 时直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,等它打印出设备流链接后返回 `{ authUrl, pending:true }`(最长等 15 秒,超时/脚本早退返回 `{ error }`);设备流在后台继续,前端轮询 status 感知完成。`?force=1` 强制重跑登录(见「🔑 重新登录」);非 POST 返回 405 |
|
|
245
|
+
| `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`session` 里带 `expiresAtSource` / `expiryKnown` / `reloginRecommended`;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
|
|
203
246
|
| `GET /api/workbuddy/usage` | **用量统计**:读取本地 token 用量台账(`$DSH_HOME/llm-workbuddy/usage.jsonl`),返回 `{ today, byModel, total }`(今日/按模型/累计的 input/output tokens、请求次数,以及**积分消耗 `credit`**);非 GET 返回 405 |
|
|
247
|
+
| `GET /api/workbuddy/checkin` | **签到状态**:返回 `{ supported, handledToday, lastResult, official }`;`supported` 为三态——`true`(代理有签到接口)/ `false`(探测到 404/405,代理未实现)/ `null`(尚未探测出结论)。探测结果缓存 30 分钟;非 GET/POST 返回 405 |
|
|
248
|
+
| `POST /api/workbuddy/checkin` | **立即签到**:透传代理 `/v1/checkin`(幂等,官方对已签到返回业务拒绝)。代理未实现签到接口时返回 `{ ok:false, supported:false, message }` 且**不写入任何失败记录** |
|
|
249
|
+
| `POST /api/workbuddy/refresh-models` | 清空模型发现缓存并重读代理 `/v1/models`,同时发布 `llm/adapters-updated` 让模型选择器立即重载;返回 `{ ok, announced, count, models }` |
|
|
204
250
|
|
|
205
251
|
> 会话文件与登录脚本路径的解析顺序:
|
|
206
252
|
> 1. 配置里显式指定的 `sessionFile` / `loginScript`;
|
|
@@ -214,8 +260,13 @@ Web 登录后终端脚本也读得到同一份会话。
|
|
|
214
260
|
- 每 **5 秒** `GET /api/workbuddy/status`;点「登录」后加快到每 **2 秒**轮询、
|
|
215
261
|
最多 30 次,直到 `authenticated:true`。
|
|
216
262
|
- 胶囊外面只显示一个 **⚙️ 设置** 按钮(带 title「WorkBuddy 设置」)。点击展开菜单,
|
|
217
|
-
|
|
263
|
+
内含 **🔍 诊断 / 📊 用量 / 🎁 签到(按条件)/ 🔄 刷新模型 / 🔑 重新登录**;
|
|
218
264
|
点击页面空白或选中某项后菜单自动收起。
|
|
265
|
+
- **🎁 签到**:**仅当代理真的实现了签到接口时才出现**(`checkin.supported === true`)。
|
|
266
|
+
`workbuddy2api` 至今没有 `/v1/checkin-status` / `/v1/checkin`,此时入口不显示,
|
|
267
|
+
也不会每天自动尝试——避免"点了必然 404"和长期挂着的失败记录。
|
|
268
|
+
- **🔑 重新登录**:**始终显示**,点击后带 `?force=1` 强制重跑设备流,忽略本地
|
|
269
|
+
会话判定(详见「登录态判定」)。
|
|
219
270
|
- **🔍 诊断**:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
|
|
220
271
|
显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
|
|
221
272
|
发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
|
|
@@ -318,8 +369,13 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
|
318
369
|
| 胶囊一直 `…`(加载中) | `GET /api/workbuddy/status` 失败 → 确认 `dsh web` 在跑、端口正确 |
|
|
319
370
|
| 胶囊红 + `代理未运行` | workbuddy2api 代理没起或挂了 → 按「安装代理」章节启动 |
|
|
320
371
|
| 胶囊显示登录成功但模型用不了 | 典型的"代理进程活着但指向旧路径/文件缺失"假象(`/health` 仍显示 ok)。点胶囊 **🔍 诊断**,看 `模型出字` 是否失败;按面板给出的重启命令重启代理 |
|
|
321
|
-
| 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3
|
|
322
|
-
|
|
|
372
|
+
| 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3`;检查安装。若返回了 `error`(脚本 15 秒内没打印授权链接、或退出码非 0),页面会弹出具体原因 |
|
|
373
|
+
| 点「登录」弹出 `alreadyLoggedIn`,但 token 实际已失效 | 本地判定是启发式(会话文件可能没有 `expiresAt`)→ 用 **⚙️ → 🔑 重新登录** 强制重跑登录 |
|
|
374
|
+
| 胶囊绿但所有模型 401 | accessToken/refreshToken 都已过期但判定未察觉 → **⚙️ → 🔑 重新登录**;或先 **🔍 诊断** 看「令牌过期」一行 |
|
|
375
|
+
| 设置菜单里没有「🎁 签到」 | **正常现象**:该代理未实现 `/v1/checkin-status` / `/v1/checkin`(`workbuddy2api` 至今未提供),插件探测到 404 后自动隐藏入口并停止每日自动尝试 |
|
|
376
|
+
| 设置菜单里的「🔑 重新登录」 | 该入口**始终可用**;判定结果不可靠时 title 会提示"当前建议执行" |
|
|
377
|
+
| 新标签页打开后登录完成,胶囊仍是红 | 会话文件(默认 `~/.codebuddy-session.json`)未刷新或已过期 → 刷新页面或点「🔑 重新登录」 |
|
|
378
|
+
| 代理版本报错 / 登录最后一步 401 | 代理过旧(< 2.0.4,缺 `X-Product-Code`、`_enterprise_headers` 等)→ `uv tool install -U workbuddy2api` 升级到 >= 2.0.4 |
|
|
323
379
|
| 启动 `dsh web` 报 `EPERM ... cordis.yml` | `.dsh` 所在系统卷受保护(`/System/Volumes/Data` 带 `protect`)。解决:`sudo chown -R $(whoami) /Users/jiyunyang/.dsh`,或 `export DSH_HOME=$HOME/dsh-home` 后重新 `dsh plugin --profile web add` 并把插件链接进新 home |
|
|
324
380
|
| 模型请求 `TRANSPORT` 错误 | 代理未运行或端口不对(连接被拒绝) |
|
|
325
381
|
|
|
@@ -336,8 +392,13 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
|
|
|
336
392
|
会透传给代理;若某模型平台侧只接受平台默认、忽略该参数,则退化为平台默认强度,不影响出字。
|
|
337
393
|
- 代理未运行时,模型请求会以 `TRANSPORT` 错误快速失败(连接被拒绝);但状态
|
|
338
394
|
小组件本身不依赖代理——代理挂了它仍能显示「代理未运行」并允许触发登录。
|
|
339
|
-
- 登录态有效期由 WorkBuddy
|
|
340
|
-
|
|
395
|
+
- 登录态有效期由 WorkBuddy 云端决定。插件会尽力从会话文件推导过期时间
|
|
396
|
+
(见「登录态判定」),但**推导不出时只能标记为"未知"而不是失败**;真正的
|
|
397
|
+
失效检测以一次真实模型请求(🔍 诊断的「模型出字」)为准,恢复手段是
|
|
398
|
+
**🔑 重新登录**,代理无需重启。
|
|
399
|
+
- **签到功能依赖代理实现 `/v1/checkin-status` 与 `/v1/checkin`**;截至
|
|
400
|
+
`workbuddy2api` 2.0.4(含 GitHub `main`、`dsh`、`codex` 各分支)均未提供,
|
|
401
|
+
因此默认不显示签到入口。代理侧补齐后无需升级插件,入口会自动出现。
|
|
341
402
|
|
|
342
403
|
---
|
|
343
404
|
|
package/lib/client.js
CHANGED
|
@@ -128,25 +128,36 @@ function runWidget() {
|
|
|
128
128
|
}
|
|
129
129
|
// 只外显一个设置按钮:点击展开诊断/用量/签到/刷新模型四个操作。
|
|
130
130
|
html += '<span class="wb-settle">' +
|
|
131
|
-
'<button class="wb-setbtn" id="wb-settings" title="WorkBuddy 设置(诊断 / 用量 / 签到 /
|
|
131
|
+
'<button class="wb-setbtn" id="wb-settings" title="WorkBuddy 设置(诊断 / 用量 / 签到 / 刷新模型 / 重新登录)">⚙️</button>';
|
|
132
132
|
if (menuOpen) {
|
|
133
|
-
|
|
133
|
+
var items =
|
|
134
|
+
'<button class="wb-item" id="wb-diagnose" title="一键诊断:真实探测登录、代理、令牌与模型出字状态">🔍 诊断</button>' +
|
|
135
|
+
'<button class="wb-item" id="wb-usage" title="用量统计:查看今日/累计 token 与积分消耗、按模型明细">📊 用量</button>';
|
|
136
|
+
// 签到按钮只在此代理真的实现了签到接口时出现。workbuddy2api 从未提供
|
|
137
|
+
// /checkin-status 与 /checkin,显示一个必然 404 的入口只会误导用户;
|
|
138
|
+
// supported 为 null 表示还没探测出结论(代理未启动等),此时保守隐藏。
|
|
134
139
|
var ck = status.checkin;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
if (ck && ck.supported === true) {
|
|
141
|
+
// ✅ 今日已处理 / 🎁 今日未处理(点击立即领取;10 点后也会自动领取)。
|
|
142
|
+
var ckTitle = "今日还没签到(每天 10 点后自动领取)\n点击立即领取";
|
|
143
|
+
var ckIcon = "🎁";
|
|
144
|
+
var ckDisabled = "";
|
|
139
145
|
var ckMsg = ck.lastResult && ck.lastResult.message ? "\n" + ck.lastResult.message : "";
|
|
140
146
|
ckTitle = ck.handledToday ? "今日已处理签到" + ckMsg : ckTitle;
|
|
141
147
|
ckIcon = ck.handledToday && ck.lastResult && ck.lastResult.ok ? "✅" : "🎁";
|
|
142
148
|
ckDisabled = ck.handledToday ? " disabled" : "";
|
|
149
|
+
items += '<button class="wb-item" id="wb-checkin" title="' + esc(ckTitle) + '"' + ckDisabled + '>' + ckIcon + " 签到</button>";
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
'<button class="wb-item" id="wb-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
items +=
|
|
152
|
+
'<button class="wb-item" id="wb-models" title="刷新模型列表:从代理重读模型并让模型选择器立即重载">🔄 刷新模型</button>';
|
|
153
|
+
// 强制重登入口**始终存在**:本地判定是从会话文件推导的启发式(代理写入
|
|
154
|
+
// 的 session.json 根本没有 expiresAt),token 在有效期内被吊销时判定仍是
|
|
155
|
+
// "有效",此时普通「登录」只会拿到 alreadyLoggedIn。若把入口做成有条件的,
|
|
156
|
+
// "判定错误 → 无法恢复"的耦合就依然存在,所以这里不做条件渲染,只在
|
|
157
|
+
// 判定不可靠时用 title 提示"建议"。
|
|
158
|
+
items += '<button class="wb-item" id="wb-relogin" title="强制重新登录:忽略本地会话判定,重跑设备流登录' +
|
|
159
|
+
(status.reloginRecommended ? '(当前建议执行:本地判定不可靠)' : '') + '">🔑 重新登录</button>';
|
|
160
|
+
html += '<span class="wb-menu">' + items + "</span>";
|
|
150
161
|
}
|
|
151
162
|
html += "</span>";
|
|
152
163
|
if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
|
|
@@ -170,6 +181,8 @@ function runWidget() {
|
|
|
170
181
|
if (ckBtn) ckBtn.addEventListener("click", function () { menuOpen = false; render(); onCheckin(); });
|
|
171
182
|
var mdBtn = host.querySelector("#wb-models");
|
|
172
183
|
if (mdBtn) mdBtn.addEventListener("click", function () { menuOpen = false; render(); onRefreshModels(); });
|
|
184
|
+
var rlBtn = host.querySelector("#wb-relogin");
|
|
185
|
+
if (rlBtn) rlBtn.addEventListener("click", function () { menuOpen = false; render(); onLogin(true); });
|
|
173
186
|
}
|
|
174
187
|
|
|
175
188
|
// 点击页面其它位置关闭设置菜单。
|
|
@@ -189,15 +202,27 @@ function runWidget() {
|
|
|
189
202
|
.catch(function () { /* leave last known state on screen */ });
|
|
190
203
|
}
|
|
191
204
|
|
|
192
|
-
|
|
205
|
+
/**
|
|
206
|
+
* 触发登录。`force` 为真时忽略本地会话判定,强制重跑设备流——用于
|
|
207
|
+
* 本地判定为"仍有效"但 token 实际已失效(被吊销)的场景,否则用户点了
|
|
208
|
+
* 登录只会拿到 alreadyLoggedIn,没有任何恢复手段。
|
|
209
|
+
*/
|
|
210
|
+
function onLogin(force) {
|
|
193
211
|
if (busy) return;
|
|
194
212
|
busy = true;
|
|
195
213
|
render();
|
|
196
|
-
fetch("/api/workbuddy/login", { method: "POST" })
|
|
214
|
+
fetch("/api/workbuddy/login" + (force ? "?force=1" : ""), { method: "POST" })
|
|
197
215
|
.then(function (r) { return r.json(); })
|
|
198
216
|
.then(function (data) {
|
|
199
217
|
if (data && data.authUrl) {
|
|
200
218
|
window.open(data.authUrl, "_blank", "noopener,noreferrer");
|
|
219
|
+
} else if (data && data.error) {
|
|
220
|
+
// e.g. no python3, or no URL within the timeout: surface it instead
|
|
221
|
+
// of silently doing nothing (the old "点了没反应" symptom).
|
|
222
|
+
busy = false;
|
|
223
|
+
render();
|
|
224
|
+
window.alert("WorkBuddy 登录失败:" + data.error);
|
|
225
|
+
return;
|
|
201
226
|
}
|
|
202
227
|
var i = 0;
|
|
203
228
|
var tick = function () {
|
|
@@ -233,10 +258,19 @@ function runWidget() {
|
|
|
233
258
|
var lines = [];
|
|
234
259
|
lines.push("登录状态: " + (d.session && d.session.authenticated ? "有效" : "无效"));
|
|
235
260
|
lines.push("会话文件: " + (d.session && d.session.file ? "存在" : "缺失"));
|
|
261
|
+
// 过期时间是多源推导出来的(代理写入的 session.json 没有 expiresAt),
|
|
262
|
+
// 把来源一并显示,便于判断"未知"到底是没探测到还是真的没写。
|
|
263
|
+
var exp = d.session && d.session.expiresAt;
|
|
264
|
+
lines.push("令牌过期: " + (exp
|
|
265
|
+
? fmtExpiry(exp) + "(来源: " + ((d.session && d.session.expiresAtSource) || "?") + ")"
|
|
266
|
+
: "未知(无法从会话文件推导)"));
|
|
236
267
|
lines.push("代理进程: " + (d.health && d.health.proxyUp ? "在运行" : "未运行"));
|
|
237
268
|
lines.push("登录令牌: " + (d.health && d.health.tokenValid ? "有效" : "无效"));
|
|
238
269
|
lines.push("模型出字: " + (d.chat && d.chat.chatWorking ? "正常" : "失败"));
|
|
239
270
|
if (d.chat && d.chat.chatError) lines.push("出字错误: " + d.chat.chatError);
|
|
271
|
+
if (d.session && d.session.reloginRecommended) {
|
|
272
|
+
lines.push("提示: 建议点 ⚙️ → 🔑 重新登录(本地判定不可靠,可强制重跑登录)");
|
|
273
|
+
}
|
|
240
274
|
var body = el.querySelector(".wb-panel-body");
|
|
241
275
|
var html = '<div style="font-weight:700;margin-bottom:4px">' + esc(title) + '</div>' +
|
|
242
276
|
'<div>' + lines.map(esc).join("\n") + "</div>";
|
|
@@ -351,6 +385,9 @@ function runWidget() {
|
|
|
351
385
|
fetch("/api/workbuddy/checkin", { method: "POST" })
|
|
352
386
|
.then(function (r) { return r.json(); })
|
|
353
387
|
.then(function (d) {
|
|
388
|
+
// 代理没实现签到接口:刷新状态让入口按 supported 自行消失,
|
|
389
|
+
// 而不是留一个点了就报错的按钮。
|
|
390
|
+
if (d && d.supported === false) { refresh(); return; }
|
|
354
391
|
btn.textContent = d && d.ok ? "✅" : "🎁";
|
|
355
392
|
btn.title = (d && d.message) || "签到结果未知";
|
|
356
393
|
refresh();
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* to a shipped static catalog when the proxy is not running.
|
|
18
18
|
*/
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, truncateSync, writeSync, closeSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, statSync, truncateSync, writeSync, closeSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { dirname, join, resolve } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -1051,6 +1051,8 @@ const CHECKIN_FILE_NAME = "checkin.json";
|
|
|
1051
1051
|
const CHECKIN_INTERVAL_MS = 30 * 60_000;
|
|
1052
1052
|
/** Request timeout for the proxy checkin endpoints. */
|
|
1053
1053
|
const CHECKIN_TIMEOUT_MS = 10_000;
|
|
1054
|
+
/** How long the login route waits for the device-flow URL before giving up. */
|
|
1055
|
+
const LOGIN_URL_TIMEOUT_MS = 15_000;
|
|
1054
1056
|
|
|
1055
1057
|
/** Resolve the checkin state file path (same convention as usageLedgerPath). */
|
|
1056
1058
|
function checkinStatePath() {
|
|
@@ -1092,7 +1094,14 @@ function localDateStr(date = new Date()) {
|
|
|
1092
1094
|
|
|
1093
1095
|
/**
|
|
1094
1096
|
* Call a proxy checkin endpoint. `method` defaults to GET (status probe);
|
|
1095
|
-
* claiming passes "POST". Returns the parsed body
|
|
1097
|
+
* claiming passes "POST". Returns the parsed body (with the HTTP status under
|
|
1098
|
+
* `httpStatus`) or an error shape.
|
|
1099
|
+
*
|
|
1100
|
+
* `missing: true` marks the specific "this proxy does not implement the
|
|
1101
|
+
* checkin API" case (HTTP 404/405 on the probe). It is kept distinct from a
|
|
1102
|
+
* transport error because the two need opposite handling: a missing endpoint is
|
|
1103
|
+
* permanent for the running proxy (hide the feature), a transport error is
|
|
1104
|
+
* transient (retry later).
|
|
1096
1105
|
*/
|
|
1097
1106
|
async function proxyCheckinCall(baseURL, path, method = "GET") {
|
|
1098
1107
|
const controller = new AbortController();
|
|
@@ -1105,9 +1114,25 @@ async function proxyCheckinCall(baseURL, path, method = "GET") {
|
|
|
1105
1114
|
});
|
|
1106
1115
|
const body = await response.json().catch(() => null);
|
|
1107
1116
|
if (!response.ok) {
|
|
1108
|
-
|
|
1117
|
+
const missing = response.status === 404 || response.status === 405;
|
|
1118
|
+
return {
|
|
1119
|
+
httpStatus: response.status,
|
|
1120
|
+
error: `HTTP ${response.status} (${method} ${path})`,
|
|
1121
|
+
...(missing ? { missing: true } : {}),
|
|
1122
|
+
// Body fields ride along both flattened and under `body`: the old shape
|
|
1123
|
+
// kept them under `body`, and `runCheckinOnce` still reads
|
|
1124
|
+
// `claim.body.detail` for its failure message.
|
|
1125
|
+
...(body !== null && typeof body === "object" ? body : {}),
|
|
1126
|
+
...(body !== null && typeof body === "object" ? { body } : {}),
|
|
1127
|
+
};
|
|
1109
1128
|
}
|
|
1110
|
-
|
|
1129
|
+
// `httpStatus` rides along even on success so callers can classify the
|
|
1130
|
+
// endpoint by HTTP status rather than by "did an error string appear".
|
|
1131
|
+
// It must NOT be named `status`: the proxy's own checkin body already has a
|
|
1132
|
+
// `status: "ok"` field that would clobber it when spread.
|
|
1133
|
+
return body !== null && typeof body === "object"
|
|
1134
|
+
? { httpStatus: response.status, ...body }
|
|
1135
|
+
: { httpStatus: response.status, error: "empty response" };
|
|
1111
1136
|
} catch (error) {
|
|
1112
1137
|
return { error: String(error?.cause?.message ?? error?.message ?? error) };
|
|
1113
1138
|
} finally {
|
|
@@ -1115,6 +1140,53 @@ async function proxyCheckinCall(baseURL, path, method = "GET") {
|
|
|
1115
1140
|
}
|
|
1116
1141
|
}
|
|
1117
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* Cached answer to "does this proxy implement the checkin API at all?".
|
|
1145
|
+
*
|
|
1146
|
+
* workbuddy2api has never shipped `/checkin-status` / `/checkin` on any branch
|
|
1147
|
+
* or PyPI release, so the plugin probes once (and then at most every
|
|
1148
|
+
* {@link CHECKIN_CAPABILITY_TTL_MS}) and hides the feature instead of
|
|
1149
|
+
* hammering a 404 and recording a fresh failure every day.
|
|
1150
|
+
*
|
|
1151
|
+
* `supported` is tri-state: `true` (endpoint answered, whatever the status),
|
|
1152
|
+
* `false` (404/405 — the route does not exist), `null` (never probed, or the
|
|
1153
|
+
* proxy was unreachable so the answer is genuinely unknown).
|
|
1154
|
+
*/
|
|
1155
|
+
const CHECKIN_CAPABILITY_TTL_MS = 30 * 60_000;
|
|
1156
|
+
let _checkinCapability = { supported: null, error: null, checkedAt: 0 };
|
|
1157
|
+
|
|
1158
|
+
/** Last known checkin-support verdict, without touching the network. */
|
|
1159
|
+
function checkinCapability() {
|
|
1160
|
+
return _checkinCapability;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Probe checkin support, reusing the cached verdict until it goes stale. */
|
|
1164
|
+
async function probeCheckinCapability(baseURL, options = {}) {
|
|
1165
|
+
const { force = false } = options;
|
|
1166
|
+
const cached = _checkinCapability;
|
|
1167
|
+
if (!force && cached.checkedAt > 0 && Date.now() - cached.checkedAt < CHECKIN_CAPABILITY_TTL_MS) {
|
|
1168
|
+
return cached;
|
|
1169
|
+
}
|
|
1170
|
+
const probe = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1171
|
+
// Classify by HTTP status, not by the presence of an error string:
|
|
1172
|
+
// - 404/405 → the route does not exist on this proxy (conclusive);
|
|
1173
|
+
// - any other HTTP status (including 401/503 from an unauthenticated proxy)
|
|
1174
|
+
// proves the endpoint exists, so the feature stays offered;
|
|
1175
|
+
// - no status at all → transport failure; the answer is unknown, and a
|
|
1176
|
+
// transient outage must never permanently hide the feature.
|
|
1177
|
+
const supported = probe?.missing === true
|
|
1178
|
+
? false
|
|
1179
|
+
: typeof probe?.httpStatus === "number"
|
|
1180
|
+
? true
|
|
1181
|
+
: null;
|
|
1182
|
+
_checkinCapability = {
|
|
1183
|
+
supported,
|
|
1184
|
+
error: supported === false ? probe.error : null,
|
|
1185
|
+
checkedAt: Date.now(),
|
|
1186
|
+
};
|
|
1187
|
+
return _checkinCapability;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1118
1190
|
/** Whether an upstream claim rejection means "already claimed today". */
|
|
1119
1191
|
function alreadyClaimedMessage(msg) {
|
|
1120
1192
|
return typeof msg === "string" && /(已签到|已经签到|already)/i.test(msg);
|
|
@@ -1123,6 +1195,7 @@ function alreadyClaimedMessage(msg) {
|
|
|
1123
1195
|
/**
|
|
1124
1196
|
* Run one auto-claim pass. Idempotent per local calendar day:
|
|
1125
1197
|
* - before `checkinAfterHour` (default 10:00) local time: no-op;
|
|
1198
|
+
* - proxy does not implement the checkin API (404/405): no-op, no state change;
|
|
1126
1199
|
* - already handled today (persisted state): no-op;
|
|
1127
1200
|
* - official status says already checked in: mark handled, done;
|
|
1128
1201
|
* - activity not open (no status data): mark handled so we stop retrying today;
|
|
@@ -1131,6 +1204,16 @@ function alreadyClaimedMessage(msg) {
|
|
|
1131
1204
|
* @returns the resulting state snapshot, or undefined when skipped/in-flight.
|
|
1132
1205
|
*/
|
|
1133
1206
|
async function runCheckinOnce(baseURL, config, logger) {
|
|
1207
|
+
// Learn whether this proxy can check in at all *before* any gate: the verdict
|
|
1208
|
+
// is what the widget uses to decide whether to offer the 「签到」 entry, so it
|
|
1209
|
+
// must be populated on startup — and also when auto-claim is disabled, where
|
|
1210
|
+
// this pass does nothing else. The probe is cached (30 min), so this is at
|
|
1211
|
+
// most one cheap GET per scheduler tick.
|
|
1212
|
+
const capability = await probeCheckinCapability(baseURL);
|
|
1213
|
+
if (capability.supported === false) {
|
|
1214
|
+
logger?.debug?.(`dsh-llm-workbuddy: checkin API unavailable on this proxy (${capability.error})`);
|
|
1215
|
+
return undefined;
|
|
1216
|
+
}
|
|
1134
1217
|
if (!config.autoCheckin) return undefined;
|
|
1135
1218
|
const now = new Date();
|
|
1136
1219
|
if (now.getHours() < config.checkinAfterHour) return undefined;
|
|
@@ -1141,6 +1224,13 @@ async function runCheckinOnce(baseURL, config, logger) {
|
|
|
1141
1224
|
runCheckinOnce._inFlight = true;
|
|
1142
1225
|
try {
|
|
1143
1226
|
const status = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1227
|
+
// The probe can disagree with the cached verdict (older proxy, route added
|
|
1228
|
+
// mid-flight): trust the live 404 and stop instead of recording a failure.
|
|
1229
|
+
if (status?.missing === true) {
|
|
1230
|
+
_checkinCapability = { supported: false, error: status.error, checkedAt: Date.now() };
|
|
1231
|
+
logger?.debug?.(`dsh-llm-workbuddy: checkin API unavailable on this proxy (${status.error})`);
|
|
1232
|
+
return undefined;
|
|
1233
|
+
}
|
|
1144
1234
|
const data = status?.upstream?.data;
|
|
1145
1235
|
if (!data) {
|
|
1146
1236
|
// Activity closed / proxy unreachable / not authenticated: stop for today.
|
|
@@ -1259,28 +1349,135 @@ function resolveLoginPaths(config) {
|
|
|
1259
1349
|
return { sessionFile, loginScript };
|
|
1260
1350
|
}
|
|
1261
1351
|
|
|
1352
|
+
/**
|
|
1353
|
+
* Decode a JWT payload without verifying its signature. The session's tokens
|
|
1354
|
+
* are issued by the WorkBuddy IdP and we only read claims (`exp`) to recover an
|
|
1355
|
+
* expiry the writer omitted — never to trust the token. Returns null for an
|
|
1356
|
+
* opaque (non-JWT) or malformed token.
|
|
1357
|
+
*/
|
|
1358
|
+
function decodeJwtPayload(token) {
|
|
1359
|
+
if (typeof token !== "string") return null;
|
|
1360
|
+
const parts = token.split(".");
|
|
1361
|
+
if (parts.length !== 3) return null;
|
|
1362
|
+
try {
|
|
1363
|
+
const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
1364
|
+
const claims = JSON.parse(json);
|
|
1365
|
+
return claims !== null && typeof claims === "object" ? claims : null;
|
|
1366
|
+
} catch {
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Normalize a numeric instant to epoch milliseconds. Values below this floor
|
|
1373
|
+
* cannot be a millisecond epoch this century, so the writer must have used
|
|
1374
|
+
* seconds (the IdP's own unit for JWT `exp`).
|
|
1375
|
+
*/
|
|
1376
|
+
function toEpochMs(value) {
|
|
1377
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
1378
|
+
return value < 1e11 ? Math.round(value * 1000) : value;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** A JWT's `exp` claim, in epoch ms. Null when absent/opaque. */
|
|
1382
|
+
function jwtExpiresAt(token) {
|
|
1383
|
+
return toEpochMs(decodeJwtPayload(token)?.exp);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Derive when the stored session actually stops working.
|
|
1388
|
+
*
|
|
1389
|
+
* The proxy (workbuddy2api) writes the IdP token payload verbatim, which has no
|
|
1390
|
+
* `expiresAt` — judging "no expiresAt ⇒ still valid" made the login state
|
|
1391
|
+
* permanently green. Sources are tried most authoritative first:
|
|
1392
|
+
*
|
|
1393
|
+
* 1. the **refresh** token's JWT `exp` — the proxy refreshes an expired access
|
|
1394
|
+
* token automatically, so the refresh token's lifetime is what really
|
|
1395
|
+
* bounds the session. Judging by the access token alone would turn the
|
|
1396
|
+
* capsule red while requests still succeed;
|
|
1397
|
+
* 2. `auth.expiresAt` — written by this plugin's own login script (also the
|
|
1398
|
+
* access token's lifetime, hence below the refresh bound);
|
|
1399
|
+
* 3. the **access** token's JWT `exp`;
|
|
1400
|
+
* 4. `auth.expiresIn` counted from the session file's mtime — the last resort
|
|
1401
|
+
* for opaque tokens; the mtime is refreshed whenever the proxy re-writes
|
|
1402
|
+
* the session, so this can only under-estimate, never over-estimate.
|
|
1403
|
+
*
|
|
1404
|
+
* Yielding no instant at all means the validity is genuinely unknown
|
|
1405
|
+
* (`source: null`) and must be reported as such rather than assumed valid.
|
|
1406
|
+
*/
|
|
1407
|
+
function resolveSessionExpiry(raw, sessionFile) {
|
|
1408
|
+
const accessJwt = jwtExpiresAt(raw?.auth?.accessToken);
|
|
1409
|
+
const refreshJwt = jwtExpiresAt(raw?.auth?.refreshToken);
|
|
1410
|
+
const explicit = toEpochMs(raw?.auth?.expiresAt);
|
|
1411
|
+
|
|
1412
|
+
if (refreshJwt !== null) {
|
|
1413
|
+
return { accessExpiresAt: accessJwt ?? explicit, refreshExpiresAt: refreshJwt, expiresAt: refreshJwt, source: "jwt-refresh" };
|
|
1414
|
+
}
|
|
1415
|
+
if (explicit !== null) {
|
|
1416
|
+
return { accessExpiresAt: explicit, refreshExpiresAt: null, expiresAt: explicit, source: "expiresAt" };
|
|
1417
|
+
}
|
|
1418
|
+
if (accessJwt !== null) {
|
|
1419
|
+
return { accessExpiresAt: accessJwt, refreshExpiresAt: null, expiresAt: accessJwt, source: "jwt" };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const expiresIn = raw?.auth?.expiresIn;
|
|
1423
|
+
if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
1424
|
+
try {
|
|
1425
|
+
const fromMtime = Math.round(statSync(sessionFile).mtimeMs) + expiresIn * 1000;
|
|
1426
|
+
return { accessExpiresAt: fromMtime, refreshExpiresAt: null, expiresAt: fromMtime, source: "expiresIn" };
|
|
1427
|
+
} catch {
|
|
1428
|
+
// fall through to "unknown"
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
return { accessExpiresAt: null, refreshExpiresAt: null, expiresAt: null, source: null };
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1262
1435
|
/**
|
|
1263
1436
|
* Read the local WorkBuddy session file and derive its validity. Returns a
|
|
1264
1437
|
* normalized status object the Web widget polls. Never throws — missing or
|
|
1265
1438
|
* malformed state is reported as `authenticated: false` so the UI can prompt.
|
|
1439
|
+
*
|
|
1440
|
+
* `expiresAt` is the instant that bounds the session (see
|
|
1441
|
+
* {@link resolveSessionExpiry}); `expiryKnown: false` means no expiry could be
|
|
1442
|
+
* derived, and `reloginRecommended` is the recovery hint the widget shows.
|
|
1266
1443
|
*/
|
|
1267
1444
|
function readSessionStatus(sessionFile) {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1445
|
+
const empty = {
|
|
1446
|
+
sessionFile: false,
|
|
1447
|
+
authenticated: false,
|
|
1448
|
+
expiresAt: null,
|
|
1449
|
+
accessExpiresAt: null,
|
|
1450
|
+
refreshExpiresAt: null,
|
|
1451
|
+
expiresAtSource: null,
|
|
1452
|
+
expiryKnown: false,
|
|
1453
|
+
expired: false,
|
|
1454
|
+
tokenPresent: false,
|
|
1455
|
+
reloginRecommended: true,
|
|
1456
|
+
account: null,
|
|
1457
|
+
};
|
|
1458
|
+
if (!existsSync(sessionFile)) return empty;
|
|
1271
1459
|
try {
|
|
1272
1460
|
const raw = JSON.parse(readFileSync(sessionFile, "utf-8"));
|
|
1273
|
-
const
|
|
1274
|
-
const
|
|
1275
|
-
const expired = expiresAt !== null && expiresAt <= now;
|
|
1461
|
+
const tokenPresent = Boolean(raw?.auth?.accessToken);
|
|
1462
|
+
const expiry = resolveSessionExpiry(raw, sessionFile);
|
|
1463
|
+
const expired = expiry.expiresAt !== null && expiry.expiresAt <= Date.now();
|
|
1276
1464
|
return {
|
|
1277
1465
|
sessionFile: true,
|
|
1278
|
-
authenticated:
|
|
1279
|
-
expiresAt,
|
|
1466
|
+
authenticated: tokenPresent && !expired,
|
|
1467
|
+
expiresAt: expiry.expiresAt,
|
|
1468
|
+
accessExpiresAt: expiry.accessExpiresAt,
|
|
1469
|
+
refreshExpiresAt: expiry.refreshExpiresAt,
|
|
1470
|
+
expiresAtSource: expiry.source,
|
|
1471
|
+
expiryKnown: expiry.expiresAt !== null,
|
|
1472
|
+
expired,
|
|
1473
|
+
tokenPresent,
|
|
1474
|
+
// The login button must stay a way out: no token, a passed expiry, or an
|
|
1475
|
+
// expiry we cannot derive all mean "offer a re-login".
|
|
1476
|
+
reloginRecommended: !tokenPresent || expired || expiry.expiresAt === null,
|
|
1280
1477
|
account: raw?.account ?? null,
|
|
1281
1478
|
};
|
|
1282
1479
|
} catch {
|
|
1283
|
-
return { sessionFile: true
|
|
1480
|
+
return { ...empty, sessionFile: true };
|
|
1284
1481
|
}
|
|
1285
1482
|
}
|
|
1286
1483
|
|
|
@@ -1383,9 +1580,9 @@ function diagnoseRestartCommand() {
|
|
|
1383
1580
|
"# 停掉旧代理(若 8787 被占用)",
|
|
1384
1581
|
"lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill",
|
|
1385
1582
|
"",
|
|
1386
|
-
"#
|
|
1387
|
-
"
|
|
1388
|
-
"
|
|
1583
|
+
"# 安装/升级代理(需 >= 2.0.4;详见 README「安装代理」)",
|
|
1584
|
+
"uv tool install -U workbuddy2api",
|
|
1585
|
+
"workbuddy2api --desensitize \\",
|
|
1389
1586
|
" --session-file ~/.codebuddy-session.json --log-file ~/.codebuddy-proxy.jsonl",
|
|
1390
1587
|
].join("\n");
|
|
1391
1588
|
}
|
|
@@ -1431,8 +1628,11 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1431
1628
|
const session = readSessionStatus(sessionFile);
|
|
1432
1629
|
const proxy = await probeProxy(baseURL);
|
|
1433
1630
|
// Checkin digest comes from the persisted state only — never block the
|
|
1434
|
-
// 5s status poll with an upstream call.
|
|
1631
|
+
// 5s status poll with an upstream call. Support comes from the cached
|
|
1632
|
+
// capability verdict (also populated by the scheduler and the checkin
|
|
1633
|
+
// route), so the widget can hide an entry this proxy cannot serve.
|
|
1435
1634
|
const checkinState = readCheckinState();
|
|
1635
|
+
const capability = checkinCapability();
|
|
1436
1636
|
const today = localDateStr();
|
|
1437
1637
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1438
1638
|
res.end(JSON.stringify({
|
|
@@ -1440,6 +1640,7 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1440
1640
|
...proxy,
|
|
1441
1641
|
loginScriptAvailable: existsSync(loginScript),
|
|
1442
1642
|
checkin: {
|
|
1643
|
+
supported: capability.supported,
|
|
1443
1644
|
autoEnabled: config?.autoCheckin ?? true,
|
|
1444
1645
|
handledToday: checkinState.handledDate === today,
|
|
1445
1646
|
lastResult: checkinState.lastResult ?? null,
|
|
@@ -1461,9 +1662,20 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1461
1662
|
res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
|
|
1462
1663
|
return;
|
|
1463
1664
|
}
|
|
1665
|
+
// `force` re-runs the device flow even when the stored session still
|
|
1666
|
+
// looks valid. Status judgement is a heuristic (the proxy's session
|
|
1667
|
+
// file carries no `expiresAt`), so the login button must never be a
|
|
1668
|
+
// dead end when a token is wrong/revoked but still not-yet-expired.
|
|
1669
|
+
let force = false;
|
|
1670
|
+
try {
|
|
1671
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1672
|
+
force = url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
|
|
1673
|
+
} catch {
|
|
1674
|
+
// malformed URL: treat as non-forced
|
|
1675
|
+
}
|
|
1464
1676
|
// Existing session is fine; no need to re-run the flow.
|
|
1465
1677
|
const existing = readSessionStatus(sessionFile);
|
|
1466
|
-
if (existing.authenticated) {
|
|
1678
|
+
if (existing.authenticated && !force) {
|
|
1467
1679
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1468
1680
|
res.end(JSON.stringify({ alreadyLoggedIn: true, authUrl: null, ...existing }));
|
|
1469
1681
|
return;
|
|
@@ -1473,17 +1685,55 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1473
1685
|
const args = ["-u", loginScript, "--session-file", sessionFile];
|
|
1474
1686
|
const child = spawn("python3", args, { env: { ...process.env, PYTHONUNBUFFERED: "1" } });
|
|
1475
1687
|
let stdout = "";
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1688
|
+
|
|
1689
|
+
// The device-flow URL is printed as soon as the flow starts (before the
|
|
1690
|
+
// long poll for completion), but it is never available synchronously —
|
|
1691
|
+
// answering immediately always returned `authUrl: null`, which the
|
|
1692
|
+
// widget turned into "clicked login, nothing happened". Resolve on the
|
|
1693
|
+
// first URL, on child exit, or on the timeout, whichever comes first.
|
|
1694
|
+
const outcome = await new Promise((resolve) => {
|
|
1695
|
+
let done = false;
|
|
1696
|
+
const finish = (value) => {
|
|
1697
|
+
if (done) return;
|
|
1698
|
+
done = true;
|
|
1699
|
+
clearTimeout(timer);
|
|
1700
|
+
resolve(value);
|
|
1701
|
+
};
|
|
1702
|
+
// Bounded wait. This timer is deliberately NOT unref'd: it is the only
|
|
1703
|
+
// thing guaranteeing the route answers even when nothing else holds
|
|
1704
|
+
// the event loop open. It is cleared the moment the child answers, so
|
|
1705
|
+
// it never outlives the request.
|
|
1706
|
+
const timer = setTimeout(
|
|
1707
|
+
() => finish({ authUrl: null, error: `登录脚本未在 ${Math.round(LOGIN_URL_TIMEOUT_MS / 1000)} 秒内输出授权链接(检查系统 python3 是否可用)` }),
|
|
1708
|
+
LOGIN_URL_TIMEOUT_MS,
|
|
1709
|
+
);
|
|
1710
|
+
child.stdout.on("data", (chunk) => {
|
|
1711
|
+
stdout += chunk.toString();
|
|
1712
|
+
const m = stdout.match(/https?:\/\/\S+/);
|
|
1713
|
+
if (m) finish({ authUrl: m[0] });
|
|
1714
|
+
});
|
|
1715
|
+
child.stderr.on("data", () => {});
|
|
1716
|
+
child.on("error", (error) => {
|
|
1717
|
+
finish({ authUrl: null, error: `无法启动 python3:${String(error?.message ?? error)}` });
|
|
1718
|
+
});
|
|
1719
|
+
child.on("close", (code) => {
|
|
1720
|
+
finish({
|
|
1721
|
+
authUrl: null,
|
|
1722
|
+
error: code === 0
|
|
1723
|
+
? "登录脚本已结束,但没有输出授权链接"
|
|
1724
|
+
: `登录脚本异常退出(exit code ${code})`,
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
// Keep the pipe drained so a chatty child cannot block on a full
|
|
1728
|
+
// stdout buffer after we have answered.
|
|
1729
|
+
child.stdout.resume();
|
|
1730
|
+
child.unref?.();
|
|
1481
1731
|
});
|
|
1482
|
-
|
|
1483
|
-
// The device flow blocks until login or timeout; we hand back the URL
|
|
1484
|
-
// immediately and let the widget poll /status for completion.
|
|
1732
|
+
|
|
1485
1733
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1486
|
-
res.end(JSON.stringify(
|
|
1734
|
+
res.end(JSON.stringify(outcome.authUrl === null
|
|
1735
|
+
? { authUrl: null, pending: false, error: outcome.error }
|
|
1736
|
+
: { authUrl: outcome.authUrl, pending: true }));
|
|
1487
1737
|
},
|
|
1488
1738
|
}));
|
|
1489
1739
|
webCtx.effect(() => webServer.register({
|
|
@@ -1506,7 +1756,15 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1506
1756
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1507
1757
|
res.end(JSON.stringify({
|
|
1508
1758
|
ok,
|
|
1509
|
-
session: {
|
|
1759
|
+
session: {
|
|
1760
|
+
file: session.sessionFile,
|
|
1761
|
+
authenticated: session.authenticated,
|
|
1762
|
+
expiresAt: session.expiresAt,
|
|
1763
|
+
expiresAtSource: session.expiresAtSource,
|
|
1764
|
+
expiryKnown: session.expiryKnown,
|
|
1765
|
+
refreshExpiresAt: session.refreshExpiresAt,
|
|
1766
|
+
reloginRecommended: session.reloginRecommended,
|
|
1767
|
+
},
|
|
1510
1768
|
health,
|
|
1511
1769
|
chat,
|
|
1512
1770
|
loginScriptAvailable: existsSync(loginScript),
|
|
@@ -1563,8 +1821,14 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1563
1821
|
const state = readCheckinState();
|
|
1564
1822
|
const upstream = await proxyCheckinCall(baseURL, "/checkin-status");
|
|
1565
1823
|
const data = upstream?.upstream?.data ?? null;
|
|
1824
|
+
// A 404/405 proves the running proxy has no checkin API at all; the
|
|
1825
|
+
// widget hides the entry rather than offering a button that can only
|
|
1826
|
+
// fail.
|
|
1827
|
+
const supported = upstream?.missing === true ? false : true;
|
|
1828
|
+
_checkinCapability = { supported, error: supported ? null : upstream.error, checkedAt: Date.now() };
|
|
1566
1829
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1567
1830
|
res.end(JSON.stringify({
|
|
1831
|
+
supported,
|
|
1568
1832
|
handledToday: state.handledDate === localDateStr(),
|
|
1569
1833
|
lastResult: state.lastResult ?? null,
|
|
1570
1834
|
official: data === null ? null : {
|
|
@@ -1587,10 +1851,24 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
|
|
|
1587
1851
|
return;
|
|
1588
1852
|
}
|
|
1589
1853
|
const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
|
|
1854
|
+
if (claim?.missing === true) {
|
|
1855
|
+
// Not a failure to report: this proxy simply has no checkin API
|
|
1856
|
+
// (workbuddy2api never shipped one). Mark it unsupported so the UI
|
|
1857
|
+
// hides the entry and stop writing failure records.
|
|
1858
|
+
_checkinCapability = { supported: false, error: claim.error, checkedAt: Date.now() };
|
|
1859
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1860
|
+
res.end(JSON.stringify({
|
|
1861
|
+
ok: false,
|
|
1862
|
+
supported: false,
|
|
1863
|
+
message: `当前代理未实现签到接口(${claim.error})`,
|
|
1864
|
+
}));
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1590
1867
|
const payload = claim?.upstream?.data;
|
|
1591
1868
|
const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
|
|
1592
1869
|
const result = {
|
|
1593
1870
|
ok,
|
|
1871
|
+
supported: true,
|
|
1594
1872
|
message: ok
|
|
1595
1873
|
? payload?.credit !== undefined
|
|
1596
1874
|
? `签到成功:+${payload.credit} 积分`
|
|
@@ -1620,6 +1898,28 @@ function config_baseURL() {
|
|
|
1620
1898
|
|
|
1621
1899
|
// #endregion
|
|
1622
1900
|
|
|
1901
|
+
// #region internals (exported for tests)
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* Internals exposed for the test suite only. They are not part of the plugin's
|
|
1905
|
+
* public surface and may change without a major bump.
|
|
1906
|
+
*/
|
|
1907
|
+
export const __internals = {
|
|
1908
|
+
readSessionStatus,
|
|
1909
|
+
resolveSessionExpiry,
|
|
1910
|
+
decodeJwtPayload,
|
|
1911
|
+
runCheckinOnce,
|
|
1912
|
+
probeCheckinCapability,
|
|
1913
|
+
checkinCapability,
|
|
1914
|
+
resetCheckinCapability: () => { _checkinCapability = { supported: null, error: null, checkedAt: 0 }; },
|
|
1915
|
+
registerWorkbuddyRoutes,
|
|
1916
|
+
// The routes read the module-level proxy URL that `apply()` commits, so a
|
|
1917
|
+
// test that mounts them directly must point it somewhere first.
|
|
1918
|
+
setBaseURL: (value) => { _baseURL = value; },
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
// #endregion
|
|
1922
|
+
|
|
1623
1923
|
// #region plugin
|
|
1624
1924
|
|
|
1625
1925
|
/**
|
package/package.json
CHANGED