sintbot 1.4.2 → 1.4.4
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.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +5 -5
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +5 -5
- package/install.sh +1 -1
- package/package.json +2 -1
- package/src/c2console.js +1 -1
- package/src/core/c2cardterminalpath.js +17 -3
- package/src/core/c2durableruntime.js +6 -0
- package/src/core/turnprogress.js +135 -0
- package/src/core/v2engine.js +141 -19
- package/src/ui/console.html +265 -3
- package/src/ui/serverbase.js +15 -0
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.4",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "Upgrade progress in the console",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "Confirm the upgrade in the page",
|
|
9
|
+
"d": "Upgrade now asks for confirmation in the page, stating the versions and that new messages wait about a minute during the switch."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "The upgrade window shows each step",
|
|
13
|
+
"d": "Waiting for idle groups, download and install, stopping the current service, checking records and backing up, preflight, switching and starting, and waiting for every group to connect are ticked off one by one with the elapsed time; while waiting it names the groups still working."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "Reconnects while the service restarts",
|
|
17
|
+
"d": "During the switch the page says the service is restarting and reconnects by itself; once the new version connects every group it reloads into the new console and shows the result once. A failed step and the restored version are marked."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"t": "Can run in the background",
|
|
21
|
+
"d": "Continue in background keeps a spinner on the top-bar icon, which reopens the progress; a notice appears when the upgrade ends."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"t": "Takes effect from 1.4.4",
|
|
25
|
+
"d": "The upgrade window comes from the running version: upgrading from 1.4.3 to 1.4.4 still shows the previous screen, and later upgrades show the window."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "1.4.3",
|
|
31
|
+
"date": "2026-11-09",
|
|
32
|
+
"name": "Progress on the reply card",
|
|
33
|
+
"items": [
|
|
34
|
+
{
|
|
35
|
+
"t": "A card appears even before any output",
|
|
36
|
+
"d": "If nothing has been produced after 25 seconds, a \"Received, working on it\" card appears and the answer later replaces it in place. A turn that ends without a reply recalls that card."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"t": "The card shows how far the task has got",
|
|
40
|
+
"d": "While running, one line updates every 15 seconds: how long it has run and what it is doing (reading, editing or writing which file, running a command, searching the web; file names only). After 10 minutes it updates once a minute, and notes when nothing has happened for more than 2 minutes. Delivered steps such as writing files, committing or pushing, and sending files to the group are listed with ▶ at the top of the card and fold into a \"Process\" section when the turn ends."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"t": "/status shows progress",
|
|
44
|
+
"d": "While running it shows how many steps have run, how long ago the last activity was, and what it is doing."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"t": "Fix: a streamed reply could fail the whole turn",
|
|
48
|
+
"d": "Text that arrived while the previous update was still being sent used to fail the turn; it now queues behind it and keeps updating the card."
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
2
52
|
{
|
|
3
53
|
"version": "1.4.2",
|
|
4
54
|
"date": "2026-11-09",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.4",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "控制台升级显示进度",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "升级前在页面里确认",
|
|
9
|
+
"d": "点「现在升级」后在页面里确认,写明从哪个版本升到哪个版本,以及切换期间约一分钟不处理新消息。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "升级窗口显示每一步",
|
|
13
|
+
"d": "等待所有群空闲、下载安装、停止旧服务、核对记录并备份、新版本预检、切换并启动、等全部群连上,逐步打勾并显示已用时间;等群空闲时列出还在运行的群。"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "服务重启时自动重连",
|
|
17
|
+
"d": "切换那几秒页面显示「服务正在重启」并自动重连;新版本连上全部群后自动刷新到新版控制台,再显示一次结果。失败时标出出错的那一步和已换回的版本。"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"t": "可以放到后台",
|
|
21
|
+
"d": "点「在后台继续」后顶栏图标转圈,点它可以再打开进度;升级结束时给一条提示。"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"t": "从 1.4.4 起生效",
|
|
25
|
+
"d": "升级窗口由正在运行的版本提供:从 1.4.3 升到 1.4.4 时仍是原来的界面,之后的升级才会显示。"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "1.4.3",
|
|
31
|
+
"date": "2026-11-09",
|
|
32
|
+
"name": "回复卡片显示进度",
|
|
33
|
+
"items": [
|
|
34
|
+
{
|
|
35
|
+
"t": "一时没有输出也先回一张卡",
|
|
36
|
+
"d": "25 秒还没有任何输出时,先出一张「已收到,正在处理」卡片;回答出来后在同一张卡里替换。这一轮最后决定不回复时,撤回这张卡。"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"t": "卡片显示做到哪了",
|
|
40
|
+
"d": "运行中每 15 秒更新一行:已跑多久、最近在做什么(看、改、写哪个文件,跑命令,查网页等,只给文件名);跑满 10 分钟后改为每分钟一次,超过 2 分钟没有新动静会注明。写改文件、提交推送、发文件到群这类交付动作以 ▶ 列在卡片上方,结束后收进「过程」折叠区。"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"t": "/status 显示进度",
|
|
44
|
+
"d": "运行中会显示已执行几步、最近一次动静在多久前,以及最近在做什么。"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"t": "修复:流式回复偶发整轮失败",
|
|
48
|
+
"d": "上一段内容还在发送时又来一段文字,原来会让这一轮失败;现在排在后面继续更新卡片。"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
2
52
|
{
|
|
3
53
|
"version": "1.4.2",
|
|
4
54
|
"date": "2026-11-09",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.4 with an AI assistant
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
|
|
|
15
15
|
## Copyable request
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
Install SintBot 1.4.
|
|
18
|
+
Install SintBot 1.4.4 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
|
|
19
19
|
```
|
package/docs/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.4
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/install.md)
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The installer checks the environment, installs `sintbot@1.4.
|
|
13
|
+
The installer checks the environment, installs `sintbot@1.4.4` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.4`. No sudo is needed.
|
|
14
14
|
|
|
15
15
|
To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
|
|
16
16
|
|
|
@@ -86,12 +86,12 @@ In a group, send "Read the first line of the project README and reply with only
|
|
|
86
86
|
|
|
87
87
|
## 5. Use it in a group
|
|
88
88
|
|
|
89
|
-
Any text message is a task. Replies arrive in a card that appears once the model starts answering
|
|
89
|
+
Any text message is a task. Replies arrive in a card that appears once the model starts answering, or after 25 seconds if it is still working without output. While the task runs, the card shows how long it has run, what it is doing and the steps already delivered; at the end those steps fold into a Process section.
|
|
90
90
|
|
|
91
91
|
| Command | Effect |
|
|
92
92
|
| --- | --- |
|
|
93
93
|
| `/help` | List commands |
|
|
94
|
-
| `/status` | Group status: model, project, busy or idle, queue |
|
|
94
|
+
| `/status` | Group status: model, project, busy or idle (steps and current work while busy), queue |
|
|
95
95
|
| `/model` | List models; `/model name` switches (only while the group is idle; starts a new conversation) |
|
|
96
96
|
| `/new` | Start a new conversation (only while the group is idle) |
|
|
97
97
|
| `/stop` | Stop the current task and cancel queued ones |
|
|
@@ -104,7 +104,7 @@ Messages sent while a task runs are added to that task in Codex groups (`codexSt
|
|
|
104
104
|
|
|
105
105
|
With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
|
|
106
106
|
|
|
107
|
-
Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. Upgrades are not announced in groups.
|
|
107
|
+
Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. A window shows each step, reconnects by itself while the service restarts and can be left running in the background. Upgrades are not announced in groups.
|
|
108
108
|
|
|
109
109
|
You can also add a group without the console: add it to the configuration, stop the service and run `start` again; the new group is registered automatically. Bound groups cannot be removed or renamed by editing the configuration.
|
|
110
110
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 用 AI 助手安装 SintBot 1.4.
|
|
1
|
+
# 用 AI 助手安装 SintBot 1.4.4
|
|
2
2
|
|
|
3
3
|
[English](../AI-ASSISTED-INSTALL.md) · 简体中文
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
## 可复制的请求
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
按一行安装指南,在这台 Mac 安装 SintBot 1.4.
|
|
18
|
+
按一行安装指南,在这台 Mac 安装 SintBot 1.4.4。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.4
|
|
2
2
|
|
|
3
3
|
[English](../install.md) · 简体中文
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
安装器会自动检查环境,把 `sintbot@1.4.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.4` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.4` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
|
@@ -86,12 +86,12 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
86
86
|
|
|
87
87
|
## 5. 在群里使用
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
直接发文字就是给它派任务。回复以卡片呈现:模型开始输出时出现;25 秒还没有输出也会先出现。运行中卡片显示已跑多久、正在做什么和已完成的步骤,结束后这些步骤收进「过程」折叠区。
|
|
90
90
|
|
|
91
91
|
| 命令 | 作用 |
|
|
92
92
|
| --- | --- |
|
|
93
93
|
| `/help` | 查看可用命令 |
|
|
94
|
-
| `/status` |
|
|
94
|
+
| `/status` | 本群状态:模型、项目、是否在忙(在忙时含步数和正在做什么)、排队数 |
|
|
95
95
|
| `/model` | 查看可选模型;`/model 名字` 切换(只在本群空闲时生效,切换后开始新的一段对话) |
|
|
96
96
|
| `/new` | 开始新的一段对话(只在本群空闲时生效) |
|
|
97
97
|
| `/stop` | 停止当前任务,连同排队的一起取消 |
|
|
@@ -104,7 +104,7 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
104
104
|
|
|
105
105
|
带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、绑定新群、给空闲的群切换模型或项目目录。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
|
|
106
106
|
|
|
107
|
-
「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90
|
|
107
|
+
「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级不在群里播报。
|
|
108
108
|
|
|
109
109
|
不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
|
|
110
110
|
|
package/install.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# The function is parsed in full before installation starts, including when piped.
|
|
3
3
|
sintbot_install() (
|
|
4
4
|
set -eu
|
|
5
|
-
version=1.4.
|
|
5
|
+
version=1.4.4
|
|
6
6
|
prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
|
|
7
7
|
fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
|
|
8
8
|
case "${1:-}" in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
|
|
6
6
|
"bin": {
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"src/core/redact.js",
|
|
104
104
|
"src/core/store.js",
|
|
105
105
|
"src/core/time.js",
|
|
106
|
+
"src/core/turnprogress.js",
|
|
106
107
|
"src/core/v2engine.js",
|
|
107
108
|
"src/core/verify.js",
|
|
108
109
|
"src/design.js",
|
package/src/c2console.js
CHANGED
|
@@ -10,7 +10,7 @@ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
|
|
|
10
10
|
'/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
|
|
11
11
|
'/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
|
|
12
12
|
'/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
|
|
13
|
-
'/api/feishu/chats', '/api/feishu/chat-members', START_PATH, CALLBACK_PATH]
|
|
13
|
+
'/api/feishu/chats', '/api/feishu/chat-members', '/api/update', START_PATH, CALLBACK_PATH]
|
|
14
14
|
const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
15
15
|
'/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
|
|
16
16
|
'/api/groups', '/api/groups/provider', '/api/fs/mkdir',
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// outbound executor a second time.
|
|
7
7
|
const { createC2CardEffectBoundary, confirmCardOperation } = require('./c2cardeffectbridge')
|
|
8
8
|
const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminaleffectbridge')
|
|
9
|
+
const { stripMilestones } = require('./turnprogress')
|
|
9
10
|
|
|
10
11
|
function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
|
|
11
12
|
receiptId, chatId, turnToken, generation, verifyGeneration,
|
|
@@ -130,7 +131,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
|
|
|
130
131
|
return true
|
|
131
132
|
} catch (error) { return fail(error) }
|
|
132
133
|
},
|
|
133
|
-
async complete({ payload, replyText, paused = false, footer = '' } = {}) {
|
|
134
|
+
async complete({ payload, replyText, paused = false, footer = '', milestones = [] } = {}) {
|
|
134
135
|
if (phase !== 'open' && phase !== 'plain') {
|
|
135
136
|
throw new Error('C2 card terminal path is not open')
|
|
136
137
|
}
|
|
@@ -143,7 +144,8 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
|
|
|
143
144
|
typeof terminalPayload.quiet !== 'boolean' || terminalPayload.quiet !== receipt.quiet ||
|
|
144
145
|
typeof terminalPayload.resultText !== 'string' ||
|
|
145
146
|
typeof terminalPayload.bufferText !== 'string' ||
|
|
146
|
-
typeof footer !== 'string')
|
|
147
|
+
typeof footer !== 'string' || !Array.isArray(milestones) ||
|
|
148
|
+
!milestones.every(item => typeof item === 'string' && item.trim())) {
|
|
147
149
|
throw new TypeError('C2 card terminal payload differs from running turn')
|
|
148
150
|
}
|
|
149
151
|
if (card && await card.flush() !== true) {
|
|
@@ -184,8 +186,20 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
|
|
|
184
186
|
// is therefore not suitable for a durable final effect.
|
|
185
187
|
return sendPlainReply(target, visibleText)
|
|
186
188
|
}
|
|
189
|
+
// Display only: milestone lines leave the answer and go into the
|
|
190
|
+
// card's "process" fold; an answer that was only milestones shows
|
|
191
|
+
// them as its body. The durable reply text stays exact.
|
|
192
|
+
let shownText = visibleText
|
|
193
|
+
const finishOpts = { footer }
|
|
194
|
+
if (milestones.length) {
|
|
195
|
+
const stripped = stripMilestones(visibleText)
|
|
196
|
+
if (stripped.trim()) {
|
|
197
|
+
shownText = stripped
|
|
198
|
+
finishOpts.milestones = [...milestones]
|
|
199
|
+
} else shownText = milestones.map(item => '▶ ' + item).join('\n')
|
|
200
|
+
}
|
|
187
201
|
let calls = 0
|
|
188
|
-
const finished = await card.finish(
|
|
202
|
+
const finished = await card.finish(shownText, { ...finishOpts,
|
|
189
203
|
terminalOperationBoundary: async operation => {
|
|
190
204
|
if (!operation || operation.kind !== 'card.update' ||
|
|
191
205
|
operation.cardId == null || typeof operation.invoke !== 'function' ||
|
|
@@ -205,6 +205,12 @@ class C2DurableRuntime {
|
|
|
205
205
|
get blocked() { return this.#closed || this.#opening || this.#externalEffectInFlight || !this.#ingress ||
|
|
206
206
|
this.#ingress.blocked || this.#journal.blocked }
|
|
207
207
|
|
|
208
|
+
// Closed or sealed for good. Unlike `blocked`, an outbound effect that is
|
|
209
|
+
// still awaiting its platform response does not count: a streaming card's
|
|
210
|
+
// next frame queues behind it instead of failing the turn.
|
|
211
|
+
get closed() { return this.#closed || !this.#ingress || this.#ingress.blocked ||
|
|
212
|
+
this.#journal.blocked }
|
|
213
|
+
|
|
208
214
|
hasRecoveryPolicy({ assertWriterLock, singleNoticePerGroup } = {}) {
|
|
209
215
|
return typeof assertWriterLock === 'function' &&
|
|
210
216
|
this.#assertWriterLock === assertWriterLock &&
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// 卡片进度:里程碑 · 「最近在做」· 脉搏文案(纯函数)。
|
|
4
|
+
// 发行宿主(v2engine)与卡片收尾共用一份。只做展示,不参与任何收据或效果判定。
|
|
5
|
+
// 卡片文案与旧引擎逐字一致,便于逐字对照。
|
|
6
|
+
|
|
7
|
+
// 长任务的「活着」信号与「做到哪了」。⚠ 只在卡里滚动,不另发消息 —— 群里多一条就是一次刷屏。
|
|
8
|
+
const T = {
|
|
9
|
+
// 迟迟没动静时先回一声:填进本来就要建的那张流式卡,答案到了原地替换 —— 不多发消息。
|
|
10
|
+
// 文案过零工具名,且不说「马上/很快」这类无法兑现的承诺。
|
|
11
|
+
ackWorking: '⏳ 已收到,正在处理…',
|
|
12
|
+
// 「最近动静」只在**停滞**时才有信息量 ⇒ 超过 cardPulseStaleMs(默认 2 分钟)没动静才追加。
|
|
13
|
+
// 还没调过任何工具时退回「处理中 · 已跑」。
|
|
14
|
+
pulse: (ran, doing, stale) => (doing && doing.length ? `⏳ 已跑 ${ran} · ${T.pulseDoing(doing)}` : `⏳ 处理中 · 已跑 ${ran}`) + (stale ? ` · 最近动静 ${stale}前` : ''),
|
|
15
|
+
// 由工具调用**确定性**推出(describeTool),模型一个字不说也有东西看。脉搏与 /status 共用。
|
|
16
|
+
// 只给文件名不给路径、不给命令内容 —— 群面零机器路径。
|
|
17
|
+
pulseDoing: (list) => `最近在做:${list.join(' · ')}`,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 脉搏「已跑多久」:分钟粒度够用(卡片底部那行的耗时另算到秒)。
|
|
21
|
+
function humanDur(ms) {
|
|
22
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
23
|
+
if (s < 60) return `${s} 秒`
|
|
24
|
+
const m = Math.floor(s / 60)
|
|
25
|
+
if (m < 60) return `${m} 分钟`
|
|
26
|
+
const h = Math.floor(m / 60)
|
|
27
|
+
return m % 60 ? `${h} 小时 ${m % 60} 分钟` : `${h} 小时`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 里程碑与「最近在做」─────────────────────────────────────────────────────
|
|
31
|
+
// 模型每完成一个阶段输出一行 `>> …`,引擎认它 —— 跑的时候随打字机进卡(显示成 ▶),收尾收进卡片
|
|
32
|
+
// 折叠区,不再被「只留最后一段」丢掉。判据收在这里,不散在三处(打字机 / 脉搏 / 收尾)。
|
|
33
|
+
const MILESTONE_MAX = 12
|
|
34
|
+
const MILESTONE_RE = /^>>\s*(.*\S)\s*$/
|
|
35
|
+
// 逐行扫、跳过代码围栏 —— shell 的 `>> file` 与 Python 的 `>>>` 都可能出现在代码块里。
|
|
36
|
+
function scanMilestoneLines(text, onLine) {
|
|
37
|
+
let fence = false
|
|
38
|
+
for (const line of String(text || '').split('\n')) {
|
|
39
|
+
if (/^\s*```/.test(line)) { fence = !fence; onLine(line, null); continue }
|
|
40
|
+
const m = fence ? null : MILESTONE_RE.exec(line)
|
|
41
|
+
onLine(line, m ? m[1] : null)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// 本轮全部里程碑(去重、封顶),从流式全量文本里取 —— 中途说的与收尾说的都算。
|
|
45
|
+
function milestonesOf(text) {
|
|
46
|
+
const out = []
|
|
47
|
+
scanMilestoneLines(text, (_l, m) => { if (m && !out.includes(m) && out.length < MILESTONE_MAX) out.push(m) })
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
// 打字机 / 脉搏用:`>> ` 原地换成 ▶(不换的话 markdown 会把 `>>` 渲染成嵌套引用)。
|
|
51
|
+
function renderMilestones(text) {
|
|
52
|
+
const lines = []
|
|
53
|
+
scanMilestoneLines(text, (l, m) => lines.push(m == null ? l : '▶ ' + m))
|
|
54
|
+
return lines.join('\n')
|
|
55
|
+
}
|
|
56
|
+
// 收尾用:正文里的里程碑行剔掉(它们进折叠区),只剩答案。
|
|
57
|
+
function stripMilestones(text) {
|
|
58
|
+
const lines = []
|
|
59
|
+
scanMilestoneLines(text, (l, m) => { if (m == null) lines.push(l) })
|
|
60
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
61
|
+
}
|
|
62
|
+
// 「最近在做」:一次工具调用 → 一句人话。只给文件名不给路径(群面零机器路径);命令内容不给
|
|
63
|
+
// (那是 agent 写的,可能带机器路径);插件按名字;认不出的返回 null,不拿框架里的工具名去凑数。
|
|
64
|
+
// codex 的工具名经 codexcode 映射后与 claude 同形(Bash / Write / MCP)。
|
|
65
|
+
const baseName = (p) => String(p || '').split(/[\\/]/).filter(Boolean).pop() || ''
|
|
66
|
+
function describeTool(name, input = {}) {
|
|
67
|
+
const n = String(name || '')
|
|
68
|
+
const inp = input && typeof input === 'object' ? input : {}
|
|
69
|
+
const file = baseName(inp.file_path || inp.path || inp.notebook_path)
|
|
70
|
+
const withFile = (verb) => (file ? `${verb} ${file}` : `${verb}文件`)
|
|
71
|
+
const mcp = /^mcp__(.+?)__/.exec(n)
|
|
72
|
+
if (mcp) return `用插件 ${mcp[1]}`
|
|
73
|
+
switch (n) {
|
|
74
|
+
case 'Read': return withFile('看')
|
|
75
|
+
case 'Edit': case 'MultiEdit': case 'NotebookEdit': return withFile('改')
|
|
76
|
+
case 'Write': return withFile('写')
|
|
77
|
+
case 'Delete': return withFile('删')
|
|
78
|
+
case 'Bash': return '跑命令'
|
|
79
|
+
case 'Glob': case 'Grep': case 'FileSearch': return '搜代码'
|
|
80
|
+
case 'WebFetch': case 'WebSearch': return '查网页'
|
|
81
|
+
case 'Task': case 'Agent': return '派子任务'
|
|
82
|
+
case 'MCP': return `用插件${inp.server ? ' ' + baseName(inp.server) : ''}`
|
|
83
|
+
default: return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ── 确定性里程碑 ──────────────────────────────────────────────────────────────
|
|
87
|
+
// 「最近在做」只滚最近 3 条,回答的是「还活着吗」;这里回答「做到哪了」:从工具调用里只认**交付类**
|
|
88
|
+
// 动作(写 / 改文件 · 发到群 · 上画布 / 生图 / 预览 · git 提交推送 · 出 PDF),累积、去重、封顶,
|
|
89
|
+
// 与模型自己的 `>> ` 行并排进卡、收尾同进折叠区。只给文件名不给路径,临时目录里的脚本不算
|
|
90
|
+
// (那是它写给自己用的),命令内容不给 —— 群面零机器路径。一次 Bash 只记最重的一条。
|
|
91
|
+
const TMP_PATH_RE = /^(?:\/private)?\/tmp\/|^\/var\/folders\/|^\$TMPDIR|^%TEMP%|\\Temp\\/i
|
|
92
|
+
const unquote = (s) => String(s || '').trim().replace(/^(["'])(.*)\1$/, '$2')
|
|
93
|
+
const SH_ARG = '("[^"]+"|\'[^\']+\'|[^\\s;|&<>]+)'
|
|
94
|
+
function autoMilestone(name, input = {}) {
|
|
95
|
+
const n = String(name || '')
|
|
96
|
+
const inp = input && typeof input === 'object' ? input : {}
|
|
97
|
+
// 路径里带 shell 变量(`$OUT`)或命令替换 ⇒ 名字解不出来,只报动作不报名
|
|
98
|
+
const fileMs = (verb, raw) => { const p = unquote(raw); const f = baseName(p); return f && !TMP_PATH_RE.test(p) && !/[$`]/.test(p) ? `${verb} ${f}` : null }
|
|
99
|
+
const target = inp.file_path || inp.path || inp.notebook_path
|
|
100
|
+
switch (n) {
|
|
101
|
+
case 'Write': return fileMs('写了', target)
|
|
102
|
+
case 'Edit': case 'MultiEdit': case 'NotebookEdit': return fileMs('改了', target)
|
|
103
|
+
case 'Bash': break
|
|
104
|
+
default: return null
|
|
105
|
+
}
|
|
106
|
+
const cmd = String(inp.command || '')
|
|
107
|
+
const grab = (re) => { const m = re.exec(cmd); return m ? m[1] : '' }
|
|
108
|
+
let a
|
|
109
|
+
// 平台基础技能的命令名不写死(sbot feishu / sbot wecom …):里程碑只认动作名
|
|
110
|
+
if ((a = grab(new RegExp('\\bsbot\\s+[a-z]+\\s+send-file\\s+' + SH_ARG)))) return fileMs('已发文件', a) || '已发文件到群'
|
|
111
|
+
if ((a = grab(new RegExp('\\bsbot\\s+[a-z]+\\s+send-image\\s+' + SH_ARG)))) return fileMs('已发图片', a) || '已发图片到群'
|
|
112
|
+
if (/\bsbot\s+[a-z]+\s+send-notice\b/.test(cmd)) return '已发通知到群'
|
|
113
|
+
if (/\bsbot\s+[a-z]+\s+send-msg\b/.test(cmd)) return '已发消息到群'
|
|
114
|
+
if (/\bsbot\s+feishu\s+board-add-(?:image|diagram|nodes)\b/.test(cmd)) return '已上画布'
|
|
115
|
+
if (/\bsbot\s+imagen\s/.test(cmd) && /--out\b/.test(cmd)) return '已提交生图'
|
|
116
|
+
if (/\bsbot\s+preview\s+start\b/.test(cmd)) return '已开预览'
|
|
117
|
+
if (/\bgit\s+(?:-C\s+\S+\s+)?push\b/.test(cmd)) return '已推送远端'
|
|
118
|
+
if (/\bgit\s+(?:-C\s+\S+\s+)?commit\b/.test(cmd)) return '已提交代码'
|
|
119
|
+
if ((a = grab(new RegExp('--print-to-pdf=' + SH_ARG)))) return fileMs('已出 PDF', a) || '已出 PDF'
|
|
120
|
+
if (/--print-to-pdf\b/.test(cmd)) return '已出 PDF'
|
|
121
|
+
if ((a = grab(new RegExp('(?:^|[\\s;&|(])cat\\s*>{1,2}\\s*' + SH_ARG)))) return fileMs('写了', a)
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
// 最多留 3 条、去重、最新在最后(脉搏与 /status 共用同一个列表)。
|
|
125
|
+
function pushDoing(list, d) {
|
|
126
|
+
if (!d) return list
|
|
127
|
+
const i = list.indexOf(d)
|
|
128
|
+
if (i >= 0) list.splice(i, 1)
|
|
129
|
+
list.push(d)
|
|
130
|
+
while (list.length > 3) list.shift()
|
|
131
|
+
return list
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { T, humanDur, MILESTONE_MAX, milestonesOf, renderMilestones,
|
|
135
|
+
stripMilestones, describeTool, autoMilestone, pushDoing }
|
package/src/core/v2engine.js
CHANGED
|
@@ -15,6 +15,7 @@ const { createC2TurnProjection } = require('./c2turnprojection')
|
|
|
15
15
|
const { prepareC2RunnerInput } = require('./c2runnerinput')
|
|
16
16
|
const { continueC2Successor } = require('./c2successorbridge')
|
|
17
17
|
const { rejectC2Input } = require('./c2inputrejection')
|
|
18
|
+
const progress = require('./turnprogress')
|
|
18
19
|
const NO_REPLY = 'NO_REPLY'
|
|
19
20
|
const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
|
|
20
21
|
const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
|
|
@@ -47,8 +48,10 @@ const T = {
|
|
|
47
48
|
'/issue 一句话 记下问题反馈',
|
|
48
49
|
steer ? '直接发文字就是派任务;任务进行中再发的消息会补充进当前任务。'
|
|
49
50
|
: '直接发文字就是派任务;任务进行中再发的消息会排队,等这一轮结束再处理。'].join('\n'),
|
|
50
|
-
status: ({ model, project, state, queue, session }) => ['【本群状态】', `· 模型:${model}`,
|
|
51
|
-
`· 项目:${project}`, `· 状态:${state}`,
|
|
51
|
+
status: ({ model, project, state, doing = [], queue, session }) => ['【本群状态】', `· 模型:${model}`,
|
|
52
|
+
`· 项目:${project}`, `· 状态:${state}`,
|
|
53
|
+
...(doing.length ? [`· 最近在做:${doing.join(' · ')}`] : []),
|
|
54
|
+
`· 排队:${queue}`, `· 对话:${session}`].join('\n'),
|
|
52
55
|
freshSession: '✅ 已开始新的一段对话,之前聊的内容不再带着',
|
|
53
56
|
newBusy: '⚠ 手头还有任务在跑 —— 等它跑完,或先发 /stop 再发 /new',
|
|
54
57
|
quotaShow: (name, value) => `${name} 额度:${value}`,
|
|
@@ -68,6 +71,7 @@ const T = {
|
|
|
68
71
|
stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
|
|
69
72
|
stopUnavailable: '当前任务正在收尾,暂时无法停止。',
|
|
70
73
|
stoppedWithQueue: n => `已停止 — 连同排队的 ${n} 条一并取消`,
|
|
74
|
+
quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
|
|
71
75
|
cardFooter: ({ ms, tokens }) => {
|
|
72
76
|
const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
|
|
73
77
|
const tokenLabel = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
|
|
@@ -351,11 +355,14 @@ async _durableV2Command(msg, cmd, rest) {
|
|
|
351
355
|
if (cmd === '/status') {
|
|
352
356
|
const rt = this.rts.get(msg.chatId)
|
|
353
357
|
const queued = this.durableV2Runtime.snapshot().state?.lanes?.queue?.[msg.chatId]?.queue?.length || 0
|
|
354
|
-
|
|
355
|
-
|
|
358
|
+
// "Running for long" is not "stuck": steps and the last activity tell them apart.
|
|
359
|
+
const busy = !!rt?.v2NormalRouteActive
|
|
360
|
+
const state = busy
|
|
361
|
+
? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))}${rt.v2Steps ? ` · 已执行 ${rt.v2Steps} 步` : ''}${rt.v2AliveAt ? ` · 最近动静 ${humanDuration(Date.now() - rt.v2AliveAt)}前` : ''})`
|
|
356
362
|
: rt?.v2AdmissionPending ? '消息已登记,等待并发名额' : '空闲'
|
|
357
363
|
const project = String(group.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
|
|
358
|
-
return reject('status', T.status({ model: label(name, provider), project, state,
|
|
364
|
+
return reject('status', T.status({ model: label(name, provider), project, state,
|
|
365
|
+
doing: busy && Array.isArray(rt.v2Doing) ? rt.v2Doing : [], queue: queued,
|
|
359
366
|
session: this.store.getSession(msg.chatId) ? '进行中(记着上下文)' : '未开始' }))
|
|
360
367
|
}
|
|
361
368
|
if (cmd === '/new') {
|
|
@@ -1258,6 +1265,13 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1258
1265
|
const generation = randomBytes(16).toString('hex')
|
|
1259
1266
|
rt.v2RunnerGeneration = generation
|
|
1260
1267
|
rt.v2StartedAt = Date.now()
|
|
1268
|
+
// Evidence that the turn is still moving, for /status and the card pulse;
|
|
1269
|
+
// every turn starts from zero.
|
|
1270
|
+
rt.v2Steps = 0
|
|
1271
|
+
rt.v2AliveAt = 0
|
|
1272
|
+
rt.v2Doing = []
|
|
1273
|
+
rt.v2AutoMilestones = []
|
|
1274
|
+
rt.v2CardQuotaFailure = false
|
|
1261
1275
|
rt.v2CardBinding = null
|
|
1262
1276
|
rt.v2StreamRequested = false
|
|
1263
1277
|
rt.v2StreamQuiescing = false
|
|
@@ -1271,6 +1285,13 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1271
1285
|
let accepted = false
|
|
1272
1286
|
let controller = null
|
|
1273
1287
|
let capTimer = null
|
|
1288
|
+
let ackTimer = null
|
|
1289
|
+
let ackDue = false
|
|
1290
|
+
let pulseTimer = null
|
|
1291
|
+
const stopProgressTimers = () => {
|
|
1292
|
+
if (ackTimer) { clearTimeout(ackTimer); ackTimer = null }
|
|
1293
|
+
if (pulseTimer) { clearInterval(pulseTimer); pulseTimer = null }
|
|
1294
|
+
}
|
|
1274
1295
|
try {
|
|
1275
1296
|
capability = authority.beginRunnerStart({ receiptId, chatId, turnToken,
|
|
1276
1297
|
generation, verifyGeneration })
|
|
@@ -1346,19 +1367,28 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1346
1367
|
rt.v2RunnerNotStarted = false
|
|
1347
1368
|
let streamChain = Promise.resolve()
|
|
1348
1369
|
let streamFailure = null
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
if (
|
|
1357
|
-
|
|
1370
|
+
// Card progress (same mechanism and wording as the legacy engine): deterministic
|
|
1371
|
+
// milestones and "recently doing" from tool calls, an early "received" card
|
|
1372
|
+
// and a pulse line. All of it is process-local card text; the card itself is
|
|
1373
|
+
// still opened through the durable terminal path below.
|
|
1374
|
+
const autoMs = []
|
|
1375
|
+
rt.v2AutoMilestones = autoMs
|
|
1376
|
+
const pushAuto = m => {
|
|
1377
|
+
if (m && !autoMs.includes(m) && autoMs.length < progress.MILESTONE_MAX) autoMs.push(m)
|
|
1378
|
+
}
|
|
1379
|
+
const autoBlock = () => (autoMs.length ? autoMs.map(m => '▶ ' + m).join('\n') + '\n\n' : '')
|
|
1380
|
+
const pulseFrom = Date.now()
|
|
1381
|
+
let cardTouchedAt = 0
|
|
1382
|
+
const progressClosed = () => rt.v2RunnerGeneration !== generation ||
|
|
1383
|
+
rt.v2TerminalStarted || controller.signal.aborted || rt.v2CancelControl ||
|
|
1384
|
+
rt.v2StreamQuiescing || !!authority.scopeState('stopIntent', chatId)
|
|
1385
|
+
const enqueueCardText = text => {
|
|
1358
1386
|
rt.v2StreamRequested = true
|
|
1359
1387
|
streamChain = streamChain.then(async () => {
|
|
1360
1388
|
if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
|
|
1361
|
-
|
|
1389
|
+
// An earlier frame or pulse may still await its platform response;
|
|
1390
|
+
// only a closed authority ends the stream.
|
|
1391
|
+
if (authority.closed) throw new Error('C2 v2 stream authority closed')
|
|
1362
1392
|
if (controller.signal.aborted || rt.v2CancelControl ||
|
|
1363
1393
|
authority.scopeState('stopIntent', chatId)) return
|
|
1364
1394
|
if (!rt.v2CardBinding) {
|
|
@@ -1368,8 +1398,10 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1368
1398
|
generation, verifyGeneration,
|
|
1369
1399
|
applyLocalEffect: this.durableV2ApplyLocalEffect,
|
|
1370
1400
|
assertWriterLock: this.durableV2AssertWriterLock,
|
|
1371
|
-
|
|
1372
|
-
|
|
1401
|
+
// An early card may end as the supported quota failure; its final
|
|
1402
|
+
// text then uses the same fixed notice as an unopened card.
|
|
1403
|
+
redactText: value => redact(rt.v2CardQuotaFailure === true
|
|
1404
|
+
? T.quotaFailure : value, { pii: job.group?.redactPii !== false }),
|
|
1373
1405
|
sendPlainReply: typeof job.platform.replyOnce === 'function'
|
|
1374
1406
|
? (target, value) => job.platform.replyOnce(target.replyCtx, value)
|
|
1375
1407
|
: null })
|
|
@@ -1385,6 +1417,82 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1385
1417
|
try { controller.abort() } catch {}
|
|
1386
1418
|
})
|
|
1387
1419
|
}
|
|
1420
|
+
const enqueueFrame = event => {
|
|
1421
|
+
if (event.type !== 'assistant') return
|
|
1422
|
+
// /status and the pulse read these; a late event from an older
|
|
1423
|
+
// generation must not write progress into this turn.
|
|
1424
|
+
if (rt.v2RunnerGeneration === generation) {
|
|
1425
|
+
rt.v2AliveAt = Date.now()
|
|
1426
|
+
if (event.tools && event.tools.length) {
|
|
1427
|
+
rt.v2Steps = (rt.v2Steps || 0) + event.tools.length
|
|
1428
|
+
for (const tool of event.tools) {
|
|
1429
|
+
progress.pushDoing(rt.v2Doing, progress.describeTool(tool.name, tool.input))
|
|
1430
|
+
pushAuto(progress.autoMilestone(tool.name, tool.input))
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
if (!this.durableV2StreamFrames) return
|
|
1435
|
+
if (!event.text || controller.signal.aborted || rt.v2CancelControl ||
|
|
1436
|
+
rt.v2StreamQuiescing ||
|
|
1437
|
+
authority.scopeState('stopIntent', chatId)) return
|
|
1438
|
+
const buffer = projection.streamSnapshot().bufferText
|
|
1439
|
+
const trimmed = buffer.trim()
|
|
1440
|
+
if (!trimmed || NO_REPLY.startsWith(trimmed)) return
|
|
1441
|
+
cardTouchedAt = Date.now()
|
|
1442
|
+
enqueueCardText(autoBlock() + progress.renderMilestones(buffer))
|
|
1443
|
+
}
|
|
1444
|
+
const cardsVisible = this.durableV2StreamFrames && !receipt.quiet &&
|
|
1445
|
+
typeof job.platform?.createStreamingCard === 'function'
|
|
1446
|
+
// No visible output after ackDelayMs: open the card that would be opened
|
|
1447
|
+
// anyway and fill it with "received"; the answer replaces it in place and
|
|
1448
|
+
// a turn that ends silent recalls it at the terminal.
|
|
1449
|
+
const ackMs = this.cfg.agent?.ackDelayMs == null ? 25_000 : Number(this.cfg.agent.ackDelayMs)
|
|
1450
|
+
// No card before the runner is accepted. A runner accepted after the
|
|
1451
|
+
// delay (Codex holds its events until its turn start returns) gets the
|
|
1452
|
+
// card as soon as it is accepted.
|
|
1453
|
+
const tryAck = () => {
|
|
1454
|
+
if (!accepted) { ackDue = true; return }
|
|
1455
|
+
if (progressClosed() || rt.v2CardBinding || rt.v2StreamRequested ||
|
|
1456
|
+
projection.streamSnapshot().bufferText.trim()) return
|
|
1457
|
+
cardTouchedAt = Date.now()
|
|
1458
|
+
enqueueCardText(progress.T.ackWorking)
|
|
1459
|
+
}
|
|
1460
|
+
const armAck = delayMs => {
|
|
1461
|
+
ackTimer = setTimeout(() => { ackTimer = null; tryAck() }, delayMs)
|
|
1462
|
+
if (ackTimer.unref) ackTimer.unref()
|
|
1463
|
+
}
|
|
1464
|
+
if (cardsVisible && ackMs > 0) armAck(ackMs)
|
|
1465
|
+
// Pulse: only rewrites the open card. Every PULSE_MS for the first
|
|
1466
|
+
// PULSE_SLOW_AFTER_MS, then every PULSE_SLOW_MS; "recent activity" is only
|
|
1467
|
+
// added after PULSE_STALE_MS without an assistant event.
|
|
1468
|
+
const agentCfg = this.cfg.agent || {}
|
|
1469
|
+
const PULSE_MS = agentCfg.cardPulseMs == null ? 15_000 : Number(agentCfg.cardPulseMs)
|
|
1470
|
+
const PULSE_SLOW_MS = agentCfg.cardPulseSlowMs == null ? 60_000 : Number(agentCfg.cardPulseSlowMs)
|
|
1471
|
+
const PULSE_SLOW_AFTER_MS = agentCfg.cardPulseSlowAfterMs == null ? 10 * 60 * 1000 : Number(agentCfg.cardPulseSlowAfterMs)
|
|
1472
|
+
const PULSE_STALE_MS = agentCfg.cardPulseStaleMs == null ? 2 * 60 * 1000 : Number(agentCfg.cardPulseStaleMs)
|
|
1473
|
+
if (cardsVisible && PULSE_MS > 0) {
|
|
1474
|
+
const pulseEvery = () => (Date.now() - pulseFrom >= PULSE_SLOW_AFTER_MS
|
|
1475
|
+
? Math.max(PULSE_MS, PULSE_SLOW_MS) : PULSE_MS)
|
|
1476
|
+
pulseTimer = setInterval(() => {
|
|
1477
|
+
const binding = rt.v2CardBinding
|
|
1478
|
+
if (progressClosed() || !binding?.opened || binding.generation !== generation) return
|
|
1479
|
+
if (Date.now() - cardTouchedAt < pulseEvery()) return
|
|
1480
|
+
cardTouchedAt = Date.now()
|
|
1481
|
+
const sinceAlive = Date.now() - (rt.v2AliveAt || pulseFrom)
|
|
1482
|
+
const stale = sinceAlive >= PULSE_STALE_MS ? progress.humanDur(sinceAlive) : ''
|
|
1483
|
+
// Same gate as the stream frame: text that may still become NO_REPLY
|
|
1484
|
+
// never reaches the card.
|
|
1485
|
+
const buffer = projection.streamSnapshot().bufferText
|
|
1486
|
+
const sofar = buffer.trim()
|
|
1487
|
+
const body = autoBlock() + (sofar && !NO_REPLY.startsWith(sofar)
|
|
1488
|
+
? progress.renderMilestones(buffer) + '\n\n' : '')
|
|
1489
|
+
try {
|
|
1490
|
+
binding.path.update(body + progress.T.pulse(
|
|
1491
|
+
progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
|
|
1492
|
+
} catch { /* a closed card never affects the turn */ }
|
|
1493
|
+
}, PULSE_MS)
|
|
1494
|
+
if (pulseTimer.unref) pulseTimer.unref()
|
|
1495
|
+
}
|
|
1388
1496
|
const maxMins = Number(this.cfg.agent.maxTurnTimeMins) || 0
|
|
1389
1497
|
const timeoutMs = maxMins > 0 ? maxMins * 60_000 : 0
|
|
1390
1498
|
const timeoutDeadlineAt = timeoutMs > 0 ? Date.now() + timeoutMs : null
|
|
@@ -1416,6 +1524,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1416
1524
|
onTurnAccepted: value => {
|
|
1417
1525
|
const ack = capability.onTurnAccepted(value)
|
|
1418
1526
|
accepted = true
|
|
1527
|
+
if (ackDue) { ackDue = false; armAck(0) }
|
|
1419
1528
|
return ack
|
|
1420
1529
|
},
|
|
1421
1530
|
onSpawnFailed: value => capability.onSpawnFailed(value),
|
|
@@ -1440,6 +1549,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1440
1549
|
let result
|
|
1441
1550
|
try {
|
|
1442
1551
|
result = await running
|
|
1552
|
+
stopProgressTimers()
|
|
1443
1553
|
rt.v2SteerClosing = true
|
|
1444
1554
|
rt.v2SteerControl = null
|
|
1445
1555
|
if (rt.v2SteerDrain) await rt.v2SteerDrain
|
|
@@ -1462,6 +1572,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1462
1572
|
}
|
|
1463
1573
|
finally {
|
|
1464
1574
|
if (capTimer) clearTimeout(capTimer)
|
|
1575
|
+
stopProgressTimers()
|
|
1465
1576
|
if (rt.v2Controller === controller) rt.v2Controller = null
|
|
1466
1577
|
}
|
|
1467
1578
|
if (authority.blocked && rt.v2RunnerStopVerified !== true) {
|
|
@@ -1512,6 +1623,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1512
1623
|
return { effectId, accepted: true, outputHeld: true }
|
|
1513
1624
|
} catch (error) {
|
|
1514
1625
|
if (capTimer) clearTimeout(capTimer)
|
|
1626
|
+
stopProgressTimers()
|
|
1515
1627
|
if (controller && rt.v2Controller === controller) rt.v2Controller = null
|
|
1516
1628
|
const proofId = `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
|
|
1517
1629
|
const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
|
|
@@ -1620,7 +1732,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1620
1732
|
verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
|
|
1621
1733
|
assertWriterLock: this.durableV2AssertWriterLock,
|
|
1622
1734
|
redactText: text => redact(quotaFailure
|
|
1623
|
-
?
|
|
1735
|
+
? T.quotaFailure
|
|
1624
1736
|
: text, { pii: job.group?.redactPii !== false }),
|
|
1625
1737
|
sendPlainReply: typeof job.platform.replyOnce === 'function'
|
|
1626
1738
|
? (target, text) => job.platform.replyOnce(target.replyCtx, text)
|
|
@@ -1631,7 +1743,15 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1631
1743
|
for (const text of updates) path.update(text)
|
|
1632
1744
|
const footer = T.cardFooter({ ms: Math.max(0, Date.now() - rt.v2StartedAt),
|
|
1633
1745
|
tokens: rt.v2UsageDecision?.tokens || null })
|
|
1634
|
-
|
|
1746
|
+
// Milestones from tool calls first, then the model's own `>> ` lines;
|
|
1747
|
+
// the card shows them in its "process" fold (display only).
|
|
1748
|
+
rt.v2CardQuotaFailure = !!quotaFailure
|
|
1749
|
+
const redactMilestone = value => redact(value, { pii: job.group?.redactPii !== false })
|
|
1750
|
+
const milestones = [...(rt.v2AutoMilestones || []),
|
|
1751
|
+
...progress.milestonesOf(payload.bufferText)]
|
|
1752
|
+
.filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
|
|
1753
|
+
.filter(m => typeof m === 'string' && m.trim())
|
|
1754
|
+
const completed = await path.complete({ payload, replyText, paused, footer, milestones })
|
|
1635
1755
|
const after = authority.snapshot()
|
|
1636
1756
|
const queue = after.state?.lanes?.queue?.[chatId]?.queue
|
|
1637
1757
|
let successor = null
|
|
@@ -1668,6 +1788,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1668
1788
|
rt.v2TerminalProjection = null
|
|
1669
1789
|
rt.v2UsageDecision = null
|
|
1670
1790
|
rt.v2StartedAt = null
|
|
1791
|
+
rt.v2AutoMilestones = []
|
|
1792
|
+
rt.v2CardQuotaFailure = false
|
|
1671
1793
|
rt.v2CardBinding = null
|
|
1672
1794
|
rt.v2StreamRequested = false
|
|
1673
1795
|
rt.v2TimeoutProof = null
|
package/src/ui/console.html
CHANGED
|
@@ -743,6 +743,32 @@ h2.sec{font-family:var(--sans);font-size:14px;letter-spacing:.2px;color:var(--in
|
|
|
743
743
|
.modal-bg.show{display:flex}
|
|
744
744
|
.modal{width:min(520px,92vw);background:var(--modal-bg);border:1px solid var(--line-hi);border-radius:16px;padding:26px;max-height:82vh;overflow:auto}
|
|
745
745
|
.modal h3{font-family:var(--sans);font-size:15px;font-weight:600;margin:0 0 18px;letter-spacing:.4px}
|
|
746
|
+
/* ── 控制台升级:确认 + 进度弹窗 ── 每一步的状态只从升级任务记录推出来,重载、断线重连后画出同一张 */
|
|
747
|
+
.modal.upg h3{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
|
748
|
+
.upg-sub{font-size:12px;color:var(--dim);margin:0 0 16px;display:flex;gap:10px;flex-wrap:wrap}
|
|
749
|
+
.upg-bar{height:4px;border-radius:4px;background:var(--line);overflow:hidden;margin:0 0 14px;position:relative}
|
|
750
|
+
.upg-bar>i{position:absolute;inset:0 auto 0 0;border-radius:4px;background:var(--ember);transition:width .5s var(--ease,ease)}
|
|
751
|
+
.upg-bar.run>i::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);animation:upg-sheen 1.3s linear infinite}
|
|
752
|
+
.upg-bar.ok>i{background:var(--ok)}.upg-bar.fail>i{background:var(--fail)}
|
|
753
|
+
@keyframes upg-sheen{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
|
754
|
+
.upg-steps{list-style:none;margin:0 0 12px;padding:0;display:grid;gap:2px}
|
|
755
|
+
.upg-steps li{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:9px;font-size:13px;color:var(--faint)}
|
|
756
|
+
.upg-steps li.done{color:var(--ink)}
|
|
757
|
+
.upg-steps li.active{color:var(--ink);background:rgba(var(--ember-rgb),.08);font-weight:600}
|
|
758
|
+
.upg-steps li.fail{color:var(--fail);background:rgba(var(--fail-rgb),.08);font-weight:600}
|
|
759
|
+
.upg-steps .dot{width:16px;height:16px;flex:none;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
|
|
760
|
+
border:1.5px solid var(--line-hi);font-size:10px;line-height:1;box-sizing:border-box}
|
|
761
|
+
.upg-steps li.done .dot{border-color:var(--ok);background:var(--ok);color:var(--modal-bg)}
|
|
762
|
+
.upg-steps li.fail .dot{border-color:var(--fail);background:var(--fail);color:var(--modal-bg)}
|
|
763
|
+
.upg-steps li.active .dot{border-color:transparent}
|
|
764
|
+
.upg-steps li.active .dot .spin{width:12px;height:12px}
|
|
765
|
+
.upg-mark{width:22px;height:22px;flex:none;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:var(--modal-bg)}
|
|
766
|
+
.upg-mark.ok{background:var(--ok)}.upg-mark.fail{background:var(--fail)}
|
|
767
|
+
.upg-note{font-size:12.5px;line-height:1.6;color:var(--dim);padding:10px 12px;border-radius:10px;background:var(--inset-bg);margin:0 0 16px}
|
|
768
|
+
.upg-note.warn{color:var(--warn)}.upg-note.fail{color:var(--fail)}
|
|
769
|
+
.upg-btns{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap}
|
|
770
|
+
.mark .ver-update.upgrading .update-dot{width:8px;height:8px;background:transparent;box-shadow:none;border:1.5px solid var(--spin-track);
|
|
771
|
+
border-top-color:var(--ember);animation:sp .8s linear infinite}
|
|
746
772
|
/* ── 新建/编辑自定义风格(左表单 · 右实时预览)────────────────────────────────
|
|
747
773
|
预览必须**一直在视野里** —— 这张表单的每一项都对应预览图的一条通道,改哪儿图上立刻变哪儿
|
|
748
774
|
才是它区别于「填一份 YAML」的全部价值;所以右列 sticky,滚表单时图不走。 */
|
|
@@ -3370,7 +3396,8 @@ function openSay(chatId){
|
|
|
3370
3396
|
}catch(e){ toast(e.message,'err'); go.disabled=false; const t2=$('#sayGoTxt'); if(t2) t2.textContent=old }
|
|
3371
3397
|
};
|
|
3372
3398
|
}
|
|
3373
|
-
function closeModal(){clearTimeout(nsTimer);nsTimer=null;nsSeq++;sayRefresh=null;$('#modalBg').classList.remove('show');$('#modal').className='modal';
|
|
3399
|
+
function closeModal(){clearTimeout(nsTimer);nsTimer=null;nsSeq++;sayRefresh=null;$('#modalBg').classList.remove('show');$('#modal').className='modal';
|
|
3400
|
+
try{upgOnClose();}catch{}} // 升级弹窗:升级照常进行,只收起
|
|
3374
3401
|
$('#modalBg').onclick=e=>{if(e.target.id==='modalBg')closeModal();};
|
|
3375
3402
|
|
|
3376
3403
|
/* ── playground ── */
|
|
@@ -3494,7 +3521,7 @@ function renderSettings(){
|
|
|
3494
3521
|
:updMode==='foreground'?'前台运行不能在控制台升级:在运行 SintBot 的终端按 Ctrl-C 等待退出,重新运行一行安装命令 curl -fsSL https://sintbot.com/install.sh | bash,再用原来的启动命令启动。'
|
|
3495
3522
|
:updMode==='dev'?'开发版不在控制台升级,请用部署脚本。':'只安装检测到的稳定版本;所有群任务与队列清空后才会安装。需要可写的全局安装目录,不会提权;后台服务会自动重启,前台运行需手动重启。';
|
|
3496
3523
|
const updateCard=`<div class="card" style="grid-column:1/-1" id="updateCard"><div class="lbl">软件更新</div>
|
|
3497
|
-
<div id="updateStatus" class="hint" style="margin:0 0 12px;color:var(--ink)">${updateText}</div>
|
|
3524
|
+
<div id="updateStatus" class="hint" style="margin:0 0 12px;color:var(--ink);display:flex;align-items:center;gap:8px">${updMode==='managed'&&updateBusy?'<span class="spin"></span>':''}<span>${updateText}</span></div>
|
|
3498
3525
|
${upd&&upd.available?`<div class="hint" style="margin:-4px 0 12px">版本差异与发布说明:<a href="https://github.com/sintlab/sintbot/releases/tag/v${encodeURIComponent(upd.latestVersion)}" target="_blank" rel="noopener" style="color:var(--ember-soft)">查看 v${esc(upd.latestVersion)} 发布说明</a></div>`:''}
|
|
3499
3526
|
<div class="inline" style="gap:14px;flex-wrap:wrap;margin-bottom:12px">
|
|
3500
3527
|
<span class="dim">自动检查稳定版(启动时及每天)</span>
|
|
@@ -3503,6 +3530,8 @@ function renderSettings(){
|
|
|
3503
3530
|
<div class="inline" style="gap:8px;flex-wrap:wrap">
|
|
3504
3531
|
<button class="btn" id="updCheck" ${!upd?'disabled':''}>检查新版</button>
|
|
3505
3532
|
${updCanInstall?`<button class="btn primary" id="updInstall" ${!upd||!upd.available||updateBusy?'disabled':''}>现在升级</button>`:''}
|
|
3533
|
+
${updMode==='managed'&&updateJob&&(updateBusy||['failed','rolled-back'].includes(updateJob.state)||upgWatchId()===updateJob.id)
|
|
3534
|
+
?`<button class="btn" id="updProgress">${updateBusy?'查看进度':'查看上次升级'}</button>`:''}
|
|
3506
3535
|
<button class="btn" id="updIgnore" ${!upd||!upd.available?'disabled':''}>忽略此版本</button>
|
|
3507
3536
|
</div>
|
|
3508
3537
|
<div class="hint" style="margin-top:10px">${updHint}</div>
|
|
@@ -3557,8 +3586,11 @@ function renderSettings(){
|
|
|
3557
3586
|
$('#updCheck').onclick=async()=>{const b=$('#updCheck');b.disabled=true;b.textContent='检查中…';
|
|
3558
3587
|
try{await api('/api/update/check','POST',{});S=await api('/api/state');await refreshInbox();renderSettings();renderOverview();toast('版本检查完成','ok');}
|
|
3559
3588
|
catch(e){toast(e.message,'err');b.disabled=false;b.textContent='检查新版';}};
|
|
3589
|
+
if($('#updProgress'))$('#updProgress').onclick=()=>upgOpen(true);
|
|
3560
3590
|
if($('#updInstall'))$('#updInstall').onclick=async()=>{const u=S.update||{},version=u.latestVersion;if(!version)return;
|
|
3561
3591
|
const announce=$('#updAnnounce')?$('#updAnnounce').checked:false;
|
|
3592
|
+
// 后台托管:页内确认 → 进度弹窗(每一步、断线重连、完成后刷新到新版控制台)。前台 / 旧式更新器仍走下面的原生确认
|
|
3593
|
+
if(u.mode==='managed'){upgConfirm(announce);return;}
|
|
3562
3594
|
const what=u.mode==='managed'?'等所有群空闲、记录结清后装到单独的目录,再切换后台服务;切换期间约一分钟不处理新消息,新版本没连上全部群会自动换回。':`${announce?'安装完成后会向未暂停的群播报;':'本次不向群播报。'}所有群任务结束后才开始安装,后台服务会重启。`;
|
|
3563
3595
|
if(!confirm(`将从 v${u.currentVersion} 升级到 v${version}。${what}继续?`))return;
|
|
3564
3596
|
const b=$('#updInstall');b.disabled=true;
|
|
@@ -3927,9 +3959,209 @@ document.addEventListener('click',(e)=>{const b=e.target.closest&&e.target.close
|
|
|
3927
3959
|
function renderAll(){renderOverview();renderProviders();renderGroups();renderPlayground();renderSettings();updateDaemon();updateTopFxApp();applyRuntimeCapabilities();}
|
|
3928
3960
|
function updateTopUpdateBadge(){
|
|
3929
3961
|
const badge=$('#topUpdateBadge');if(!badge)return;
|
|
3930
|
-
|
|
3962
|
+
const busy=upgManaged()&&upgIsActive(upgJob());
|
|
3963
|
+
badge.hidden=!(S&&S.update&&(S.update.available||busy));
|
|
3964
|
+
badge.classList.toggle('upgrading',busy);
|
|
3965
|
+
const tip=busy?'正在升级,查看进度':'发现新版本,打开软件更新';
|
|
3966
|
+
if(badge.title!==tip){badge.title=tip;badge.setAttribute('aria-label',tip);}
|
|
3967
|
+
upgEnsureWatch();
|
|
3931
3968
|
}
|
|
3969
|
+
|
|
3970
|
+
/* UPGRADE_PROGRESS_BEGIN ── 控制台升级的进度弹窗 ──────────────────────────────────────────
|
|
3971
|
+
点「现在升级」→ 页内确认 → 进度弹窗。升级期间每 1.5 秒读一次 GET /api/update;服务切换那几秒
|
|
3972
|
+
连不上(或反代回 502)就显示「服务正在重启,页面会自动重连」,不当成错误;重启后旧令牌作废(环回
|
|
3973
|
+
免密模式)则交给 checkStaleAuth 整页重载。新版本连上全部群、任务记为 done 后,这一页还是旧版前端
|
|
3974
|
+
⇒ 倒数 3 秒整页刷新,刷新后按 sessionStorage 记下的任务 id 再把结果弹出来一次。
|
|
3975
|
+
每一步的状态只从任务记录(state / prefix / installed / steps)推出来,不靠页面记忆。 */
|
|
3976
|
+
const UPG_PHASES=[
|
|
3977
|
+
['wait','等待所有群空闲'],
|
|
3978
|
+
['install','下载并安装新版本'],
|
|
3979
|
+
['stop','停止旧服务'],
|
|
3980
|
+
['backup','核对群记录并备份数据'],
|
|
3981
|
+
['preflight','新版本预检'],
|
|
3982
|
+
['start','切换并启动新版本'],
|
|
3983
|
+
['connect','等待全部群连上'],
|
|
3984
|
+
];
|
|
3985
|
+
function upgIsActive(j){return !!(j&&['queued','installing','switching'].includes(j.state));}
|
|
3986
|
+
function upgVer(v){return String(v||'').trim().replace(/^v/i,'');}
|
|
3987
|
+
function upgStep(j,name){const s=((j&&j.steps)||[]).find(x=>x&&x.step===name);return s?(s.ok?'ok':'bad'):'';}
|
|
3988
|
+
// → [{key,label,state:'done'|'active'|'fail'|'todo'}]:最后一个已到达的步骤之前全算完成,下一步是
|
|
3989
|
+
// 进行中(任务还在跑)或失败(任务已失败 / 已换回);换回旧版本时多一行「换回 vX」。
|
|
3990
|
+
function upgPhases(j){
|
|
3991
|
+
if(!j)return [];
|
|
3992
|
+
const st=j.state, ok=n=>upgStep(j,n)==='ok';
|
|
3993
|
+
const reached={
|
|
3994
|
+
wait:!!(j.prefix||j.installed)||st==='installing'||st==='switching',
|
|
3995
|
+
install:!!j.installed,
|
|
3996
|
+
stop:ok('stop service'),
|
|
3997
|
+
backup:ok('journals unchanged')&&ok('data backed up'),
|
|
3998
|
+
preflight:ok('new version preflight'),
|
|
3999
|
+
start:ok('service entry replaced')&&ok('start new version'),
|
|
4000
|
+
connect:st==='done',
|
|
4001
|
+
};
|
|
4002
|
+
let last=-1;UPG_PHASES.forEach(([k],i)=>{if(reached[k])last=i;});
|
|
4003
|
+
if(st==='done')last=UPG_PHASES.length-1;
|
|
4004
|
+
const active=upgIsActive(j), failed=st==='failed'||st==='rolled-back';
|
|
4005
|
+
const out=UPG_PHASES.map(([key,label],i)=>({key,label,
|
|
4006
|
+
state:i<=last?'done':i===last+1?(active?'active':failed?'fail':'todo'):'todo'}));
|
|
4007
|
+
if(upgStep(j,'rollback')){
|
|
4008
|
+
const back=upgStep(j,'previous version started');
|
|
4009
|
+
out.push({key:'rollback',label:`换回 v${upgVer(j.from)}`,state:back==='ok'||st==='rolled-back'?'done':back==='bad'||st==='failed'?'fail':'active'});
|
|
4010
|
+
}
|
|
4011
|
+
return out;
|
|
4012
|
+
}
|
|
4013
|
+
// 连不上时是不是在切换:已进入安装 / 已装好 / 已在切换(排队等空闲时服务不会自己重启)
|
|
4014
|
+
function upgRestarting(j){return !!(j&&(j.state==='switching'||j.state==='installing'||j.installed));}
|
|
4015
|
+
function upgDur(ms){const s=Math.max(0,Math.round(ms/1000));return s<60?`${s} 秒`:`${Math.floor(s/60)} 分 ${String(s%60).padStart(2,'0')} 秒`;}
|
|
4016
|
+
/* UPGRADE_PROGRESS_LOGIC_END */
|
|
4017
|
+
const upg={timer:0,tick:0,polling:false,open:false,view:'',offlineSince:0,waiting:[],reloadAt:0,toasted:''};
|
|
4018
|
+
const UPG_PAGE_VERSION=upgVer((document.querySelector('.mark .ver')||{}).textContent);
|
|
4019
|
+
function upgWatchId(){try{return sessionStorage.getItem('sbot-upgrade-watch')||'';}catch{return '';}}
|
|
4020
|
+
function upgSetWatch(id){try{if(id)sessionStorage.setItem('sbot-upgrade-watch',id);else sessionStorage.removeItem('sbot-upgrade-watch');}catch{}}
|
|
4021
|
+
// 「在后台继续」收起的任务 id:服务重启后页面会整页重载(环回免密模式换令牌),收起的弹窗不能因此又弹出来
|
|
4022
|
+
function upgHiddenId(){try{return sessionStorage.getItem('sbot-upgrade-hidden')||'';}catch{return '';}}
|
|
4023
|
+
function upgSetHidden(id){try{if(id)sessionStorage.setItem('sbot-upgrade-hidden',id);else sessionStorage.removeItem('sbot-upgrade-hidden');}catch{}}
|
|
4024
|
+
function upgJob(){return (S&&S.update&&S.update.job)||null;}
|
|
4025
|
+
function upgManaged(){return !!(S&&S.update&&S.update.mode==='managed');}
|
|
4026
|
+
function upgModalShown(){return !!(upg.open&&upg.view==='progress'&&$('#modalBg').classList.contains('show')&&$('#modal').classList.contains('upg'));}
|
|
4027
|
+
function upgSchedule(ms=1500){clearTimeout(upg.timer);upg.timer=setTimeout(upgPoll,ms);}
|
|
4028
|
+
// 由 updateTopUpdateBadge 在每次状态更新时调用:任务在跑就保证轮询在走;这一页发起(或看过)的任务
|
|
4029
|
+
// 刚结束、页面刚刷新过来 ⇒ 把结果弹出来一次。
|
|
4030
|
+
function upgEnsureWatch(){
|
|
4031
|
+
if(!upgManaged())return;
|
|
4032
|
+
const j=upgJob();if(!j)return;
|
|
4033
|
+
if(upgIsActive(j)&&!upg.timer&&!upg.polling)upgSchedule(1500);
|
|
4034
|
+
if(upg.open||upgWatchId()!==j.id||$('#modalBg').classList.contains('show'))return;
|
|
4035
|
+
if(upgHiddenId()!==j.id)upgOpen(false);
|
|
4036
|
+
else if(!upgIsActive(j))upgNotifyDone(j); // 收起过:结束时只发一条提示
|
|
4037
|
+
}
|
|
4038
|
+
function upgNotifyDone(j){
|
|
4039
|
+
if(!j||upg.toasted===j.id)return;
|
|
4040
|
+
upg.toasted=j.id;upgSetWatch('');upgSetHidden('');
|
|
4041
|
+
toast(j.message||(j.state==='done'?`已升级到 v${upgVer(j.to)}`:'升级没有完成'),j.state==='done'?'ok':'err');
|
|
4042
|
+
}
|
|
4043
|
+
async function upgPoll(){
|
|
4044
|
+
upg.timer=0;if(upg.polling)return;upg.polling=true;
|
|
4045
|
+
let r=null,data=null;
|
|
4046
|
+
try{
|
|
4047
|
+
try{r=await fetch('/api/update',{headers:{'X-Sbot-Token':TOKEN},cache:'no-store'});}catch{}
|
|
4048
|
+
if(r&&r.status===401){upgRender();checkStaleAuth();return;} // 重启后令牌作废:整页重载或落到登录框
|
|
4049
|
+
if(r&&r.ok){try{data=await r.json();}catch{}}
|
|
4050
|
+
if(!data||!data.currentVersion){ // 断线 / 反代 502 / 服务重启中
|
|
4051
|
+
if(!upg.offlineSince)upg.offlineSince=Date.now();
|
|
4052
|
+
upgRender();upgSchedule(2000);return;
|
|
4053
|
+
}
|
|
4054
|
+
const wasOffline=!!upg.offlineSince;upg.offlineSince=0;
|
|
4055
|
+
upg.waiting=Array.isArray(data.waitingGroups)?data.waitingGroups:[];
|
|
4056
|
+
const {waitingGroups,...update}=data;
|
|
4057
|
+
S.update={...(S.update||{}),...update};
|
|
4058
|
+
const j=update.job;
|
|
4059
|
+
if(upgIsActive(j)){upgRender();updateTopUpdateBadge();upgSchedule(1500);return;}
|
|
4060
|
+
upgFinished(j,update,wasOffline);
|
|
4061
|
+
}finally{upg.polling=false;}
|
|
4062
|
+
}
|
|
4063
|
+
// 任务结束(done / failed / rolled-back):这一页是旧版前端就倒数刷新;弹窗被收起了就只发一条提示。
|
|
4064
|
+
async function upgFinished(j,update,wasOffline){
|
|
4065
|
+
const mine=!!(j&&upgWatchId()===j.id);
|
|
4066
|
+
const stale=!!(j&&j.state==='done'&&UPG_PAGE_VERSION&&upgVer(update.currentVersion)!==UPG_PAGE_VERSION);
|
|
4067
|
+
if(j&&mine&&stale&&upgModalShown()&&!upg.reloadAt){
|
|
4068
|
+
upg.reloadAt=Date.now()+3000;upgRender();updateTopUpdateBadge();setTimeout(()=>location.reload(),3000);return;
|
|
4069
|
+
}
|
|
4070
|
+
if(j&&mine&&!upgModalShown())upgNotifyDone(j);
|
|
4071
|
+
upgRender();
|
|
4072
|
+
try{S=await api('/api/state');}catch{}
|
|
4073
|
+
updateTopUpdateBadge();
|
|
4074
|
+
if(!busyInteracting()){renderSettings();renderOverview();}
|
|
4075
|
+
else if(wasOffline&&!upgModalShown())renderOverview();
|
|
4076
|
+
}
|
|
4077
|
+
function upgConfirm(announce){
|
|
4078
|
+
const u=S.update||{};
|
|
4079
|
+
$('#modal').className='modal upg';
|
|
4080
|
+
$('#modal').innerHTML=`<h3>升级到 v${esc(upgVer(u.latestVersion))}</h3>
|
|
4081
|
+
<div class="upg-sub"><span class="mono">v${esc(upgVer(u.currentVersion))} → v${esc(upgVer(u.latestVersion))}</span></div>
|
|
4082
|
+
<div class="upg-note">等所有群空闲、记录结清后装到单独的目录,再切换后台服务;切换期间约一分钟不处理新消息,新版本没连上全部群会自动换回。</div>
|
|
4083
|
+
<div class="hint" style="margin:-6px 0 18px">开始后这里会显示每一步的进度;服务重启时页面会自动重连。</div>
|
|
4084
|
+
<div class="upg-btns"><button class="btn" id="upgCancel">取消</button><button class="btn primary" id="upgGo">开始升级</button></div>`;
|
|
4085
|
+
upg.open=true;upg.view='confirm';
|
|
4086
|
+
$('#modalBg').classList.add('show');
|
|
4087
|
+
$('#upgCancel').onclick=()=>closeModal();
|
|
4088
|
+
$('#upgGo').onclick=async()=>{
|
|
4089
|
+
const b=$('#upgGo');b.disabled=true;b.innerHTML='<span class="spin"></span> 正在提交…';
|
|
4090
|
+
try{
|
|
4091
|
+
const r=await api('/api/update/start','POST',{version:u.latestVersion,announce:!!announce});
|
|
4092
|
+
S.update={...(S.update||{}),...r};
|
|
4093
|
+
upgSetWatch(r.job&&r.job.id);upg.reloadAt=0;
|
|
4094
|
+
upgOpen(true);updateTopUpdateBadge();upgSchedule(600);
|
|
4095
|
+
}catch(e){
|
|
4096
|
+
if(e.payload&&e.payload.update)S.update={...(S.update||{}),...e.payload.update};
|
|
4097
|
+
toast(e.message,'err');b.disabled=false;b.textContent='开始升级';
|
|
4098
|
+
}
|
|
4099
|
+
};
|
|
4100
|
+
}
|
|
4101
|
+
// 打开进度 / 结果弹窗。user=true:用户点的(「查看进度」、顶栏图标、刚确认),记下任务 id 以便刷新后接着看。
|
|
4102
|
+
function upgOpen(user){
|
|
4103
|
+
const j=upgJob();if(!j)return;
|
|
4104
|
+
if(user){upgSetWatch(j.id);upgSetHidden('');}
|
|
4105
|
+
$('#modal').className='modal upg';
|
|
4106
|
+
upg.open=true;upg.view='progress';
|
|
4107
|
+
$('#modalBg').classList.add('show');
|
|
4108
|
+
upgRender();
|
|
4109
|
+
clearInterval(upg.tick);upg.tick=setInterval(()=>{if(!upgModalShown()){clearInterval(upg.tick);upg.tick=0;return;}upgRender();},1000);
|
|
4110
|
+
if(upgIsActive(j)&&!upg.timer&&!upg.polling)upgSchedule(300);
|
|
4111
|
+
}
|
|
4112
|
+
// 弹窗被关掉(按钮、点遮罩、别的弹窗):升级照常进行,顶栏图标保持转圈,结束时只发提示
|
|
4113
|
+
function upgOnClose(){
|
|
4114
|
+
if(!upg.open)return;
|
|
4115
|
+
const wasProgress=upg.view==='progress';
|
|
4116
|
+
upg.open=false;upg.view='';clearInterval(upg.tick);upg.tick=0;
|
|
4117
|
+
const j=upgJob();
|
|
4118
|
+
if(wasProgress&&upgIsActive(j))upgSetHidden(j.id);
|
|
4119
|
+
else if(wasProgress&&j&&upgWatchId()===j.id){upgSetWatch('');upgSetHidden('');}
|
|
4120
|
+
// 结果看完了:设置页那张卡在弹窗开着时没重画,这里补一次
|
|
4121
|
+
if(wasProgress&&!upgIsActive(j))setTimeout(()=>{if(!busyInteracting()){renderSettings();renderOverview();}},0);
|
|
4122
|
+
}
|
|
4123
|
+
function upgRender(){
|
|
4124
|
+
const j=upgJob(), statusBox=$('#updateStatus');
|
|
4125
|
+
if(statusBox&&j&&upgManaged()&&upgIsActive(j)){
|
|
4126
|
+
const text=upg.offlineSince?(upgRestarting(j)?'服务正在重启,页面会自动重连…':'暂时连不上服务,正在重试…')
|
|
4127
|
+
:j.state==='switching'&&upgStep(j,'start new version')==='ok'?'新版本已启动,正在等全部群连上…':(j.message||j.state);
|
|
4128
|
+
statusBox.innerHTML=`<span class="spin"></span><span>${esc(text)}</span>`;
|
|
4129
|
+
}
|
|
4130
|
+
if(!upgModalShown()||!j)return;
|
|
4131
|
+
// 安装一结束,切换进程不到一秒就停掉旧服务:页面最后看到的多半还是「安装中」。这时连不上 = 正在切换
|
|
4132
|
+
const view=upg.offlineSince&&upgRestarting(j)&&j.state!=='switching'?{...j,state:'switching',installed:true}:j;
|
|
4133
|
+
const active=upgIsActive(view), st=view.state, phases=upgPhases(view);
|
|
4134
|
+
const end=active?Date.now():(+j.finishedAt||+j.updatedAt||Date.now());
|
|
4135
|
+
const took=upgDur(end-(+j.createdAt||end));
|
|
4136
|
+
const doneN=phases.filter(p=>p.key!=='rollback'&&p.state==='done').length;
|
|
4137
|
+
const pct=st==='done'?100:Math.max(4,Math.round(doneN/UPG_PHASES.length*100));
|
|
4138
|
+
const to=esc(upgVer(j.to)), from=esc(upgVer(j.from));
|
|
4139
|
+
const head=st==='done'?`<span class="upg-mark ok">✓</span><span>已升级到 v${to}</span>`
|
|
4140
|
+
:active?`<span class="spin"></span><span>正在升级到 v${to}</span>`
|
|
4141
|
+
:`<span class="upg-mark fail">!</span><span>升级没有完成</span>`;
|
|
4142
|
+
let note,noteCls='';
|
|
4143
|
+
if(active&&upg.offlineSince){note=st==='switching'?'服务正在重启,页面会自动重连…':'暂时连不上服务,正在重试…';noteCls='warn';}
|
|
4144
|
+
else if(st==='switching'&&upgStep(view,'start new version')==='ok'){note='新版本已启动,正在等全部群连上…';}
|
|
4145
|
+
else if(st==='queued'&&upg.waiting.length){note=`还在运行的群:${upg.waiting.map(g=>g.name).join('、')}`;}
|
|
4146
|
+
else if(st==='done'&&upg.reloadAt){note=`页面将在 ${Math.max(0,Math.ceil((upg.reloadAt-Date.now())/1000))} 秒后刷新,载入新版控制台`;}
|
|
4147
|
+
else{note=j.message||st;if(!active&&st!=='done')noteCls='fail';}
|
|
4148
|
+
const icon=p=>p.state==='done'?'✓':p.state==='fail'?'!':p.state==='active'?'<span class="spin"></span>':'';
|
|
4149
|
+
const btns=active?`<button class="btn" id="upgHide">在后台继续</button>`
|
|
4150
|
+
:st==='done'&&upg.reloadAt?`<button class="btn primary" id="upgReload">现在刷新</button>`
|
|
4151
|
+
:`<button class="btn${st==='done'?' primary':''}" id="upgDone">${st==='done'?'完成':'关闭'}</button>`;
|
|
4152
|
+
$('#modal').innerHTML=`<h3>${head}</h3>
|
|
4153
|
+
<div class="upg-sub"><span class="mono">v${from} → v${to}</span><span>${active?`已用 ${took}`:`用时 ${took}`}</span></div>
|
|
4154
|
+
<div class="upg-bar ${active?'run':st==='done'?'ok':'fail'}"><i style="width:${pct}%"></i></div>
|
|
4155
|
+
<ol class="upg-steps">${phases.map(p=>`<li class="${p.state}"><span class="dot">${icon(p)}</span><span>${esc(p.label)}</span></li>`).join('')}</ol>
|
|
4156
|
+
<div class="upg-note ${noteCls}">${esc(note)}</div>
|
|
4157
|
+
<div class="upg-btns">${btns}</div>`;
|
|
4158
|
+
const hide=$('#upgHide');if(hide)hide.onclick=()=>closeModal();
|
|
4159
|
+
const done=$('#upgDone');if(done)done.onclick=()=>closeModal();
|
|
4160
|
+
const rl=$('#upgReload');if(rl)rl.onclick=()=>location.reload();
|
|
4161
|
+
}
|
|
4162
|
+
/* UPGRADE_PROGRESS_END */
|
|
3932
4163
|
(()=>{const badge=$('#topUpdateBadge');if(!badge)return;badge.onclick=()=>{
|
|
4164
|
+
if(upgManaged()&&upgIsActive(upgJob())){upgOpen(true);return;}
|
|
3933
4165
|
const nav=$('nav [data-tab="settings"]');if(nav)nav.click();
|
|
3934
4166
|
const card=$('#updateCard');if(!card)return;
|
|
3935
4167
|
requestAnimationFrame(()=>{card.scrollIntoView({behavior:'smooth',block:'start'});
|
|
@@ -4777,6 +5009,27 @@ const I18N_EN=[
|
|
|
4777
5009
|
["只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90 秒内连上全部群会自动换回。旧版本保留用于回滚,不提权,不在群里播报。","Installs only the detected stable version: once every group is idle and settled, it goes into its own folder and the background service switches to it; if the new version does not connect every group within 90 seconds, the previous one is restored. The previous version is kept for rollback. No elevated privileges and no group announcements."],
|
|
4778
5010
|
["前台运行不能在控制台升级:在运行 SintBot 的终端按 Ctrl-C 等待退出,重新运行一行安装命令 curl -fsSL https://sintbot.com/install.sh | bash,再用原来的启动命令启动。","A foreground run cannot upgrade from the console: press Ctrl-C in the terminal running SintBot and wait for it to exit, rerun the one-line installer curl -fsSL https://sintbot.com/install.sh | bash, then start with your usual command."],
|
|
4779
5011
|
["开发版不在控制台升级,请用部署脚本。","Development builds are not upgraded from the console; use the deployment scripts."],
|
|
5012
|
+
["查看进度","View progress"],
|
|
5013
|
+
["查看上次升级","View last upgrade"],
|
|
5014
|
+
["正在升级,查看进度","Upgrading; view progress"],
|
|
5015
|
+
["等所有群空闲、记录结清后装到单独的目录,再切换后台服务;切换期间约一分钟不处理新消息,新版本没连上全部群会自动换回。","Once every group is idle and settled, it installs into its own folder and switches the background service; new messages wait about a minute during the switch, and the previous version is restored if the new one does not connect every group."],
|
|
5016
|
+
["开始后这里会显示每一步的进度;服务重启时页面会自动重连。","Once started, this window shows each step; the page reconnects by itself while the service restarts."],
|
|
5017
|
+
["开始升级","Start upgrade"],
|
|
5018
|
+
["正在提交…","Submitting…"],
|
|
5019
|
+
["等待所有群空闲","Waiting for every group to be idle"],
|
|
5020
|
+
["下载并安装新版本","Download and install the new version"],
|
|
5021
|
+
["停止旧服务","Stop the current service"],
|
|
5022
|
+
["核对群记录并备份数据","Check group records and back up data"],
|
|
5023
|
+
["新版本预检","Preflight the new version"],
|
|
5024
|
+
["切换并启动新版本","Switch to and start the new version"],
|
|
5025
|
+
["等待全部群连上","Wait for every group to connect"],
|
|
5026
|
+
["升级没有完成","The upgrade did not complete"],
|
|
5027
|
+
["服务正在重启,页面会自动重连…","The service is restarting; this page reconnects by itself…"],
|
|
5028
|
+
["暂时连不上服务,正在重试…","Cannot reach the service right now; retrying…"],
|
|
5029
|
+
["新版本已启动,正在等全部群连上…","The new version has started; waiting for every group to connect…"],
|
|
5030
|
+
["在后台继续","Continue in background"],
|
|
5031
|
+
["现在刷新","Reload now"],
|
|
5032
|
+
["关闭","Close"],
|
|
4780
5033
|
["访问地址正在切换;页面即将重载","Changing the access address; this page will reload"],
|
|
4781
5034
|
["访问设置已保存","Access settings saved"],
|
|
4782
5035
|
["开启局域网访问前要设置至少 6 位访问密码","Set an access password of at least 6 characters before enabling LAN access"],
|
|
@@ -5404,6 +5657,15 @@ const I18N_EN_RX=[
|
|
|
5404
5657
|
[/^v([\d.]+) 已安装,等所有群空闲后切换$/,"v$1 is installed; switching when every group is idle"],
|
|
5405
5658
|
[/^正在切换到 v([\d.]+);期间的消息会提示稍后重发$/,"Switching to v$1; messages meanwhile are asked to be resent later"],
|
|
5406
5659
|
[/^已升级到 v([\d.]+)$/,"Upgraded to v$1"],
|
|
5660
|
+
[/^升级到 v([\d.]+)$/,"Upgrade to v$1"],
|
|
5661
|
+
[/^正在升级到 v([\d.]+)$/,"Upgrading to v$1"],
|
|
5662
|
+
[/^换回 v([\d.]+)$/,"Restore v$1"],
|
|
5663
|
+
[/^已用 (\d+) 秒$/,"Elapsed $1 s"],
|
|
5664
|
+
[/^已用 (\d+) 分 (\d+) 秒$/,"Elapsed $1 min $2 s"],
|
|
5665
|
+
[/^用时 (\d+) 秒$/,"Took $1 s"],
|
|
5666
|
+
[/^用时 (\d+) 分 (\d+) 秒$/,"Took $1 min $2 s"],
|
|
5667
|
+
[/^还在运行的群:([\s\S]+)$/,"Groups still working: $1"],
|
|
5668
|
+
[/^页面将在 (\d+) 秒后刷新,载入新版控制台$/,"Reloading in $1 s to load the new console"],
|
|
5407
5669
|
[/^没有切换到 v([\d.]+):([\s\S]*);已换回 v([\d.]+)$/,"Did not switch to v$1 ($2); v$3 was restored"],
|
|
5408
5670
|
[/^等待群空闲超时;可在空闲后重新发起$/,"Timed out waiting for idle groups; retry when they are idle"],
|
|
5409
5671
|
[/^有暂停的群,升级已取消;先处理暂停的群$/,"A group is paused, so the upgrade was cancelled; handle the paused group first"],
|
package/src/ui/serverbase.js
CHANGED
|
@@ -344,6 +344,15 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
344
344
|
} catch { /* 回填是锦上添花,读不了就空着 */ }
|
|
345
345
|
const inboxDismiss = dashboard.createDismissStore({ file: inboxDismissPath || path.join(os.homedir(), '.sbot', 'inbox-dismissed.json'), log })
|
|
346
346
|
let lastHealth = null // 最近一次 buildState 的健康检查,给只算收件箱的 /api/inbox 用(那里不重跑探针)
|
|
347
|
+
// 升级排队时还有任务、排队、未结或暂停的群(只给名字,升级弹窗据此说明在等谁)
|
|
348
|
+
const upgradeWaitingGroups = () => {
|
|
349
|
+
let live = null, groupsRaw = {}
|
|
350
|
+
try { live = getLiveState ? getLiveState() : null } catch { return [] }
|
|
351
|
+
try { groupsRaw = readRaw().groups || {} } catch {}
|
|
352
|
+
return Object.entries((live && live.groups) || {})
|
|
353
|
+
.filter(([, g]) => g && (g.busy || g.finalizing || g.waitingForCapacity || g.queue > 0 || g.pending > 0 || g.held))
|
|
354
|
+
.map(([chatId, g]) => ({ name: (groupsRaw[chatId] && groupsRaw[chatId].name) || chatId.slice(-8), held: !!g.held }))
|
|
355
|
+
}
|
|
347
356
|
// 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
|
|
348
357
|
const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
|
|
349
358
|
try {
|
|
@@ -839,6 +848,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
839
848
|
if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
|
|
840
849
|
// 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
|
|
841
850
|
if (p === '/api/changelog-en' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelogEn() })
|
|
851
|
+
// 升级进度:控制台的升级弹窗在升级期间每一两秒读一次。只读,只给升级状态和还有任务的群名,
|
|
852
|
+
// 不重算整份 state(那里有健康探针);服务重启的那几秒连不上,由页面当作「正在重启」处理。
|
|
853
|
+
if (p === '/api/update' && req.method === 'GET') {
|
|
854
|
+
if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
|
|
855
|
+
return sendJSON(res, 200, { ...updater.status(), waitingGroups: upgradeWaitingGroups() })
|
|
856
|
+
}
|
|
842
857
|
if (p === '/api/update/check' && req.method === 'POST') {
|
|
843
858
|
if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
|
|
844
859
|
return sendJSON(res, 200, await updater.check())
|