@walkhi/code-relax 0.1.0-beta.7 → 0.1.0-beta.9
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/README.md +12 -13
- package/dist/bin/codex-remote.mjs +43 -18
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/shared/app-server-events.cjs +5 -3
- package/dist/src/desktop-launcher.mjs +7 -10
- package/dist/src/managed-app-server.mjs +3 -0
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +3 -4
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
- package/dist/src/platform/windows/resident-startup.ps1 +58 -18
- package/dist/src/platform/windows/service-host.mjs +4 -7
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/self-relay/admin-page.mjs +54 -0
- package/dist/src/self-relay/admin-state.mjs +25 -12
- package/dist/src/self-relay/demo.mjs +2 -3
- package/dist/src/self-relay/lifecycle.mjs +6 -6
- package/dist/src/self-relay/server.mjs +7 -11
- package/dist/src/server.mjs +163 -1034
- package/dist/src/service-doctor.mjs +10 -5
- package/dist/src/service-lifecycle.mjs +3 -8
- package/dist/src/shared-app-server.mjs +13 -11
- package/dist/src/shared-recovery.mjs +5 -5
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/web/capabilities.js +1 -8
- package/dist/web/chat-transport.js +6 -12
- package/dist/web/chat.css +13 -14
- package/dist/web/chat.js +23 -41
- package/dist/web/community.css +15 -10
- package/dist/web/community.html +4 -5
- package/dist/web/composer-controller.js +0 -4
- package/dist/web/index.html +7 -39
- package/dist/web/resources.json +1 -1
- package/dist/web/task-list-view.js +1 -8
- package/dist/web/timeline-reducer.js +0 -4
- package/package.json +16 -18
- package/tools/postinstall.mjs +4 -1
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -358
- package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
- package/dist/src/platform/windows/desktop-tools.mjs +0 -48
- package/dist/src/thread-catalog.mjs +0 -47
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/community-view.js +0 -20
package/README.md
CHANGED
|
@@ -7,32 +7,31 @@ Bridge 让 HarmonyOS 客户端控制 Windows 上的 Codex Desktop。当前要求
|
|
|
7
7
|
公开 beta 已发布到 npm,安装或更新使用:
|
|
8
8
|
|
|
9
9
|
```powershell
|
|
10
|
-
npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax
|
|
10
|
+
npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax --foreground-scripts
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
首次安装前先保存工作,并从 Codex 菜单或 Windows 托盘完全退出 Codex。更新无需先停止正在运行的 Bridge;安装器会短暂停止、替换并恢复服务。npm 全局安装会自动执行 `setup` 和 Skill 安装。`setup` 将完整服务部署到 `%LOCALAPPDATA%\Programs\CodexRemote\service`,记录安装所用 Node 的绝对路径,不复制 Node。配置、日志和二维码以 `installation.json.stateRoot` 为准,通常位于 `%LOCALAPPDATA%\CodexRemote`,MSIX 环境可能解析到包缓存目录;附件位于 `%ProgramData%\CodexRemote\attachments`。
|
|
14
14
|
|
|
15
15
|
`setup` 立即启动 Bridge 和固定 45839 受管 app-server,并启用当前用户登录自启;自启不打开 Codex。受管实例健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍在运行,不中断现有任务,允许两个 app-server 暂时共存,并在手机提示立即或稍后重启。
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Codex 内核
|
|
18
18
|
|
|
19
19
|
```powershell
|
|
20
|
-
relax shortcuts #
|
|
21
|
-
relax shared-start #
|
|
22
|
-
relax desktop-start # 基础控制、Desktop、Bridge
|
|
20
|
+
relax shortcuts # 创建 Codex 内核桌面快捷方式
|
|
21
|
+
relax shared-start # Codex 内核、Desktop、Bridge
|
|
23
22
|
relax start # 启动/复用常驻 Bridge 和已配置中继
|
|
24
23
|
relax pair # 启动/复用服务并打开配对二维码
|
|
25
24
|
relax status
|
|
26
25
|
relax stop
|
|
27
26
|
```
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
Codex 内核由官方 `codex app-server --listen ws://127.0.0.1:45839` 提供执行服务,Desktop 和 Bridge 均可连接;它只监听回环地址。Bridge 独立报告内核健康、Desktop 接入和手机可写状态;Desktop 退出后,手机仍可操作内核中的任务。同一 thread 已被另一个实例持有时,手机只读且可重新检测。
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
手机工作站详情可确认让 Desktop 接入 Codex 内核,独立进程逐步执行并保留 Bridge/中继连接,见[启用流程](../docs/shared-recovery.md)。本地 `shared-start` 在 Desktop 未接入受管服务时要求先自行退出 Desktop,不强制结束任务。Bridge 状态匹配且健康时直接复用;未知进程占用 45831/45839 时拒绝接管。`stop` 只停止 Bridge,不停止 Desktop 或受管 app-server。
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
受管 app-server 通过隐藏控制台启动,使后续控制台子进程继承隐藏窗口。手机的项目、任务列表和新建任务均使用受管内核,不依赖 Desktop 工具管道;项目和任务 ID 保持原生值。
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
受管 app-server 未就绪时,手机停留在连接界面并显示“Codex 内核未就绪”;Bridge 保留健康和恢复接口,聊天请求返回错误。
|
|
36
35
|
|
|
37
36
|
## Code Relax 中继
|
|
38
37
|
|
|
@@ -47,7 +46,7 @@ relax relay-stop --json
|
|
|
47
46
|
relax relay-revoke --json
|
|
48
47
|
```
|
|
49
48
|
|
|
50
|
-
`relay-shared-start`
|
|
49
|
+
`relay-shared-start` 先准备 Codex 内核和 Bridge,再连接公网私有中继。每台移动设备通过 `pair` 配对一次;新增设备不替换已有设备。连接器把 Windows hostname 和当前 Wi-Fi/有线网络候选经已认证 WebSocket 临时发送给手机,手机本地保存电脑名,只在设备身份验证通过后保存可达地址;服务器不持久化这些 Code Relax 中继 hostname、IP 或网络名。OpenAI 官方认证和设备配对仍直接访问 OpenAI;完成后,手机使用统一配对产生的设备凭据,通过独立元数据 WebSocket 把官方环境 ID、名称和 hostname 写入设备记录,不上传 OpenAI access token、配对码或登录态。停止连接器不撤销授权。
|
|
51
50
|
|
|
52
51
|
## Bridge 调试页面
|
|
53
52
|
|
|
@@ -57,14 +56,14 @@ relax dev-status --json
|
|
|
57
56
|
relax dev-stop --json
|
|
58
57
|
```
|
|
59
58
|
|
|
60
|
-
调试页面与 Bridge 共用进程和端口(默认 45831
|
|
59
|
+
调试页面与 Bridge 共用进程和端口(默认 45831),只额外提供白名单静态资源,用于局域网页面调试。`dev-stop` 只关闭静态页面,不影响 Bridge WebSocket。Code Relax 中继和 OpenAI 中继均使用 HAP 内置页面。
|
|
61
60
|
|
|
62
61
|
## 更新与卸载
|
|
63
62
|
|
|
64
63
|
更新固定服务:
|
|
65
64
|
|
|
66
65
|
```powershell
|
|
67
|
-
npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax
|
|
66
|
+
npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax --foreground-scripts
|
|
68
67
|
```
|
|
69
68
|
|
|
70
69
|
`setup` 先暂存并校验新版本,再短暂停止 Bridge/中继完成切换;原服务此前正在运行时会自动恢复,新版本启动失败则回滚并启动旧版本。无需提前执行 `stop`。Node 路径变化或原 Node 被删除后需重新 setup。升级可能因 Node 可执行文件路径变化而再次触发 Windows 防火墙授权。
|
|
@@ -80,4 +79,4 @@ npm uninstall -g @walkhi/code-relax
|
|
|
80
79
|
|
|
81
80
|
## 发布边界
|
|
82
81
|
|
|
83
|
-
registry
|
|
82
|
+
registry 测试包使用 `@walkhi/code-relax@beta`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。安装摘要通过 `--foreground-scripts` 显示,首次安装或启动链迁移时干净重启 Desktop,分别报告已安装与实际运行版本;登录启动使用当前用户任务计划。通信协议仍为 7。详细规则见 [npm 分发](../docs/npm-distribution.md)。
|
|
@@ -19,15 +19,14 @@ import { requestRecovery, recoveryStatus, recoveryWarning } from '../src/shared-
|
|
|
19
19
|
import { configureManagedEnvironment, clearManagedEnvironment } from '../src/platform/windows/user-environment.mjs';
|
|
20
20
|
|
|
21
21
|
const help = `Code Relax(Windows)
|
|
22
|
-
用法:relax <setup|doctor|skill-install|skill-remove|uninstall|start|shared-start|
|
|
22
|
+
用法:relax <setup|doctor|skill-install|skill-remove|uninstall|start|shared-start|shortcuts|pair|status|stop> [--json]
|
|
23
23
|
setup 部署并启动固定目录服务、受管 app-server 和登录自启动;安全配置 Desktop 后续接入
|
|
24
24
|
start 启动或复用 Bridge,并恢复已配置的私有中继
|
|
25
|
-
shared-start
|
|
26
|
-
shared-recover --confirm 重新打开 Desktop
|
|
25
|
+
shared-start 启动或复用 Codex 内核,再启动 Desktop、Bridge 和已配置的私有中继
|
|
26
|
+
shared-recover --confirm 重新打开 Desktop 并接入 Codex 内核;不强制重启已有内核
|
|
27
27
|
shared-recovery-status 查看最近一次恢复的实际阶段和结果
|
|
28
28
|
autostart-enable / autostart-disable / autostart-status 管理当前用户登录时启动 Bridge
|
|
29
|
-
|
|
30
|
-
shortcuts 创建完整控制/基础控制桌面快捷方式
|
|
29
|
+
shortcuts 创建 Codex 内核桌面快捷方式
|
|
31
30
|
pair 启动或复用工作站服务,并打开一次性配对二维码
|
|
32
31
|
status 查看服务健康状态
|
|
33
32
|
stop 停止服务
|
|
@@ -35,7 +34,7 @@ const help = `Code Relax(Windows)
|
|
|
35
34
|
dev-status 查看 Bridge 调试页面状态
|
|
36
35
|
dev-stop 关闭调试页面,不停止 Bridge
|
|
37
36
|
relay-start 启动或复用私有中继,已有授权直接恢复,首次授权生成二维码
|
|
38
|
-
relay-shared-start
|
|
37
|
+
relay-shared-start 启动 Codex 内核和 Bridge 后连接私有中继(需先退出当前 Codex)
|
|
39
38
|
relay-status 查看私有中继连接器及手机连接状态
|
|
40
39
|
relay-stop 停止私有中继连接器,不停止 Bridge 或 Codex
|
|
41
40
|
relay-revoke 撤销这台电脑的全部移动设备授权(停止连接器不会撤销授权)
|
|
@@ -52,7 +51,7 @@ async function main() {
|
|
|
52
51
|
console.log(help);
|
|
53
52
|
return;
|
|
54
53
|
}
|
|
55
|
-
if (!['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'relay-shared-start', 'relay-revoke', 'relay-start', 'relay-status', 'relay-stop', 'dev', 'dev-status', 'dev-stop', 'setup', 'doctor', 'skill-install', 'skill-remove', 'uninstall', 'shared-start', '
|
|
54
|
+
if (!['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'relay-shared-start', 'relay-revoke', 'relay-start', 'relay-status', 'relay-stop', 'dev', 'dev-status', 'dev-stop', 'setup', 'doctor', 'skill-install', 'skill-remove', 'uninstall', 'shared-start', 'shortcuts', 'start', 'pair', 'status', 'stop'].includes(command)
|
|
56
55
|
|| flags.some(flag => flag !== '--json' && !(command === 'shared-recover' && flag === '--confirm'))) {
|
|
57
56
|
throw new Error('无效命令或参数。使用 --help 查看用法。');
|
|
58
57
|
}
|
|
@@ -63,13 +62,13 @@ async function main() {
|
|
|
63
62
|
if (['relay-shared-start', 'relay-revoke', 'relay-start', 'relay-status', 'relay-stop'].includes(command)) {
|
|
64
63
|
if (command === 'relay-shared-start') {
|
|
65
64
|
const existing = await selfRelay('relay-status', stateRoot);
|
|
66
|
-
if (existing.running && existing.transportMode !== 'lan-shared') throw new Error('
|
|
65
|
+
if (existing.running && existing.transportMode !== 'lan-shared') throw new Error('旧连接器仍在运行,请先执行 relay-stop;不会自动中断手机连接。');
|
|
67
66
|
const result = await new Promise((resolve, reject) => {
|
|
68
67
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'shared-start', '--json'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
69
68
|
let output = '', errors = '';
|
|
70
69
|
child.stdout.on('data', chunk => { output += chunk; }); child.stderr.on('data', chunk => { errors += chunk; });
|
|
71
70
|
child.once('error', reject);
|
|
72
|
-
child.once('exit', code => { if (code) reject(new Error(errors.trim().replace(/^(?:Code Relax:)+/u, '') || '
|
|
71
|
+
child.once('exit', code => { if (code) reject(new Error(errors.trim().replace(/^(?:Code Relax:)+/u, '') || 'Codex 内核启动失败')); else { try { resolve(JSON.parse(output)); } catch { reject(new Error('Codex 内核启动结果无法确认,未启动中继')); } } });
|
|
73
72
|
});
|
|
74
73
|
if (!result.bridgeReady) { console.log(json ? JSON.stringify(result) : result.displayText); return; }
|
|
75
74
|
}
|
|
@@ -83,7 +82,7 @@ async function main() {
|
|
|
83
82
|
return;
|
|
84
83
|
}
|
|
85
84
|
const installedEntry = path.join(serviceRoot, 'dist/bin/codex-remote.mjs');
|
|
86
|
-
if (['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'shared-start', '
|
|
85
|
+
if (['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'shared-start', 'shortcuts', 'start', 'pair', 'status', 'stop'].includes(command) && path.resolve(fileURLToPath(import.meta.url)).toLowerCase() !== installedEntry.toLowerCase()) {
|
|
87
86
|
if (!fs.existsSync(installedEntry)) throw new Error('尚未部署服务,请先执行 relax setup。');
|
|
88
87
|
const executable = readRecord(path.join(serviceRoot, 'installation.json'))?.nodeExecutable;
|
|
89
88
|
if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新执行 relax setup。');
|
|
@@ -170,7 +169,9 @@ async function main() {
|
|
|
170
169
|
const source = path.join(stateRoot, name), target = path.join(physicalStateRoot, name);
|
|
171
170
|
if (fs.existsSync(source) && !fs.existsSync(target)) fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
|
172
171
|
}
|
|
173
|
-
const
|
|
172
|
+
const hadInstalledService = fs.existsSync(path.join(serviceRoot, 'installation.json'));
|
|
173
|
+
const desktopBeforeSetup = await desktopHost.inspect();
|
|
174
|
+
const previous = hadInstalledService ? await service.status() : { processRunning: false };
|
|
174
175
|
const previousRelay = await selfRelay('relay-status', stateRoot);
|
|
175
176
|
const resumeAfterUpdate = previous.processRunning || previousRelay.running;
|
|
176
177
|
const executable = fs.realpathSync(process.execPath);
|
|
@@ -211,7 +212,13 @@ async function main() {
|
|
|
211
212
|
restarted: false,
|
|
212
213
|
previousPid: bridge.managedAppServer.pid || null,
|
|
213
214
|
};
|
|
215
|
+
let desktopRestarted = false;
|
|
216
|
+
let desktopRestartReason = null;
|
|
214
217
|
if (managedAppServerMigration.required) {
|
|
218
|
+
const desktopInfo = await desktopHost.inspect();
|
|
219
|
+
const desktopAttached = desktopInfo.running
|
|
220
|
+
&& (bridge.desktop?.attachedToManagedServer === true || Boolean(await host.sharedServiceUrl()));
|
|
221
|
+
if (desktopAttached) await desktopHost.closeDesktop(desktopInfo.desktop);
|
|
215
222
|
await service.restartManagedAppServer();
|
|
216
223
|
const migrationDeadline = Date.now() + 40_000;
|
|
217
224
|
do {
|
|
@@ -226,9 +233,23 @@ async function main() {
|
|
|
226
233
|
}
|
|
227
234
|
managedAppServerMigrated = true;
|
|
228
235
|
managedAppServerMigration.restarted = true;
|
|
236
|
+
if (desktopAttached) {
|
|
237
|
+
await launchDesktop('shared', { stateRoot: physicalStateRoot, host: desktopHost });
|
|
238
|
+
desktopRestarted = true;
|
|
239
|
+
desktopRestartReason = 'app-server-migration';
|
|
240
|
+
bridge = await service.status();
|
|
241
|
+
}
|
|
229
242
|
}
|
|
230
243
|
const autostart = await residentStartup(installed, 'enable');
|
|
231
244
|
const environment = await configureManagedEnvironment(physicalStateRoot);
|
|
245
|
+
if (!hadInstalledService && desktopBeforeSetup.running && !desktopRestarted) {
|
|
246
|
+
const currentDesktop = await desktopHost.inspect();
|
|
247
|
+
if (currentDesktop.running) await desktopHost.closeDesktop(currentDesktop.desktop);
|
|
248
|
+
await launchDesktop('shared', { stateRoot: physicalStateRoot, host: desktopHost });
|
|
249
|
+
desktopRestarted = true;
|
|
250
|
+
desktopRestartReason = 'first-install';
|
|
251
|
+
bridge = await service.status();
|
|
252
|
+
}
|
|
232
253
|
installed.autostart = autostart;
|
|
233
254
|
installed.restarted = resumeAfterUpdate;
|
|
234
255
|
installed.environment = environment;
|
|
@@ -237,10 +258,14 @@ async function main() {
|
|
|
237
258
|
installed.managedAppServerMigration = { ...managedAppServerMigration,
|
|
238
259
|
currentPid: bridge.managedAppServer.pid || null,
|
|
239
260
|
launcherRevision: bridge.managedAppServer.launcherRevision ?? null };
|
|
261
|
+
installed.desktopRestarted = desktopRestarted;
|
|
262
|
+
installed.desktopRestartReason = desktopRestartReason;
|
|
240
263
|
installed.desktop = bridge.desktop;
|
|
241
|
-
installed.desktopRestartRecommended = bridge.desktop?.state === 'ordinary';
|
|
264
|
+
installed.desktopRestartRecommended = !desktopRestarted && bridge.desktop?.state === 'ordinary';
|
|
242
265
|
const migrationText = managedAppServerMigrated ? '已自动重建旧启动链的 app-server;安装前尚未完成的任务可能已中断。\n' : '';
|
|
243
|
-
const restartText =
|
|
266
|
+
const restartText = desktopRestarted
|
|
267
|
+
? 'Codex Desktop 已干净重启并重新接入受管 app-server。'
|
|
268
|
+
: installed.desktopRestartRecommended
|
|
244
269
|
? 'Codex 当前仍在使用原连接。可以在手机端选择“立即重启(推荐)”,或稍后自行完全退出再启动。'
|
|
245
270
|
: '下次正常启动 Codex 时会连接受管 app-server。';
|
|
246
271
|
console.log(json ? JSON.stringify({ ...installed, nextSteps: desktopSetupSteps, displayText: `${migrationText}${desktopSetupSteps.join('\n')}\n${restartText}` }) : `服务已安装:${installed.serviceRoot}\n使用已安装的 Node:${installed.nodeExecutable}\n受管 app-server 已就绪;首次联网可能需要 Windows 防火墙授权。\n${migrationText}${desktopSetupSteps.join('\n')}\n${restartText}`);
|
|
@@ -252,16 +277,16 @@ async function main() {
|
|
|
252
277
|
return;
|
|
253
278
|
}
|
|
254
279
|
let desktop;
|
|
255
|
-
if (command === 'shared-start'
|
|
280
|
+
if (command === 'shared-start') {
|
|
256
281
|
if (!json) console.log('正在检查 Codex 的启动模式…');
|
|
257
|
-
desktop = await launchDesktop(
|
|
282
|
+
desktop = await launchDesktop('shared', { stateRoot, host: desktopHost });
|
|
258
283
|
delete process.env.CODEX_APP_TOOLS_PIPE_PATH;
|
|
259
284
|
}
|
|
260
285
|
if (!json && (desktop || command === 'start')) console.log('正在检查电脑连接并准备 Bridge…');
|
|
261
286
|
const result = await service[desktop ? 'start' : command]();
|
|
262
|
-
if (command === 'status') {
|
|
263
|
-
console.log(json ? JSON.stringify(result) : result.running
|
|
264
|
-
? `服务正常:${result.mode},端口 ${result.port},PID ${result.pid}` : '服务未就绪。');
|
|
287
|
+
if (command === 'status') {
|
|
288
|
+
console.log(json ? JSON.stringify(result) : result.running
|
|
289
|
+
? `服务正常:${result.mode},版本 ${result.version || '未知'},端口 ${result.port},PID ${result.pid}` : '服务未就绪。');
|
|
265
290
|
return;
|
|
266
291
|
}
|
|
267
292
|
const { accessUrl, qrPath: _legacyQrPath, ...metadata } = result;
|
|
@@ -3,7 +3,7 @@ import { startRelayDemo } from '../src/self-relay/demo.mjs';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
|
|
6
|
-
const mode =
|
|
6
|
+
const mode = 'lan-shared';
|
|
7
7
|
let remoteRelay;
|
|
8
8
|
if (process.env.RELAY_URL) {
|
|
9
9
|
let hostKey;
|
|
@@ -272,9 +272,11 @@
|
|
|
272
272
|
if (action === 'steer') {
|
|
273
273
|
const item = (await readQueueItems(request, threadId)).find(item => item.id === queuedSubmissionId);
|
|
274
274
|
if (!item) throw new Error('该排队消息已不在队列中');
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
const page = await request('thread/turns/list', {
|
|
276
|
+
threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
277
|
+
});
|
|
278
|
+
const active = page.data?.[0];
|
|
279
|
+
if (active?.status === 'inProgress') {
|
|
278
280
|
await request('turn/steer', { threadId, expectedTurnId: active.id, input: item.input,
|
|
279
281
|
clientUserMessageId: item.clientUserMessageId });
|
|
280
282
|
const { deleted } = await request('thread/queue/delete', { threadId, queuedSubmissionId });
|
|
@@ -5,10 +5,10 @@ import { readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
|
5
5
|
import { ManagedAppServer, managedAppServerUrl as sharedUrl, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
|
|
6
6
|
export { portAvailable };
|
|
7
7
|
export async function launchDesktop(mode, { stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
|
|
8
|
-
if (
|
|
8
|
+
if (mode !== 'shared') throw new Error('只支持接入 Code Relax 管理的 Codex 内核。');
|
|
9
9
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
10
10
|
const recovery = readRecord(path.join(stateRoot, 'shared-recovery.lock'));
|
|
11
|
-
if (recovery && recovery.operationId !== recoveryId) throw new Error('
|
|
11
|
+
if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请先查看 shared-recovery-status。');
|
|
12
12
|
const lock = path.join(stateRoot, 'desktop-launch.lock');
|
|
13
13
|
let descriptor;
|
|
14
14
|
let managed;
|
|
@@ -29,18 +29,15 @@ export async function launchDesktop(mode, { stateRoot, host, environment = proce
|
|
|
29
29
|
}
|
|
30
30
|
const childEnv = { ...environment };
|
|
31
31
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
|
|
37
|
-
}
|
|
38
|
-
// Ordinary Desktop must not terminate a shared server that may still be executing tasks.
|
|
32
|
+
progress('starting-app-server');
|
|
33
|
+
managed = new ManagedAppServer({ stateRoot, host, environment, available, wait, timeout });
|
|
34
|
+
if (!await managed.start()) throw new Error(managed.snapshot().error || '受管 app-server 未就绪。');
|
|
35
|
+
childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
|
|
39
36
|
progress('starting-desktop');
|
|
40
37
|
await host.start(info.desktop, [], childEnv, []);
|
|
41
38
|
const deadline = Date.now() + timeout;
|
|
42
39
|
while (true) {
|
|
43
|
-
const ready =
|
|
40
|
+
const ready = await host.sharedServiceUrl();
|
|
44
41
|
if (ready) break;
|
|
45
42
|
if (Date.now() >= deadline) throw new Error('Desktop 尚未按所选模式就绪,未启动 Bridge。请查看 Desktop 窗口后重试。');
|
|
46
43
|
await wait(500);
|
|
@@ -77,6 +77,9 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
77
77
|
if (!await this.available()) throw new Error('45839 被未识别进程占用,未启动或结束任何进程。');
|
|
78
78
|
const childEnvironment = { ...this.environment };
|
|
79
79
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
|
|
80
|
+
for (const key of Object.keys(childEnvironment)) {
|
|
81
|
+
if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
|
|
82
|
+
}
|
|
80
83
|
const pid = await this.host.start(info.cli, ['app-server', '--listen', managedAppServerUrl], childEnvironment,
|
|
81
84
|
[path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
|
|
82
85
|
const started = await this.host.process(pid);
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import sharp from 'sharp';
|
|
5
|
-
|
|
6
1
|
export function validateInlineImage(value) {
|
|
7
2
|
const data = typeof value === 'string' ? value.slice(value.indexOf(',') + 1) : '';
|
|
8
3
|
if (typeof value !== 'string' || value.length > Math.ceil(8 * 1024 * 1024 / 3) * 4 + 64 ||
|
|
@@ -12,16 +7,3 @@ export function validateInlineImage(value) {
|
|
|
12
7
|
}
|
|
13
8
|
return value;
|
|
14
9
|
}
|
|
15
|
-
|
|
16
|
-
// Only the Desktop tool compatibility path needs a file visible to the host.
|
|
17
|
-
export async function persistLegacyImage(value, directory) {
|
|
18
|
-
validateInlineImage(value);
|
|
19
|
-
const input = Buffer.from(value.slice(value.indexOf(',') + 1), 'base64');
|
|
20
|
-
const buffer = await sharp(input, { limitInputPixels: 40_000_000 }).rotate()
|
|
21
|
-
.resize({ width: 2560, height: 2560, fit: 'inside', withoutEnlargement: true }).webp({ quality: 88 }).toBuffer();
|
|
22
|
-
const id = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
23
|
-
await fs.mkdir(directory, { recursive: true });
|
|
24
|
-
const file = path.join(directory, `${id}.webp`);
|
|
25
|
-
await fs.writeFile(file, buffer);
|
|
26
|
-
return file;
|
|
27
|
-
}
|
package/dist/src/onboarding.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const connectionModesText = [
|
|
2
2
|
'可选连接模式:',
|
|
3
3
|
'• 局域网:启动时会把当前 Wi-Fi/有线网络候选临时发给已配对设备;App 验证后保存,也可手动输入 IP。',
|
|
4
|
-
'•
|
|
4
|
+
'• 私有中继:同一设备授权可跨网络长期重连;Codex 内核就绪后才能进入聊天。',
|
|
5
5
|
'• OpenAI 官方中继:在已配对电脑内使用官方账号和设备授权;认证仍直连 OpenAI。',
|
|
6
6
|
].join('\n');
|
|
7
7
|
|
|
8
8
|
export function phonePairingSteps(transportMode, paired = false) {
|
|
9
|
-
const mode = transportMode === 'lan-shared' ? '局域网 ·
|
|
9
|
+
const mode = transportMode === 'lan-shared' ? '局域网 · Codex 内核已就绪' : '局域网(Codex 内核未就绪)';
|
|
10
10
|
return paired ? [
|
|
11
11
|
`已有移动设备授权。手机可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
|
|
12
12
|
'添加另一台设备时执行 relax pair;每台设备只需扫码配对一次。',
|
|
@@ -19,8 +19,7 @@ export function phonePairingSteps(transportMode, paired = false) {
|
|
|
19
19
|
export const desktopSetupSteps = [
|
|
20
20
|
'Code Relax Skill 已随 npm 包自动安装。',
|
|
21
21
|
'安装完成后 Bridge 和受管 app-server 已启动,并默认在当前用户登录时启动。',
|
|
22
|
-
'如果安装时 Codex
|
|
23
|
-
'基础控制首次聊天需要在 Codex 对话启动一次 Code Relax 绑定上下文;完整控制不需要。',
|
|
22
|
+
'如果安装时 Codex 仍在运行,手机可继续使用 Codex 内核;可选择立即或稍后重启,让 Desktop 也接入同一内核。',
|
|
24
23
|
'尚无设备授权时执行 relax pair,打开并扫描配对二维码。',
|
|
25
24
|
'若工作站提示 Desktop 尚未接入受管服务,可在手机确认重新打开 Desktop,无需电脑脚本或快捷方式。',
|
|
26
25
|
'统一配对先建立电脑记录;进入该电脑后可另行完成 OpenAI 官方 Remote 配对。',
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
当前只实现 Windows Codex:
|
|
4
4
|
|
|
5
5
|
- `windows/runtime-paths.mjs`:固定服务、用户状态、Codex 数据、附件和长期凭据路径。
|
|
6
|
-
- `windows/service-host.mjs
|
|
6
|
+
- `windows/service-host.mjs`:网卡、Bridge 进程环境和旧状态迁移。
|
|
7
7
|
- `windows/desktop-host.mjs`、PowerShell 脚本:普通/共享 Desktop、快捷方式和隐藏控制台启动。
|
|
8
8
|
- `windows/credential-store.mjs/.ps1`:私有中继电脑凭据的 DPAPI 与 ACL。
|
|
9
9
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
param([string]$NodePath, [string]$Entry, [ValidateSet('shared-start'
|
|
1
|
+
param([string]$NodePath, [string]$Entry, [ValidateSet('shared-start')][string]$Action)
|
|
2
2
|
$ErrorActionPreference = 'Stop'
|
|
3
3
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
4
|
& $NodePath $Entry $Action
|
|
@@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop'
|
|
|
4
4
|
$shell = New-Object -ComObject WScript.Shell
|
|
5
5
|
if (-not $Destination) { $Destination = $shell.SpecialFolders.Item('Desktop') }
|
|
6
6
|
$launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
|
|
7
|
-
$items = @(@{Name='Code Relax -
|
|
7
|
+
$items = @(@{Name='Code Relax - Codex 内核'; Command='shared-start'})
|
|
8
8
|
$results = @()
|
|
9
9
|
foreach ($item in $items) {
|
|
10
10
|
$target = Join-Path $Destination ($item.Name + '.lnk')
|
|
@@ -1,27 +1,67 @@
|
|
|
1
1
|
param([string]$NodePath, [string]$Entry, [ValidateSet('enable','disable','status')][string]$Action='status')
|
|
2
2
|
$ErrorActionPreference = 'Stop'
|
|
3
3
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
|
|
5
|
+
$taskName = 'Code Relax Bridge'
|
|
6
|
+
$taskSource = '@walkhi/code-relax'
|
|
7
|
+
$taskPath = '\' + $taskName
|
|
8
|
+
$arguments = '"' + $Entry + '" start --json'
|
|
9
|
+
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
10
|
+
|
|
11
|
+
$scheduler = New-Object -ComObject 'Schedule.Service'
|
|
12
|
+
$scheduler.Connect()
|
|
13
|
+
$root = $scheduler.GetFolder('\')
|
|
14
|
+
$registered = @($root.GetTasks(1) | Where-Object { $_.Name -eq $taskName } | Select-Object -First 1)
|
|
15
|
+
if ($registered.Count -gt 0 -and $registered[0].Definition.RegistrationInfo.Source -ne $taskSource) {
|
|
16
|
+
throw 'Scheduled task is not owned by this installation.'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Remove only the legacy shortcut created by this package. A Startup LNK that
|
|
20
|
+
# launches hidden PowerShell with ExecutionPolicy Bypass triggers AV heuristics.
|
|
4
21
|
$shell = New-Object -ComObject WScript.Shell
|
|
5
|
-
$
|
|
6
|
-
$
|
|
7
|
-
$
|
|
8
|
-
$
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
throw 'Startup shortcut is not owned by this installation.'
|
|
22
|
+
$legacyPath = Join-Path $shell.SpecialFolders.Item('Startup') 'Code Relax Bridge.lnk'
|
|
23
|
+
$legacyLauncher = Join-Path $PSScriptRoot 'resident-start.ps1'
|
|
24
|
+
$legacyExists = Test-Path -LiteralPath $legacyPath
|
|
25
|
+
if ($Action -ne 'status' -and $legacyExists) {
|
|
26
|
+
$legacy = $shell.CreateShortcut($legacyPath)
|
|
27
|
+
if (-not $legacy.Arguments.Contains('"' + $legacyLauncher + '"')) {
|
|
28
|
+
throw 'Legacy startup shortcut is not owned by this installation.'
|
|
13
29
|
}
|
|
14
30
|
}
|
|
31
|
+
|
|
15
32
|
if ($Action -eq 'enable') {
|
|
16
33
|
if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)) { throw 'Installed runtime is missing.' }
|
|
17
|
-
$
|
|
18
|
-
$
|
|
19
|
-
$
|
|
20
|
-
$
|
|
21
|
-
$
|
|
22
|
-
$
|
|
23
|
-
$
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
$definition = $scheduler.NewTask(0)
|
|
35
|
+
$definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
|
|
36
|
+
$definition.RegistrationInfo.Source = $taskSource
|
|
37
|
+
$definition.Principal.UserId = $currentUser
|
|
38
|
+
$definition.Principal.LogonType = 3 # TASK_LOGON_INTERACTIVE_TOKEN
|
|
39
|
+
$definition.Principal.RunLevel = 0 # TASK_RUNLEVEL_LUA
|
|
40
|
+
$definition.Settings.Enabled = $true
|
|
41
|
+
$definition.Settings.Hidden = $true
|
|
42
|
+
$definition.Settings.StartWhenAvailable = $true
|
|
43
|
+
$definition.Settings.DisallowStartIfOnBatteries = $false
|
|
44
|
+
$definition.Settings.StopIfGoingOnBatteries = $false
|
|
45
|
+
$definition.Settings.ExecutionTimeLimit = 'PT5M'
|
|
46
|
+
$definition.Settings.MultipleInstances = 2 # TASK_INSTANCES_IGNORE_NEW
|
|
47
|
+
$trigger = $definition.Triggers.Create(9) # TASK_TRIGGER_LOGON
|
|
48
|
+
$trigger.UserId = $currentUser
|
|
49
|
+
$trigger.Enabled = $true
|
|
50
|
+
$exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
|
|
51
|
+
$exec.Path = $NodePath
|
|
52
|
+
$exec.Arguments = $arguments
|
|
53
|
+
$exec.WorkingDirectory = $env:USERPROFILE
|
|
54
|
+
[void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE
|
|
55
|
+
} elseif ($Action -eq 'disable' -and $registered.Count -gt 0) {
|
|
56
|
+
$root.DeleteTask($taskName, 0)
|
|
26
57
|
}
|
|
27
|
-
|
|
58
|
+
if ($Action -ne 'status' -and $legacyExists) { Remove-Item -LiteralPath $legacyPath }
|
|
59
|
+
|
|
60
|
+
$active = @($root.GetTasks(1) | Where-Object { $_.Name -eq $taskName } | Select-Object -First 1)
|
|
61
|
+
$taskEnabled = $active.Count -gt 0 -and $active[0].Enabled
|
|
62
|
+
@{
|
|
63
|
+
enabled=($taskEnabled -or ($Action -eq 'status' -and $legacyExists))
|
|
64
|
+
path=$(if ($taskEnabled) { 'Task Scheduler:' + $taskPath } elseif ($legacyExists) { $legacyPath } else { 'Task Scheduler:' + $taskPath })
|
|
65
|
+
scope='current-user-logon'
|
|
66
|
+
mechanism=$(if ($taskEnabled) { 'task-scheduler' } elseif ($legacyExists) { 'legacy-startup-shortcut' } else { 'task-scheduler' })
|
|
67
|
+
} | ConvertTo-Json -Compress
|
|
@@ -28,16 +28,13 @@ export function migrateState(stateRoot) {
|
|
|
28
28
|
if (!fs.existsSync(target) && fs.existsSync(old)) fs.copyFileSync(old, target, fs.constants.COPYFILE_EXCL);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
export async function
|
|
32
|
-
const environment = { ...process.env };
|
|
33
|
-
|
|
34
|
-
if (sharedUrl) environment.CODEX_REMOTE_SHARED_WS_URL = sharedUrl;
|
|
35
|
-
else delete environment.CODEX_REMOTE_SHARED_WS_URL;
|
|
36
|
-
const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
|
|
31
|
+
export async function bridgeEnvironment() {
|
|
32
|
+
const environment = { ...process.env };
|
|
33
|
+
const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
|
|
37
34
|
const executable = installation?.nodeExecutable;
|
|
38
35
|
if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新安装 Code Relax。');
|
|
39
36
|
environment.CODEX_MCP_NODE_PATH = executable;
|
|
40
37
|
environment.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS = JSON.stringify(installation.legacyUploadsRoots || []);
|
|
41
|
-
|
|
38
|
+
delete environment.CODEX_APP_TOOLS_PIPE_PATH;
|
|
42
39
|
return { executable, environment };
|
|
43
40
|
}
|
|
@@ -5,15 +5,18 @@ $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
|
5
5
|
if (-not $package -or $Executable -ne (Join-Path $package.InstallLocation 'app/ChatGPT.exe')) {
|
|
6
6
|
throw 'Desktop executable identity changed; recovery stopped.'
|
|
7
7
|
}
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
$
|
|
8
|
+
# Never kill another user's Desktop or an app-server process tree. The independent
|
|
9
|
+
# installer/recovery worker must survive and relaunch only in this interactive session.
|
|
10
|
+
$currentSessionId = (Get-Process -Id $PID -ErrorAction Stop).SessionId
|
|
11
|
+
$workers = @(Get-CimInstance Win32_Process -Filter "Name='ChatGPT.exe'" | Where-Object {
|
|
12
|
+
$_.ExecutablePath -eq $Executable -and $_.SessionId -eq $currentSessionId
|
|
13
|
+
})
|
|
11
14
|
foreach ($worker in $workers) {
|
|
12
15
|
$actual = Get-CimInstance Win32_Process -Filter "ProcessId=$($worker.ProcessId)"
|
|
13
16
|
if ($actual -and $actual.ExecutablePath -eq $worker.ExecutablePath -and $actual.CreationDate -eq $worker.CreationDate) {
|
|
14
17
|
Stop-Process -Id $worker.ProcessId -Force -ErrorAction Stop
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
|
-
if (Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Executable }) {
|
|
20
|
+
if (Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Executable -and $_.SessionId -eq $currentSessionId }) {
|
|
18
21
|
throw 'Desktop did not exit; recovery stopped.'
|
|
19
22
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function adminPage() {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="zh-CN">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Code Relax 管理</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{color-scheme:dark;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;background:#101713;color:#edf4ee}
|
|
9
|
+
*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid transparent;border-radius:9px;padding:9px 14px;background:#397e58;color:white;font-weight:600}button:hover{background:#46966b}button:disabled{opacity:.45;cursor:default}button.secondary{background:#26352c;color:#c8d7cd;border-color:#405548}button.secondary:hover{background:#34483a}button.danger{background:transparent;color:#eaa29c;border-color:#734947}button.danger:hover{background:#4a2929}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #8ad2a0;outline-offset:2px}
|
|
10
|
+
main{max-width:1180px;margin:auto;padding:36px 24px 70px}.eyebrow{margin:0;color:#83b598;letter-spacing:.12em;font-size:11px;font-weight:700}h1{font-size:29px;line-height:1.2;margin:6px 0 8px}h2{font-size:17px;margin:0}p{margin:0}.subtle{color:#a3b4a8}.page-head{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:24px}.page-head .subtle{margin-top:6px}.grid{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(290px,.85fr);gap:18px;align-items:start}.stack{display:grid;gap:18px}.card{background:#19241d;border:1px solid #34463a;border-radius:16px;padding:22px;box-shadow:0 12px 30px #080e0a24}.card-head{display:flex;align-items:start;justify-content:space-between;gap:12px;margin-bottom:18px}.hint{font-size:12px;color:#9eafa3;margin-top:3px}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.metric{background:#111b15;border:1px solid #2b3c30;border-radius:11px;padding:13px;color:#9eafa3;font-size:12px}.metric strong{display:block;color:#f0f6f0;font-size:24px;line-height:1.2;margin-bottom:5px;font-variant-numeric:tabular-nums}.metric small{display:block;color:#80b893}.field{display:grid;gap:7px;margin:15px 0}.field label{font-size:13px;font-weight:600;color:#c5d7c9}input,textarea{width:100%;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}textarea{min-height:145px;resize:vertical}.actions{display:flex;flex-wrap:wrap;gap:9px;align-items:center}.actions button{margin:0}.current{border:1px solid #385841;background:#112017;border-radius:11px;padding:15px;margin-bottom:18px}.current.empty{border-color:#34463a;background:#111b15}.badge{display:inline-block;background:#28583b;color:#c9f2d5;border-radius:100px;padding:2px 8px;font-size:11px;font-weight:700}.current h3,.history h3{font-size:15px;margin:8px 0 4px;overflow-wrap:anywhere}.current p,.history p{white-space:pre-wrap;overflow-wrap:anywhere;color:#c1d1c5}.date{font-size:12px;color:#94a99a}.history{display:grid;gap:10px}.history-item{border:1px solid #34463a;background:#141e17;border-radius:11px;padding:15px}.history-item.active{border-color:#4d865e}.history-top{display:flex;justify-content:space-between;align-items:start;gap:10px}.history-item p{margin:8px 0 12px;max-height:4.5em;overflow:hidden}.history-item .actions button{padding:6px 10px;font-size:12px}.empty-note{padding:24px;border:1px dashed #415748;border-radius:10px;color:#a3b4a8;text-align:center}.status{min-height:24px;margin-top:16px;color:#96d4a9}.status.error{color:#f0aaa2}@media(max-width:800px){main{padding:24px 16px 50px}.grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:500px){.page-head{align-items:start}.card{padding:17px}.metrics{gap:8px}.metric{padding:10px}.metric strong{font-size:21px}}
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body><main>
|
|
14
|
+
<header class="page-head"><div><p class="eyebrow">CODE RELAX / RELAY</p><h1>中继管理</h1><p class="subtle">查看运行状态,调整转发容量与系统公告。</p></div><button id="refresh" class="secondary" type="button">刷新状态</button></header>
|
|
15
|
+
<div class="grid"><div class="stack">
|
|
16
|
+
<section class="card"><div class="card-head"><div><h2>运行状态</h2><p class="hint">当前中继连接与转发占用</p></div></div><div id="metrics" class="metrics"></div></section>
|
|
17
|
+
<section class="card"><div class="card-head"><div><h2>当前公告</h2><p class="hint">仅当前公告展示给手机并向在线设备推送</p></div></div><div id="current" class="current empty"></div><button id="withdraw" class="secondary" type="button">撤下当前公告</button></section>
|
|
18
|
+
<section class="card"><div class="card-head"><div><h2>历史公告</h2><p id="historyCount" class="hint"></p></div></div><div id="history" class="history"></div></section>
|
|
19
|
+
</div><div class="stack">
|
|
20
|
+
<section class="card" id="editor"><div class="card-head"><div><h2>发布公告</h2><p class="hint">每次发布都会生成新的公告提醒</p></div></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><div class="actions"><button id="publish" type="button">发布公告</button><button id="clear" class="secondary" type="button">清空</button></div></section>
|
|
21
|
+
<section class="card"><div class="card-head"><div><h2>服务器转发容量</h2><p class="hint">限制经 WSS 转发的普通会话数</p></div></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button></section>
|
|
22
|
+
</div></div><p id="result" class="status" role="status" aria-live="polite"></p>
|
|
23
|
+
</main><script>
|
|
24
|
+
const el=id=>document.getElementById(id);
|
|
25
|
+
let snapshot=null;
|
|
26
|
+
const request=async(path,options={})=>{const response=await fetch(path,options);const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
|
|
27
|
+
function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
|
|
28
|
+
function node(tag,className,text){const item=document.createElement(tag);if(className)item.className=className;if(text!==undefined)item.textContent=text;return item}
|
|
29
|
+
function metric(value,label,detail){const item=node('div','metric');item.append(node('strong','',String(value)),node('span','',label));if(detail)item.append(node('small','',detail));return item}
|
|
30
|
+
function date(value){return new Date(value).toLocaleString('zh-CN')}
|
|
31
|
+
function render(state){snapshot=state;el('limit').value=state.maxRelaySessions;el('metrics').replaceChildren(
|
|
32
|
+
metric(state.activeRelaySessions,'受限转发','上限 '+state.maxRelaySessions),metric(state.activeExemptRelaySessions,'白名单转发'),
|
|
33
|
+
metric(state.websocketConnections,'WebSocket 连接'),metric(state.onlineComputers,'在线工作站'),metric(state.activePhones,'活动手机'));
|
|
34
|
+
const current=el('current');current.replaceChildren();current.classList.toggle('empty',!state.announcement);
|
|
35
|
+
if(state.announcement){const item=state.announcement;current.append(node('span','badge','正在展示'),node('h3','',item.title),node('p','',item.message),node('div','date','发布于 '+date(item.publishedAt)))}
|
|
36
|
+
else current.append(node('span','subtle','当前没有展示中的公告。'));
|
|
37
|
+
el('withdraw').disabled=!state.announcement;
|
|
38
|
+
const history=el('history');history.replaceChildren();el('historyCount').textContent='共 '+state.announcements.length+' 条已发布记录';
|
|
39
|
+
if(!state.announcements.length){history.append(node('div','empty-note','尚无历史公告'));return}
|
|
40
|
+
for(const item of state.announcements){const active=item.id===state.announcement?.id;const row=node('article','history-item'+(active?' active':''));
|
|
41
|
+
const top=node('div','history-top');const title=node('h3','',item.title);top.append(title);if(active)top.append(node('span','badge','当前'));
|
|
42
|
+
const actions=node('div','actions');const reuse=node('button','secondary','载入编辑');reuse.type='button';reuse.addEventListener('click',()=>{el('title').value=item.title;el('message').value=item.message;el('editor').scrollIntoView({behavior:'smooth',block:'start'});el('title').focus()});actions.append(reuse);
|
|
43
|
+
if(!active){const remove=node('button','danger','删除记录');remove.type='button';remove.addEventListener('click',()=>{if(confirm('确定删除这条历史公告?'))write('DELETE','/admin/api/announcements/'+encodeURIComponent(item.id),{},'历史公告已删除')});actions.append(remove)}
|
|
44
|
+
row.append(top,node('div','date','发布于 '+date(item.publishedAt)),node('p','',item.message),actions);history.append(row)}
|
|
45
|
+
}
|
|
46
|
+
async function load(){try{render(await request('/admin/api/status'))}catch(error){setResult(error.message,true);throw error}}
|
|
47
|
+
async function write(method,path,body,message){try{await request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)});await load();setResult(message)}catch(error){setResult(error.message,true)}}
|
|
48
|
+
el('refresh').addEventListener('click',load);
|
|
49
|
+
el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{maxRelaySessions:Number(el('limit').value)},'转发容量已保存'));
|
|
50
|
+
el('publish').addEventListener('click',()=>{if(!el('title').value.trim()||!el('message').value.trim()){setResult('请填写标题和正文',true);return}write('PUT','/admin/api/announcement',{title:el('title').value,message:el('message').value},'公告已发布')});
|
|
51
|
+
el('withdraw').addEventListener('click',()=>{if(snapshot?.announcement&&confirm('确定撤下当前公告?'))write('DELETE','/admin/api/announcement',{},'公告已撤下')});
|
|
52
|
+
el('clear').addEventListener('click',()=>{el('title').value='';el('message').value='';el('title').focus()});load().catch(()=>{});
|
|
53
|
+
</script></body></html>`;
|
|
54
|
+
}
|