Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/keepalive-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Offline keepalive tests

on:
push:
pull_request:

jobs:
test:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
python: ['3.10', '3.12']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: python -m unittest discover -s tests -v
- run: node --check bridge-server.js
- run: node --check app.js
- run: node --check workbench-config.js
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
data/
*.log
papers/
keepalive.json

# 依赖
node_modules/
Expand Down
89 changes: 87 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,92 @@ python -m http.server 8899 # 或 npx serve -p 8899
# http://127.0.0.1:8899
```

> 💡 附带 `keepalive.py` 保活脚本:每 30 秒自动检测两个端口,服务挂了自动重启。
> 💡 附带 `keepalive.py` 保活脚本:默认每 30 秒检查服务的 HTTP 响应,异常退出后自动重启。手动运行上述命令的方式保持可用。

### 方式三:开机自启(推荐重度用户)

Windows 下把 `Startup` 目录里的 VBS 指向 `keepalive.py` 即可实现静默自启。
需要 **Windows、Python 3.10+(包含 `py` 启动器和 `pythonw.exe`)以及 Node.js 18+**。无需安装 Python 包、npm 包或管理员权限。在项目根目录打开 PowerShell:

```powershell
py -3 --version
node --version

# 先在前台验证:启动桥服务和静态服务器,默认地址如下
py -3 keepalive.py run
# 浏览器打开 http://127.0.0.1:8899
# 在这个窗口按 Ctrl+C,安全停止保活程序及它启动的服务

# 安装当前用户的登录自启(安装本身不会启动服务)
py -3 keepalive.py install-startup
```

也可运行 `Startup\install.cmd`。安装器读取 Windows 的当前用户 Startup 文件夹,生成 `GradWorkbench-<项目标识>.vbs`,写入本次使用的 `pythonw.exe`、项目和配置文件的**绝对路径**。VBS 使用隐藏窗口运行,支持中文及带空格的路径。仓库中的 `Startup/grad-workbench.vbs.in` 是模板,请通过安装器生成,勿直接复制。重复安装覆盖同一项目的同名入口。

这是**用户登录后**启动;不是登录前运行的 Windows 服务。安装后可立即运行安装器输出的 VBS 文件测试,或重新登录 Windows。重复点击 VBS、重复执行 `run`,或使用不同配置再次启动同一项目,都只保留一个保活实例。可在另一个终端查看和停止:

```powershell
py -3 keepalive.py status # 显示服务状态、端口、进程及启动次数
py -3 keepalive.py stop # 等待安全退出;默认最多等 30 秒
py -3 keepalive.py stop --timeout 60

# 取消下次登录启动;也可运行 Startup\uninstall.cmd
py -3 keepalive.py uninstall-startup
# 卸载启动入口不会停止当前会话;需要时再执行 stop
```

#### 配置端口与运行参数

```powershell
Copy-Item keepalive.example.json keepalive.json
notepad keepalive.json
py -3 keepalive.py run

# 也支持项目目录之外的配置文件(相对路径以执行命令时的目录为准)
py -3 keepalive.py run --config "D:\我的配置\workbench.json"
py -3 keepalive.py install-startup --config "D:\我的配置\workbench.json"
```

未指定 `--config` 时,读取脚本所在目录的 `keepalive.json`;文件不存在则使用默认值。配置修改后执行 `stop`,再运行 VBS 或 `run`。`stop`、`status` 按项目定位实例,无需重复填写配置文件。

| 字段 | 默认值 | 含义 |
|---|---|---|
| `bridge_port` | `5177` | 桥服务端口,1–65535 |
| `static_port` | `8899` | 网页端口,必须与桥端口不同 |
| `check_interval` | `30` | HTTP 健康检查间隔,秒 |
| `request_timeout` | `2` | 单次本地 HTTP 请求的超时,秒 |
| `startup_timeout` | `15` | 新进程的启动宽限时间,秒 |
| `failure_threshold` | `3` | 宽限结束后,连续检查失败几次才重启仍存活的子进程 |
| `restart_delay` | `1` | 故障后的首次重试等待,秒 |
| `max_restart_delay` | `60` | 指数退避的上限,秒;持续健康达到启动宽限时间后重置 |
| `shutdown_timeout` | `5` | 子进程优雅退出或终止的等待时间,秒 |
| `node` | `"node"` | Node 可执行文件名或绝对路径,如 `"C:/Program Files/nodejs/node.exe"` |

未知字段、无效数字和相同端口会报错,避免静默使用错误配置。新启动的桥通过 `WORKBENCH_BRIDGE_PORT` 获取端口。保活模式下,静态服务自动提供 `workbench-config.js`,网页的桥请求(包括数据恢复)使用配置后的端口,无需修改 `app.js`。更换网页端口后,直接访问 `http://127.0.0.1:<static_port>`;双击本地 HTML 的默认地址仍是 8899。

手动运行 `node bridge-server.js`、`python -m http.server 8899` 或 `npx serve -p 8899` 仍然可用。手动改桥端口时可先设置 `$env:WORKBENCH_BRIDGE_PORT = '5178'`,并修改 `workbench-config.js` 中的 `bridgePort`;自动保活会自行提供配置。浏览器的 localStorage 按网页来源区分,**更改网页端口前先导出备份**,新地址不会自动继承旧地址的数据。

#### 健康检查、恢复与退出规则

- 桥检查 `/health` 的服务标识、项目数据目录;静态服务检查 `/_workbench/health` 及 `index.html` 的实际内容。自有子进程还校验启动标识和进程号,避免把其他占用端口的服务认作自己的服务。
- 子进程异常退出在下一轮检查发现,按退避时间重启;HTTP 持续不健康时,先经过启动宽限和连续失败阈值,再重启。默认检查间隔为 30 秒,因此故障发现与恢复不是即时的。
- 已经手动运行、身份匹配且健康的服务可继续使用,状态为 `external`。普通手动静态服务器需要能通过 `/index.html` 提供本项目页面,且桥仍使用默认 5177;自定义桥端口请让保活程序启动静态服务。外部服务退出后保活可启动替代服务;仍占着端口但不健康的外部服务不会被强制终止。
- 无关程序占用端口时,状态为 `conflict`,保留该程序并记录日志,按退避时间继续重试;释放端口或修改配置后可恢复。不会按端口、进程名或旧 PID 批量杀进程。
- `stop` 和 Ctrl+C 会先停止保活循环,再尝试通过本地 HTTP 让自有子进程退出;超时后仅终止本实例持有进程句柄的子进程。手动启动的外部服务保留。若清理失败或未能确认退出结果,停止命令返回错误并提示查日志。网页中的桥操作在保活模式下显示为重启桥;**持续关闭请使用 `keepalive.py stop`**。
- 强制结束保活程序不属于安全退出:子进程可能仍运行。再次运行会通过系统锁和健康检查识别现状,避免重复启动;这些遗留服务按外部服务处理。正常停止请使用上面的停止命令。

#### 日志与排查

运行状态、带认证令牌的控制信息和日志位于 `%LOCALAPPDATA%\grad-workbench\<项目标识>\`,不放在静态网页目录中。`run` 会输出实际目录。`keepalive.log` 包含健康状态变化、启动失败、退出码、退避与子进程输出;单文件最大约 2 MiB,保留 3 个历史文件。控制端口仅监听 `127.0.0.1`,停止和查询必须带每次启动生成的令牌;停止命令不会对磁盘中的旧 PID 发送信号。系统文件锁在异常退出后自动释放,保留的 `instance.lock` 文件无需手动删除。

安装后未启动时,先在终端运行 `py -3 keepalive.py run` 查看错误。常见原因是 Node 未进入登录后的 PATH(在配置中填绝对路径)、Python/项目/配置目录被移动,或组织策略禁用了 Windows Script Host。移动项目或更换 Python 后,先从旧目录卸载启动入口,再在新目录安装。Startup 自启不提供保活程序自身崩溃后的守护;重新登录或重新运行 VBS 可启动它。

#### 离线自动化测试

```powershell
py -3 -m unittest discover -s tests -v
```

只需要本机 Python 3.10+、Node.js 18+,无需安装第三方包、登录账号、联网或准备用户数据。测试将源码复制到临时项目,使用合成网页、随机本地端口、独立状态目录和临时 Startup 目录。覆盖正常服务、真实 Node 桥、异常退出恢复、HTTP 不健康、端口冲突及释放后的恢复、重复启动、外部服务保护、带认证的安全停止,以及 Windows VBS 安装/启动/卸载。Windows 专用项在其他系统上自动跳过;测试不会修改真实用户 Startup 文件夹或仓库中的 `data/`。

## 🎨 定制属于你自己的版本

Expand All @@ -76,6 +157,10 @@ grad-workbench/
├── app.js # 全部逻辑
├── bridge-server.js # 可选:本地桥服务(Node)
├── keepalive.py # 可选:服务保活脚本
├── keepalive.example.json # 保活配置示例(本地 keepalive.json 不入库)
├── workbench-config.js # 手动运行时的网页端口默认值
├── Startup/ # VBS 模板及自启安装/卸载入口
├── tests/ # 无第三方依赖的离线自动化测试
├── agent_runner.py # 可选:AI Agent 运行器
├── assets/ # 图标资源
└── wordbooks/ # 英语词库(JSON)
Expand Down
7 changes: 7 additions & 0 deletions Startup/grad-workbench.vbs.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
' Template: install with `py -3 keepalive.py install-startup`.
' Paths and command are filled in by the installer; do not copy this template directly.
Option Explicit
Dim shell
Set shell = CreateObject("WScript.Shell")
shell.CurrentDirectory = @@WORKDIR@@
shell.Run @@COMMAND@@, 0, False
3 changes: 3 additions & 0 deletions Startup/install.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off
py -3 "%~dp0..\keepalive.py" install-startup %*
exit /b %errorlevel%
3 changes: 3 additions & 0 deletions Startup/uninstall.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off
py -3 "%~dp0..\keepalive.py" uninstall-startup %*
exit /b %errorlevel%
17 changes: 11 additions & 6 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ function saveJSON(key, val) {
scheduleSync();
}
/* ============ Agent 桥(本地服务 127.0.0.1:5177,自动同步到 data/sync.json) ============ */
const BRIDGE = 'http://127.0.0.1:5177';
const configuredBridgePort = window.WORKBENCH_CONFIG && window.WORKBENCH_CONFIG.bridgePort;
const BRIDGE = 'http://127.0.0.1:' + (Number.isInteger(configuredBridgePort) && configuredBridgePort >= 1 && configuredBridgePort <= 65535 ? configuredBridgePort : 5177);
const BRIDGE_SUPERVISED = !!(window.WORKBENCH_CONFIG && window.WORKBENCH_CONFIG.supervised);
let bridgeOnline = false;
let bridgeSyncTimer = null;
function bridgeCheck() {
Expand Down Expand Up @@ -76,16 +78,19 @@ function updateBridgeUI() {
}
const el = document.getElementById('bridgeInd');
if (el) {
el.textContent = bridgeOnline ? '⏹ 关闭桥服务' : '🔌 桥已关闭(点击开启)';
el.textContent = bridgeOnline ? (BRIDGE_SUPERVISED ? '🔄 重启桥服务' : '⏹ 关闭桥服务') : '🔌 桥已关闭(点击开启)';
el.className = 'bridge-ind ' + (bridgeOnline ? 'on' : 'off');
}
}
document.getElementById('bridgeInd').addEventListener('click', () => {
if (bridgeOnline) {
myConfirm('关闭 Agent 桥服务?\n关闭后网页与 Agent 的数据同步暂停(已同步的数据不丢失)。\n下次需要时:双击桌面「开启工作台桥」,或对 Hermes 说「开桥」。', () => {
const notice = BRIDGE_SUPERVISED
? '重启 Agent 桥服务?\n保活程序会自动重新启动桥服务。\n如需持续关闭,请按 README 的停止步骤退出保活程序。'
: '关闭 Agent 桥服务?\n关闭后网页与 Agent 的数据同步暂停(已同步的数据不丢失)。\n下次需要时:双击桌面「开启工作台桥」,或对 Hermes 说「开桥」。';
myConfirm(notice, () => {
fetch(BRIDGE + '/control/stop', { method: 'POST', mode: 'cors' })
.then(() => { bridgeOnline = false; updateBridgeUI(); toast('✅ 桥服务已关闭,内存已释放。'); })
.catch(() => { bridgeOnline = false; updateBridgeUI(); toast('桥服务已关闭。'); });
.then(() => { bridgeOnline = false; updateBridgeUI(); toast(BRIDGE_SUPERVISED ? '桥服务正在重启,请稍候。' : '✅ 桥服务已关闭,内存已释放。'); })
.catch(() => { bridgeOnline = false; updateBridgeUI(); toast(BRIDGE_SUPERVISED ? '桥连接已断开,保活程序将尝试恢复。' : '桥服务已关闭。'); });
});
} else {
toast('开启 Agent 桥的两种方式:\n① 双击桌面「开启工作台桥」图标\n② 在 Hermes 对话里说「开桥」,我会帮你启动');
Expand All @@ -95,7 +100,7 @@ document.getElementById('bridgeInd').addEventListener('click', () => {
(function () {
if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
try {
fetch('http://127.0.0.1:5177/sync')
fetch(BRIDGE + '/sync')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || typeof d !== 'object') return;
Expand Down
29 changes: 25 additions & 4 deletions bridge-server.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// ============ 工作台 · Agent 桥服务 ============
// 网页(localStorage) <-> 本机文件 <-> Hermes agent
// 仅监听 127.0.0.1:5177,只允许读写本目录 data/ 下的文件
// 仅监听 127.0.0.1(默认 5177,可通过 WORKBENCH_BRIDGE_PORT 配置)
// 控制:POST /control/stop 优雅退出(网页端「关闭」按钮调用)
const http = require('http');
const fs = require('fs');
Expand All @@ -9,7 +9,12 @@ const { execFile } = require('child_process');

const DATA_DIR = path.join(__dirname, 'data');
const OUT_DIR = path.join(DATA_DIR, 'agent_out');
const PORT = 5177;
const portSetting = process.env.WORKBENCH_BRIDGE_PORT || '5177';
if (!/^[1-9]\d*$/.test(portSetting) || Number(portSetting) > 65535) {
console.error('[bridge] WORKBENCH_BRIDGE_PORT must be an integer from 1 to 65535');
process.exit(2);
}
const PORT = Number(portSetting);
// opencode 官网同步(用量数据来自 SSR 页面)
const OC_WS = 'wrk_01KYYZWQJJ6T3H5AWJGRE638TN';
const OC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0';
Expand Down Expand Up @@ -251,11 +256,13 @@ const server = http.createServer((req, res) => {

if (req.method === 'POST' && parts[0] === 'control' && parts[1] === 'stop') {
send(res, 200, { ok: true, message: 'stopping' });
setTimeout(() => { try { server.close(); } catch (e) {} process.exit(0); }, 300);
setTimeout(shutdown, 100);
return;
}
if (req.method === 'GET' && parts[0] === 'health') {
return send(res, 200, { ok: true, dir: DATA_DIR, out: OUT_DIR });
return send(res, 200, { ok: true, dir: DATA_DIR, out: OUT_DIR,
service: 'grad-workbench-bridge', pid: process.pid,
instance: process.env.WORKBENCH_INSTANCE_ID || null });
}
if (req.method === 'GET' && parts[0] === 'list' && parts[1]) {
if (parts[1] !== 'agent_out') return send(res, 400, { error: 'only agent_out' });
Expand Down Expand Up @@ -882,6 +889,20 @@ const server = http.createServer((req, res) => {
send(res, 404, { error: 'not found' });
});

let shuttingDown = false;
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
server.close(() => process.exit(0));
if (server.closeIdleConnections) server.closeIdleConnections();
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
server.on('error', error => {
console.error('[bridge] listen failed:', error.message);
process.exit(1);
});
server.listen(PORT, '127.0.0.1', () => {
console.log('[bridge] listening on http://127.0.0.1:' + PORT);
});
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="workbench-config.js"></script>
<script>
/* file:// 双击打开时:本地服务可用则静默跳到 http 版(浏览器限制 file:// 加载脚本,http 稳定);
服务不可用则保持原页面不打扰,仅在右上角显示一条可关闭的小提示 */
Expand Down
12 changes: 12 additions & 0 deletions keepalive.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"bridge_port": 5177,
"static_port": 8899,
"check_interval": 30,
"request_timeout": 2,
"startup_timeout": 15,
"failure_threshold": 3,
"restart_delay": 1,
"max_restart_delay": 60,
"shutdown_timeout": 5,
"node": "node"
}
Loading
Loading