@xiaoqiong0v0/opencode-ssh-tool 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -24
- package/dist/base-session.d.ts +128 -0
- package/dist/base-session.d.ts.map +1 -0
- package/dist/base-session.js +414 -0
- package/dist/base-session.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +27 -3
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +28 -3
- package/dist/constants.js.map +1 -1
- package/dist/history.d.ts +14 -5
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +25 -9
- package/dist/history.js.map +1 -1
- package/dist/i18n.d.ts +249 -196
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +171 -180
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +320 -332
- package/dist/index.js.map +1 -1
- package/dist/last-match.d.ts +17 -0
- package/dist/last-match.d.ts.map +1 -0
- package/dist/last-match.js +63 -0
- package/dist/last-match.js.map +1 -0
- package/dist/local-session.d.ts +9 -91
- package/dist/local-session.d.ts.map +1 -1
- package/dist/local-session.js +46 -308
- package/dist/local-session.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +5 -0
- package/dist/log.js.map +1 -0
- package/dist/server-manager.d.ts +3 -1
- package/dist/server-manager.d.ts.map +1 -1
- package/dist/server-manager.js +3 -2
- package/dist/server-manager.js.map +1 -1
- package/dist/server.d.ts +35 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +362 -305
- package/dist/server.js.map +1 -1
- package/dist/session-store.d.ts +2 -0
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +15 -0
- package/dist/session-store.js.map +1 -1
- package/dist/session.d.ts +15 -108
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +69 -361
- package/dist/session.js.map +1 -1
- package/dist/shell-adapter.d.ts +20 -0
- package/dist/shell-adapter.d.ts.map +1 -0
- package/dist/shell-adapter.js +70 -0
- package/dist/shell-adapter.js.map +1 -0
- package/dist/ssh-auth.d.ts.map +1 -1
- package/dist/ssh-auth.js +1 -2
- package/dist/ssh-auth.js.map +1 -1
- package/dist/utils.d.ts +25 -24
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +277 -45
- package/dist/utils.js.map +1 -1
- package/dist/web/app.css +35 -0
- package/dist/web/app.js +2 -0
- package/dist/web/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf +0 -0
- package/dist/web/index.html +24 -0
- package/package.json +8 -4
package/dist/server.js
CHANGED
|
@@ -1,17 +1,55 @@
|
|
|
1
|
-
// 本地 HTTP
|
|
1
|
+
// 本地 HTTP+WebSocket 服务:HTTP 提供页面/静态资源,WS 承载全部实时数据(会话列表 / transcript 快照 / 运行增量)
|
|
2
|
+
// 前端页面为独立 web/ 工程(esbuild 打包到 dist/web/),本文件负责:i18n 占位替换 + 静态资源分发 + WS 协议实现
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
|
-
import { readdirSync,
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
4
|
+
import { readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { join, dirname, extname, normalize } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
8
|
+
import { listAllSessions, removeSessionState } from "./session-store.js";
|
|
9
|
+
import { tr } from "./i18n.js";
|
|
10
|
+
import { PTY_COLS } from "./constants.js";
|
|
11
|
+
import log from "./log.js";
|
|
12
|
+
/** 前端模板中需要替换的 i18n 占位键 */
|
|
13
|
+
const PAGE_KEYS = [
|
|
14
|
+
"web_title",
|
|
15
|
+
"web_loading",
|
|
16
|
+
"web_time",
|
|
17
|
+
"web_new_messages",
|
|
18
|
+
"web_terminals",
|
|
19
|
+
"web_local",
|
|
20
|
+
"web_delete_terminal",
|
|
21
|
+
];
|
|
22
|
+
/** 前端 JS 内 I18N 对象键 → i18n key */
|
|
23
|
+
const JS_I18N_KEYS = {
|
|
24
|
+
run: "web_running",
|
|
25
|
+
commands: "web_commands",
|
|
26
|
+
autoRefresh: "web_auto_refresh",
|
|
27
|
+
sessionGone: "web_session_gone",
|
|
28
|
+
noSession: "web_no_session",
|
|
29
|
+
loadFailed: "web_load_failed",
|
|
30
|
+
local: "web_local",
|
|
31
|
+
terminals: "web_terminals",
|
|
32
|
+
};
|
|
33
|
+
/** WS 运行增量轮询间隔 */
|
|
34
|
+
const STREAM_TICK_MS = 100;
|
|
35
|
+
/** runEnd 后等待快照稳定(后台哨兵监听写入 history)的缓冲时间 */
|
|
36
|
+
const SNAP_STABLE_MS = 400;
|
|
37
|
+
/** 会话列表推送间隔(服务端主动推送,非浏览器轮询) */
|
|
38
|
+
const STATUS_PUSH_MS = 2000;
|
|
6
39
|
/**
|
|
7
|
-
* 启动 HTTP 服务(监听 127.0.0.1)
|
|
40
|
+
* 启动 HTTP + WebSocket 服务(监听 127.0.0.1)
|
|
8
41
|
* @param port 端口,0 或未指定 → 系统随机分配(避免冲突)
|
|
9
|
-
* @param getSessions
|
|
42
|
+
* @param getSessions 获取扁平会话条目列表的函数(供本进程页面展示与实时流)
|
|
10
43
|
* @param dir 插件缓存根目录(跨进程状态文件/历史文件所在处)
|
|
44
|
+
* @param lang Web 界面语言(默认 en)
|
|
11
45
|
* @returns 服务句柄(含实际端口与 URL)
|
|
12
46
|
*/
|
|
13
|
-
export function startServer(port, getSessions, dir) {
|
|
47
|
+
export function startServer(port, getSessions, dir, lang = "en") {
|
|
14
48
|
let actualPort = port;
|
|
49
|
+
// 前端产物目录:dist/web(相对本模块 dist/server.js 的上一级)
|
|
50
|
+
const webDir = join(dirname(fileURLToPath(import.meta.url)), "web");
|
|
51
|
+
// 模板只读一次(dist/web/index.html),每次请求做 i18n 占位替换
|
|
52
|
+
const pageTemplate = readTemplate(webDir);
|
|
15
53
|
const server = createServer((req, res) => {
|
|
16
54
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
17
55
|
const path = url.pathname;
|
|
@@ -20,65 +58,252 @@ export function startServer(port, getSessions, dir) {
|
|
|
20
58
|
res.end(JSON.stringify({ ok: true }));
|
|
21
59
|
return;
|
|
22
60
|
}
|
|
23
|
-
if (path === "/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const bySession = new Map();
|
|
28
|
-
for (const s of states) {
|
|
29
|
-
if (!bySession.has(s.sessionID))
|
|
30
|
-
bySession.set(s.sessionID, { title: s.title, directory: s.directory, terminals: [] });
|
|
31
|
-
bySession.get(s.sessionID).terminals.push({ name: s.name, kind: s.kind, host: s.host, user: s.user, port: s.port, program: s.program, connected: s.connected, busy: s.busy, pending: s.pending });
|
|
32
|
-
}
|
|
33
|
-
const sessions = [...bySession.entries()].map(([sessionID, v]) => ({
|
|
34
|
-
sessionID,
|
|
35
|
-
title: v.title,
|
|
36
|
-
directory: v.directory,
|
|
37
|
-
terminals: v.terminals,
|
|
38
|
-
}));
|
|
39
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
40
|
-
res.end(JSON.stringify({ port: actualPort, sessions }));
|
|
61
|
+
if (path === "/") {
|
|
62
|
+
const html = renderPage(lang, pageTemplate);
|
|
63
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
64
|
+
res.end(html);
|
|
41
65
|
return;
|
|
42
66
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
// 静态资源:/web/* → dist/web/*(JS/CSS/字体)
|
|
68
|
+
if (path.startsWith("/web/")) {
|
|
69
|
+
serveStatic(res, webDir, decodeURIComponent(path.slice("/web/".length)));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
73
|
+
res.end("Not Found");
|
|
74
|
+
});
|
|
75
|
+
// ===== WebSocket:会话列表 + transcript 快照 + 运行中增量 =====
|
|
76
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
77
|
+
server.on("upgrade", (req, socket, head) => {
|
|
78
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
79
|
+
if (url.pathname !== "/ws") {
|
|
80
|
+
socket.destroy();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
84
|
+
wss.emit("connection", ws, req);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
// 发送 JSON 消息给客户端(连接未开则忽略)
|
|
88
|
+
const send = (ws, msg) => {
|
|
89
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
90
|
+
ws.send(JSON.stringify(msg));
|
|
91
|
+
};
|
|
92
|
+
/** 构建聚合会话列表(与旧 /api/status 同构,供 WS sessions 消息推送) */
|
|
93
|
+
const buildSessions = () => {
|
|
94
|
+
const states = listAllSessions(dir);
|
|
95
|
+
// 用 live 连接修正 stale 状态:进程复苏后旧状态文件里的 connected=true 实为已断开
|
|
96
|
+
const liveKey = new Set(getSessions().map((e) => `${e.sessionID}:${e.name}`));
|
|
97
|
+
for (const s of states) {
|
|
98
|
+
if (s.connected && !liveKey.has(`${s.sessionID}:${s.name}`)) {
|
|
99
|
+
s.connected = false;
|
|
59
100
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
101
|
+
}
|
|
102
|
+
const bySession = new Map();
|
|
103
|
+
for (const s of states) {
|
|
104
|
+
if (!bySession.has(s.sessionID))
|
|
105
|
+
bySession.set(s.sessionID, { title: s.title, directory: s.directory, terminals: [] });
|
|
106
|
+
bySession.get(s.sessionID).terminals.push({ name: s.name, kind: s.kind, host: s.host, user: s.user, port: s.port, program: s.program, connected: s.connected, busy: s.busy, pending: s.pending });
|
|
107
|
+
}
|
|
108
|
+
const sessions = [...bySession.entries()].map(([sessionID, v]) => ({
|
|
109
|
+
sessionID,
|
|
110
|
+
title: v.title,
|
|
111
|
+
directory: v.directory,
|
|
112
|
+
terminals: v.terminals,
|
|
113
|
+
}));
|
|
114
|
+
return { port: actualPort, sessions };
|
|
115
|
+
};
|
|
116
|
+
/** 构建某终端的 transcript 快照(本进程句柄直读内存;跨进程回退读历史文件) */
|
|
117
|
+
const buildSnapshot = (sid, name, entry) => {
|
|
118
|
+
if (entry) {
|
|
119
|
+
const pairs = [];
|
|
120
|
+
for (const pair of entry.session.getHistory().getPairs()) {
|
|
121
|
+
if (pair.command === "__SSH_SEP__") {
|
|
122
|
+
pairs.push({ type: "sep", ts: pair.ts, text: "" });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
pairs.push({ type: "cmd", ts: pair.ts, endTs: pair.endTs, text: pair.command });
|
|
65
126
|
pairs.push({ type: "out", text: entry.session.getHistory().readOutput(pair) });
|
|
66
127
|
}
|
|
128
|
+
}
|
|
129
|
+
// 运行中:命令文本 + 当前输出(前端可立即显示命令行,再跟动画)
|
|
130
|
+
if (entry.session.hasRunningStream()) {
|
|
131
|
+
const runningCmd = entry.session.getRunningCommand();
|
|
132
|
+
if (runningCmd)
|
|
133
|
+
pairs.push({ type: "cmd", ts: Date.now(), text: runningCmd });
|
|
67
134
|
const running = entry.session.getRunningOutput();
|
|
68
135
|
if (running)
|
|
69
136
|
pairs.push({ type: "run", text: running });
|
|
70
137
|
}
|
|
71
|
-
|
|
72
|
-
res.end(JSON.stringify({ pairs }));
|
|
73
|
-
return;
|
|
138
|
+
return { pairs };
|
|
74
139
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
140
|
+
// 本进程无此会话句柄(跨进程会话/复用服务)→ 从历史文件读取
|
|
141
|
+
const state = listAllSessions(dir).find((s) => s.sessionID === sid && s.name === name);
|
|
142
|
+
const histName = state?.kind === "local" ? `local-${name}` : name;
|
|
143
|
+
const pairs = readHistoryFromFile(dir, sid, histName) ?? [];
|
|
144
|
+
if (pairs.length === 0 && !state) {
|
|
145
|
+
// 状态文件与历史都消失 = 会话真正删除,前端剔除该幽灵终端
|
|
146
|
+
return { pairs: [], notFound: true };
|
|
79
147
|
}
|
|
80
|
-
|
|
81
|
-
|
|
148
|
+
return { pairs };
|
|
149
|
+
};
|
|
150
|
+
/** 会话历史指纹(含末对序号+字节数),用于判断是否需要重推快照 */
|
|
151
|
+
const historyKey = (session) => {
|
|
152
|
+
const hp = session.getHistory().getPairs();
|
|
153
|
+
const last = hp[hp.length - 1];
|
|
154
|
+
return `${hp.length}:${last ? `${last.seq}:${last.size}` : "-"}`;
|
|
155
|
+
};
|
|
156
|
+
// WS 定时推送:状态列表(低频)+ 订阅终端的运行增量/快照(高频)
|
|
157
|
+
const streamTimer = setInterval(() => {
|
|
158
|
+
for (const client of wss.clients) {
|
|
159
|
+
if (client.readyState !== WebSocket.OPEN)
|
|
160
|
+
continue;
|
|
161
|
+
const sub = client._sub;
|
|
162
|
+
if (!sub)
|
|
163
|
+
continue;
|
|
164
|
+
const entry = getSessions().find((e) => e.sessionID === sub.sid && e.name === sub.name);
|
|
165
|
+
const session = entry?.session;
|
|
166
|
+
if (!session) {
|
|
167
|
+
// 跨进程会话(无本进程句柄):仅在有变化时推文件级快照(低频,退化无实时流)
|
|
168
|
+
const snap = buildSnapshot(sub.sid, sub.name);
|
|
169
|
+
const key = JSON.stringify(snap);
|
|
170
|
+
if (key !== client._lastKey) {
|
|
171
|
+
client._lastKey = key;
|
|
172
|
+
send(client, { type: "snapshot", sessionID: sub.sid, name: sub.name, ...snap });
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// 命令开始运行:检测从空闲→运行切换,立即推送命令文本(前端先渲染命令行,动画紧跟其后)
|
|
177
|
+
const runActive = session.hasRunningStream();
|
|
178
|
+
if (runActive && !client._wasRunning) {
|
|
179
|
+
const cmd = session.getRunningCommand();
|
|
180
|
+
if (cmd)
|
|
181
|
+
send(client, { type: "cmdStart", command: cmd });
|
|
182
|
+
}
|
|
183
|
+
client._wasRunning = runActive;
|
|
184
|
+
// 运行增量推送(cmdStart 之后,保证前端先建命令行再建 run 块)
|
|
185
|
+
const st = session.getRunningStream();
|
|
186
|
+
if (st.done) {
|
|
187
|
+
// 仅首次转 done 时发 runEnd,避免后续 tick 重复发送重置 snapBase
|
|
188
|
+
if (!client._forceSnap) {
|
|
189
|
+
send(client, { type: "runEnd" });
|
|
190
|
+
client._forceSnap = true;
|
|
191
|
+
client._snapBase = Date.now();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (st.data) {
|
|
195
|
+
send(client, { type: "run", data: st.data });
|
|
196
|
+
}
|
|
197
|
+
// 快照推送:runEnd 后等后台写入稳定,或空闲期历史变化(exec 同步完成)
|
|
198
|
+
const key = historyKey(session);
|
|
199
|
+
const forceDone = client._forceSnap && (!runActive || (client._snapBase ?? 0) > 0 && Date.now() - client._snapBase >= SNAP_STABLE_MS);
|
|
200
|
+
if (forceDone || (!runActive && key !== client._lastKey)) {
|
|
201
|
+
client._forceSnap = false;
|
|
202
|
+
client._lastKey = key;
|
|
203
|
+
send(client, { type: "snapshot", sessionID: sub.sid, name: sub.name, ...buildSnapshot(sub.sid, sub.name, entry) });
|
|
204
|
+
}
|
|
205
|
+
// Raw 连续流增量推送(订阅时已发全量,此后只推新字节)
|
|
206
|
+
if (client._rawPos !== undefined) {
|
|
207
|
+
const r = session.readRawStream(client._rawPos);
|
|
208
|
+
if (r.data) {
|
|
209
|
+
client._rawPos = r.pos;
|
|
210
|
+
send(client, { type: "raw", data: r.data, pos: r.pos, reset: !!r.reset });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}, STREAM_TICK_MS);
|
|
215
|
+
const statusTimer = setInterval(() => {
|
|
216
|
+
const payload = buildSessions();
|
|
217
|
+
const json = JSON.stringify(payload);
|
|
218
|
+
for (const client of wss.clients) {
|
|
219
|
+
if (client.readyState !== WebSocket.OPEN)
|
|
220
|
+
continue;
|
|
221
|
+
if (json === client._lastSessionsJson)
|
|
222
|
+
continue;
|
|
223
|
+
client._lastSessionsJson = json;
|
|
224
|
+
send(client, { type: "sessions", ...payload });
|
|
225
|
+
}
|
|
226
|
+
}, STATUS_PUSH_MS);
|
|
227
|
+
wss.on("connection", (ws) => {
|
|
228
|
+
ws.on("message", (raw) => {
|
|
229
|
+
let msg;
|
|
230
|
+
try {
|
|
231
|
+
msg = JSON.parse(raw.toString());
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
switch (msg.type) {
|
|
237
|
+
case "list": {
|
|
238
|
+
const payload = buildSessions();
|
|
239
|
+
ws._lastSessionsJson = JSON.stringify(payload);
|
|
240
|
+
send(ws, { type: "sessions", ...payload });
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "subscribe": {
|
|
244
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
245
|
+
const name = typeof msg.name === "string" ? msg.name : "default";
|
|
246
|
+
if (!sid)
|
|
247
|
+
return;
|
|
248
|
+
ws._sub = { sid, name };
|
|
249
|
+
ws._forceSnap = false;
|
|
250
|
+
const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
|
|
251
|
+
ws._wasRunning = entry?.session.hasRunningStream() ?? false;
|
|
252
|
+
// 预置流指针:运行中订阅时先消费一次(丢弃),快照已含当前输出,后续增量不与快照重叠
|
|
253
|
+
entry?.session.getRunningStream();
|
|
254
|
+
const snap = buildSnapshot(sid, name, entry);
|
|
255
|
+
ws._lastKey = entry ? historyKey(entry.session) : JSON.stringify(snap);
|
|
256
|
+
send(ws, { type: "snapshot", sessionID: sid, name, ...snap });
|
|
257
|
+
// Raw 连续流:仅客户端要求且本进程持有会话句柄时启用
|
|
258
|
+
const wantRaw = msg.raw === true;
|
|
259
|
+
if (wantRaw && entry) {
|
|
260
|
+
const r = entry.session.readRawStream(0);
|
|
261
|
+
ws._rawPos = r.pos;
|
|
262
|
+
send(ws, { type: "raw", data: r.data, pos: r.pos, reset: true });
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
ws._rawPos = undefined;
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "unsubscribe": {
|
|
270
|
+
ws._sub = undefined;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case "ping": {
|
|
274
|
+
send(ws, { type: "pong" });
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "deleteTerminal": {
|
|
278
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
279
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
280
|
+
if (!sid || !name)
|
|
281
|
+
break;
|
|
282
|
+
const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
|
|
283
|
+
if (entry) {
|
|
284
|
+
// 有活动句柄:close() 已含 history.dispose() 删除历史目录
|
|
285
|
+
entry.session.close();
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// 跨进程/陈旧终端:无句柄,直接删除历史目录(历史文件 + 会话记录)
|
|
289
|
+
const histName = `local-${name}`;
|
|
290
|
+
const histDir = join(dir, sid, histName);
|
|
291
|
+
const sshDir = join(dir, sid, name);
|
|
292
|
+
try {
|
|
293
|
+
rmSync(histDir, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
catch { /* 忽略 */ }
|
|
296
|
+
try {
|
|
297
|
+
rmSync(sshDir, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
catch { /* 忽略 */ }
|
|
300
|
+
}
|
|
301
|
+
removeSessionState(dir, sid, name);
|
|
302
|
+
log.info(`Web 页删除终端 ${sid}/${name}`);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
82
307
|
});
|
|
83
308
|
return new Promise((resolve, reject) => {
|
|
84
309
|
server.once("error", reject);
|
|
@@ -90,11 +315,87 @@ export function startServer(port, getSessions, dir) {
|
|
|
90
315
|
server,
|
|
91
316
|
port: actualPort,
|
|
92
317
|
url: `http://127.0.0.1:${actualPort}`,
|
|
93
|
-
close: () =>
|
|
318
|
+
close: () => {
|
|
319
|
+
clearInterval(streamTimer);
|
|
320
|
+
clearInterval(statusTimer);
|
|
321
|
+
server.close();
|
|
322
|
+
wss.close();
|
|
323
|
+
},
|
|
94
324
|
});
|
|
95
325
|
});
|
|
96
326
|
});
|
|
97
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* 渲染终端记录页面:读取编译后的 index.html,替换 i18n 占位符与前端 I18N 对象
|
|
330
|
+
* @param lang 界面语言
|
|
331
|
+
* @param template index.html 模板文本(启动时已读)
|
|
332
|
+
* @returns 最终 HTML 文本
|
|
333
|
+
*/
|
|
334
|
+
function renderPage(lang, template) {
|
|
335
|
+
let out = template;
|
|
336
|
+
// 语言占位
|
|
337
|
+
out = out.replaceAll("{{lang}}", lang);
|
|
338
|
+
// 文本占位({{web_xxx}})
|
|
339
|
+
for (const key of PAGE_KEYS) {
|
|
340
|
+
out = out.replaceAll(`{{${key}}}`, tr(key, lang));
|
|
341
|
+
}
|
|
342
|
+
// 前端 JS I18N 对象(window.__I18N__ = ...)
|
|
343
|
+
const i18nObj = {};
|
|
344
|
+
for (const [jsKey, i18nKey] of Object.entries(JS_I18N_KEYS)) {
|
|
345
|
+
i18nObj[jsKey] = tr(i18nKey, lang);
|
|
346
|
+
}
|
|
347
|
+
out = out.replaceAll("__I18N_JSON__", JSON.stringify(i18nObj));
|
|
348
|
+
// PTY 列宽注入(前端 TermScreen 按此列宽渲染,与后端生成 ANSI 的列宽一致,避免错行)
|
|
349
|
+
out = out.replaceAll("__PTY_COLS_VAL__", String(PTY_COLS));
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
/** 读取前端模板(dist/web/index.html);缺失时返回引导构建的提示页 */
|
|
353
|
+
function readTemplate(webDir) {
|
|
354
|
+
const file = join(webDir, "index.html");
|
|
355
|
+
if (!existsSync(file)) {
|
|
356
|
+
return `<!DOCTYPE html><html><body><h1>Web assets not built</h1><p>Run <code>npm run build</code> to build the web frontend.</p></body></html>`;
|
|
357
|
+
}
|
|
358
|
+
return readFileSync(file, "utf8");
|
|
359
|
+
}
|
|
360
|
+
/** 静态文件类型映射 */
|
|
361
|
+
const MIME = {
|
|
362
|
+
".html": "text/html; charset=utf-8",
|
|
363
|
+
".js": "application/javascript; charset=utf-8",
|
|
364
|
+
".css": "text/css; charset=utf-8",
|
|
365
|
+
".ttf": "font/ttf",
|
|
366
|
+
".woff": "font/woff",
|
|
367
|
+
".woff2": "font/woff2",
|
|
368
|
+
".svg": "image/svg+xml",
|
|
369
|
+
".png": "image/png",
|
|
370
|
+
".ico": "image/x-icon",
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* 分发静态资源(防目录穿越:归一化后校验目标在 webDir 内)
|
|
374
|
+
* @param res 响应对象
|
|
375
|
+
* @param webDir 前端产物根目录
|
|
376
|
+
* @param rel 相对路径(已去 /web/ 前缀)
|
|
377
|
+
*/
|
|
378
|
+
function serveStatic(res, webDir, rel) {
|
|
379
|
+
if (rel.includes("\0")) {
|
|
380
|
+
res.writeHead(400);
|
|
381
|
+
res.end("Bad Request");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const file = normalize(join(webDir, rel));
|
|
385
|
+
if (!file.startsWith(webDir + "\\") && !file.startsWith(webDir + "/") && file !== webDir) {
|
|
386
|
+
res.writeHead(403);
|
|
387
|
+
res.end("Forbidden");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!existsSync(file)) {
|
|
391
|
+
res.writeHead(404);
|
|
392
|
+
res.end("Not Found");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const ext = extname(file).toLowerCase();
|
|
396
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
397
|
+
res.end(readFileSync(file));
|
|
398
|
+
}
|
|
98
399
|
/**
|
|
99
400
|
* 从历史文件读取某终端记录(跨进程场景:本进程无该会话句柄时用)
|
|
100
401
|
* @param dir 插件缓存根目录
|
|
@@ -116,8 +417,12 @@ function readHistoryFromFile(dir, sessionID, name) {
|
|
|
116
417
|
for (const f of files) {
|
|
117
418
|
try {
|
|
118
419
|
const data = JSON.parse(readFileSync(join(hdir, f), "utf8"));
|
|
420
|
+
if (data.command === "__SSH_SEP__") {
|
|
421
|
+
out.push({ type: "sep", ts: data.ts ?? Date.now(), text: "" });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
119
424
|
if (typeof data.command === "string")
|
|
120
|
-
out.push({ type: "cmd", ts: data.ts ?? Date.now(), text: data.command });
|
|
425
|
+
out.push({ type: "cmd", ts: data.ts ?? Date.now(), endTs: data.endTs, text: data.command });
|
|
121
426
|
if (typeof data.output === "string")
|
|
122
427
|
out.push({ type: "out", text: data.output });
|
|
123
428
|
}
|
|
@@ -127,252 +432,4 @@ function readHistoryFromFile(dir, sessionID, name) {
|
|
|
127
432
|
}
|
|
128
433
|
return out;
|
|
129
434
|
}
|
|
130
|
-
/** 渲染终端记录页面(黑底绿字等宽,JS 轮询自动刷新) */
|
|
131
|
-
function renderPage() {
|
|
132
|
-
return `<!DOCTYPE html>
|
|
133
|
-
<html lang="zh">
|
|
134
|
-
<head>
|
|
135
|
-
<meta charset="utf-8">
|
|
136
|
-
<title>SSH 终端记录</title>
|
|
137
|
-
<style>
|
|
138
|
-
html, body { margin: 0; height: 100%; background: #0d1117; color: #c9d1d9; font-family: system-ui, sans-serif; }
|
|
139
|
-
header { position: sticky; top: 0; display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #161b22; border-bottom: 1px solid #30363d; }
|
|
140
|
-
header h1 { margin: 0; font-size: 14px; color: #e6edf3; font-weight: 600; }
|
|
141
|
-
select { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 4px 8px; border-radius: 6px; }
|
|
142
|
-
pre { margin: 0; padding: 16px; font-family: "Cascadia Code", Consolas, "Courier New", monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow: auto; height: calc(100% - 52px); box-sizing: border-box; color: #c9d1d9; }
|
|
143
|
-
/* 两列布局:左侧时间列(开关控制显隐),右侧内容列(命令/输出格式不变) */
|
|
144
|
-
.row { display: flex; align-items: stretch; }
|
|
145
|
-
.row .t { box-sizing: border-box; flex: 0 0 172px; color: #8b949e; padding: 0 10px 0 6px; border-right: 1px solid #30363d; white-space: pre; }
|
|
146
|
-
.row .c { flex: 1; padding-left: 12px; white-space: pre; }
|
|
147
|
-
body:not(.show-time) .row .t { display: none; }
|
|
148
|
-
/* 命令行:明显区分(背景条 + 左侧蓝条 + 加粗);用 box-shadow 不占布局,保证时间列/分割线与输出行完全对齐 */
|
|
149
|
-
.row.cmdline { background: #161b22; box-shadow: inset 3px 0 0 #58a6ff; font-weight: 600; }
|
|
150
|
-
.tgl { font-size: 12px; color: #8b949e; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
|
151
|
-
#meta { font-size: 12px; color: #8b949e; }
|
|
152
|
-
/* 深色细滚动条:匹配暗色主题 */
|
|
153
|
-
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
154
|
-
::-webkit-scrollbar-track { background: #161b22; }
|
|
155
|
-
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 5px; border: 2px solid #161b22; }
|
|
156
|
-
::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
|
157
|
-
::-webkit-scrollbar-corner { background: #161b22; }
|
|
158
|
-
* { scrollbar-width: thin; scrollbar-color: #30363d #161b22; }
|
|
159
|
-
/* 回到底部悬浮按钮:离开底部且有新消息时显示 */
|
|
160
|
-
.to-bottom-btn {
|
|
161
|
-
position: fixed; right: 24px; bottom: 24px; display: none;
|
|
162
|
-
background: #238636; color: #fff; border: none; border-radius: 20px;
|
|
163
|
-
padding: 8px 16px; font-size: 13px; cursor: pointer; z-index: 10;
|
|
164
|
-
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
|
165
|
-
}
|
|
166
|
-
.to-bottom-btn:hover { background: #2ea043; }
|
|
167
|
-
</style>
|
|
168
|
-
</head>
|
|
169
|
-
<body>
|
|
170
|
-
<header>
|
|
171
|
-
<h1>终端记录</h1>
|
|
172
|
-
<select id="session" onchange="onSessionChange(true)"></select>
|
|
173
|
-
<select id="terminal" onchange="onSessionChange(true)"></select>
|
|
174
|
-
<label class="tgl"><input type="checkbox" id="showTime" onchange="onShowTimeChange()"> 时间</label>
|
|
175
|
-
<span id="meta"></span>
|
|
176
|
-
</header>
|
|
177
|
-
<pre id="term">加载中...</pre>
|
|
178
|
-
<button id="toBottom" class="to-bottom-btn" onclick="scrollToNewest()">↓ 新消息</button>
|
|
179
|
-
<script>
|
|
180
|
-
let sessionsData = [];
|
|
181
|
-
// 是否贴底(用户向上滚动查看历史时不自动下滚,仅贴底时跟随新输出)
|
|
182
|
-
let stickToBottom = true;
|
|
183
|
-
// 上次会话列表指纹(无变化则不重建下拉框,避免打断用户选择/焦点)
|
|
184
|
-
let lastSessionsKey = "";
|
|
185
|
-
// 上次渲染的 transcript 文本(判断是否真有新消息才显示悬浮按钮)
|
|
186
|
-
let lastTranscript = "";
|
|
187
|
-
// 新消息顶部位置(点击悬浮按钮时滚动到此处,而非底部)
|
|
188
|
-
let pendingTop = 0;
|
|
189
|
-
|
|
190
|
-
// ANSI → 彩色 HTML:消费所有 CSI 序列(颜色/光标/清屏等),SGR 着色、其余丢弃;先 HTML 转义防 XSS
|
|
191
|
-
const ANSI_BASE = ["#010101","#de382b","#39b54a","#ffc005","#006fb8","#762671","#2cb3e9","#c9d1d9"];
|
|
192
|
-
const ANSI_BRIGHT = ["#666666","#ff7b72","#3fb950","#d29922","#58a6ff","#bc8cff","#39c5cf","#f0f6fc"];
|
|
193
|
-
function ansiToHtml(s) {
|
|
194
|
-
const esc = s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
195
|
-
const re = /\\x1b\\[[0-9;?]*[A-Za-z]/g;
|
|
196
|
-
let fg = null, bg = null, bold = false, last = 0, m;
|
|
197
|
-
const parts = [];
|
|
198
|
-
const flush = (end) => {
|
|
199
|
-
const text = esc.slice(last, end);
|
|
200
|
-
last = end;
|
|
201
|
-
if (!text) return;
|
|
202
|
-
const style = [];
|
|
203
|
-
if (bold) style.push("font-weight:bold");
|
|
204
|
-
if (fg) style.push("color:" + fg);
|
|
205
|
-
if (bg) style.push("background-color:" + bg);
|
|
206
|
-
parts.push(style.length ? '<span style="' + style.join(";") + '">' + text + "</span>" : text);
|
|
207
|
-
};
|
|
208
|
-
while ((m = re.exec(esc))) {
|
|
209
|
-
flush(m.index);
|
|
210
|
-
const seq = m[0];
|
|
211
|
-
// 仅 SGR(结尾 m)改颜色,其余 CSI(光标/清屏/括号粘贴等)丢弃
|
|
212
|
-
if (seq.endsWith("m")) {
|
|
213
|
-
const body = seq.slice(2, -1);
|
|
214
|
-
const codes = body ? body.split(";").map(x => parseInt(x, 10)) : [0];
|
|
215
|
-
if (!body || codes.indexOf(0) >= 0) { fg = null; bg = null; bold = false; }
|
|
216
|
-
for (const c of codes) {
|
|
217
|
-
if (c === 1) bold = true;
|
|
218
|
-
else if (c === 22) bold = false;
|
|
219
|
-
else if (c >= 30 && c <= 37) fg = ANSI_BASE[c - 30];
|
|
220
|
-
else if (c === 39) fg = null;
|
|
221
|
-
else if (c >= 90 && c <= 97) fg = ANSI_BRIGHT[c - 90];
|
|
222
|
-
else if (c >= 40 && c <= 47) bg = ANSI_BASE[c - 40];
|
|
223
|
-
else if (c === 49) bg = null;
|
|
224
|
-
else if (c >= 100 && c <= 107) bg = ANSI_BRIGHT[c - 100];
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
// 关键:跳过序列本身,避免真实 ESC 残留(浏览器显示为"口")
|
|
228
|
-
last = re.lastIndex;
|
|
229
|
-
}
|
|
230
|
-
flush(esc.length);
|
|
231
|
-
return parts.join("");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function sessionLabel(s) {
|
|
235
|
-
// 优先显示会话标题,其次 host@user,最后短 sessionID
|
|
236
|
-
if (s.title && s.title.trim()) return s.title;
|
|
237
|
-
const t = s.terminals && s.terminals[0];
|
|
238
|
-
if (t && t.host) return (t.user ? t.user + "@" : "") + t.host;
|
|
239
|
-
return s.sessionID.slice(0, 8) + "…";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function loadSessions() {
|
|
243
|
-
const r = await fetch("/api/status");
|
|
244
|
-
const data = await r.json();
|
|
245
|
-
const newSessions = data.sessions || [];
|
|
246
|
-
const sel = document.getElementById("session");
|
|
247
|
-
// 会话指纹未变化则跳过重建(保留用户选择与焦点)
|
|
248
|
-
const key = newSessions.map(s => s.sessionID + "|" + s.terminals.length).join(",");
|
|
249
|
-
if (key === lastSessionsKey) return;
|
|
250
|
-
lastSessionsKey = key;
|
|
251
|
-
sessionsData = newSessions;
|
|
252
|
-
const prev = sel.value;
|
|
253
|
-
sel.innerHTML = "";
|
|
254
|
-
for (const s of sessionsData) {
|
|
255
|
-
const opt = document.createElement("option");
|
|
256
|
-
opt.value = s.sessionID;
|
|
257
|
-
opt.textContent = sessionLabel(s) + " (" + s.terminals.length + " 终端)";
|
|
258
|
-
sel.appendChild(opt);
|
|
259
|
-
}
|
|
260
|
-
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
|
|
261
|
-
else sel.selectedIndex = sessionsData.length ? 0 : -1;
|
|
262
|
-
onSessionChange(false);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function onSessionChange(forceStick) {
|
|
266
|
-
lastTranscript = "";
|
|
267
|
-
pendingTop = 0;
|
|
268
|
-
const sel = document.getElementById("session");
|
|
269
|
-
const sid = sel.value;
|
|
270
|
-
const tsel = document.getElementById("terminal");
|
|
271
|
-
const prev = tsel.value;
|
|
272
|
-
tsel.innerHTML = "";
|
|
273
|
-
const s = sessionsData.find(x => x.sessionID === sid);
|
|
274
|
-
for (const t of (s ? s.terminals : [])) {
|
|
275
|
-
const opt = document.createElement("option");
|
|
276
|
-
opt.value = t.name || "default";
|
|
277
|
-
opt.textContent = (t.name || "default") + (t.kind === "local" ? " [本地]" : "") + " " + (t.connected ? "●" : "○") + (t.busy ? " ⏳" : "");
|
|
278
|
-
tsel.appendChild(opt);
|
|
279
|
-
}
|
|
280
|
-
if (prev && [...tsel.options].some(o => o.value === prev)) tsel.value = prev;
|
|
281
|
-
else tsel.selectedIndex = tsel.options.length ? 0 : -1;
|
|
282
|
-
// 仅用户主动切换会话/终端时强制回到底部;自动轮询重建不打扰当前滚动位置
|
|
283
|
-
if (forceStick) stickToBottom = true;
|
|
284
|
-
loadTranscript();
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async function loadTranscript() {
|
|
288
|
-
const pre = document.getElementById("term");
|
|
289
|
-
const sel = document.getElementById("session");
|
|
290
|
-
const tsel = document.getElementById("terminal");
|
|
291
|
-
const sid = sel.value;
|
|
292
|
-
const name = tsel.value;
|
|
293
|
-
if (!sid || !name) { pre.textContent = "无会话,请先 ssh_connect"; return; }
|
|
294
|
-
// 更新前判断是否贴底:用户已向上滚动离开底部则不自动下滚,保持当前位置
|
|
295
|
-
if (pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30) stickToBottom = true;
|
|
296
|
-
const showTime = document.getElementById("showTime").checked;
|
|
297
|
-
document.body.classList.toggle("show-time", showTime);
|
|
298
|
-
const r = await fetch("/api/transcript?session=" + encodeURIComponent(sid) + "&name=" + encodeURIComponent(name));
|
|
299
|
-
const data = await r.json();
|
|
300
|
-
const pairs = data.pairs || [];
|
|
301
|
-
// 渲染前旧内容完整高度 = 新消息顶部位置(旧内容不变时高度稳定)
|
|
302
|
-
const prevHeight = pre.scrollHeight;
|
|
303
|
-
// 内容是否有新变化(有新消息才显示悬浮按钮)
|
|
304
|
-
const sig = JSON.stringify(pairs);
|
|
305
|
-
const changed = sig !== lastTranscript;
|
|
306
|
-
lastTranscript = sig;
|
|
307
|
-
pre.innerHTML = pairs.map((p) => {
|
|
308
|
-
if (p.type === "cmd") {
|
|
309
|
-
const t = showTime ? fmtTime(p.ts) : "";
|
|
310
|
-
return '<div class="row cmdline"><span class="t">' + t + '</span><span class="c">' + ansiToHtml(p.text) + '</span></div>';
|
|
311
|
-
}
|
|
312
|
-
// out / run:输出列,保留 ANSI 彩色,时间列留空(不改变输出格式)
|
|
313
|
-
const label = p.type === "run" ? "[运行中] " : "";
|
|
314
|
-
return '<div class="row"><span class="t"></span><span class="c">' + label + ansiToHtml(p.text) + '</span></div>';
|
|
315
|
-
}).join("");
|
|
316
|
-
const cmdCount = pairs.filter((p) => p.type === "cmd").length;
|
|
317
|
-
document.getElementById("meta").textContent = sid + "/" + name + " · " + cmdCount + " 条命令 · 每 2s 自动刷新";
|
|
318
|
-
if (stickToBottom) {
|
|
319
|
-
pre.scrollTop = pre.scrollHeight;
|
|
320
|
-
toBottomBtn.style.display = "none";
|
|
321
|
-
} else if (pendingTop > 0) {
|
|
322
|
-
// 离开底部且有未读新消息 → 显示悬浮按钮(pendingTop 为跳转位置)
|
|
323
|
-
toBottomBtn.style.display = "block";
|
|
324
|
-
}
|
|
325
|
-
// 无论贴底与否,有内容变化都记录新消息顶部(供用户滚上去后跳转)
|
|
326
|
-
if (changed && pre.scrollHeight > pre.clientHeight) {
|
|
327
|
-
pendingTop = prevHeight;
|
|
328
|
-
if (stickToBottom) {
|
|
329
|
-
// 贴底跟随 = 已读到最新,清除未读标记(用户滚上去才重新提示)
|
|
330
|
-
pendingTop = 0;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// 时间戳格式化 yyyy-MM-dd HH:mm:ss
|
|
336
|
-
function fmtTime(ts) {
|
|
337
|
-
const d = new Date(ts);
|
|
338
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
339
|
-
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// 时间开关:localStorage 持久化,刷新页面保持状态
|
|
343
|
-
function onShowTimeChange() {
|
|
344
|
-
const el = document.getElementById("showTime");
|
|
345
|
-
localStorage.setItem("showTime", el.checked ? "1" : "0");
|
|
346
|
-
document.body.classList.toggle("show-time", el.checked);
|
|
347
|
-
loadTranscript();
|
|
348
|
-
}
|
|
349
|
-
document.getElementById("showTime").checked = localStorage.getItem("showTime") === "1";
|
|
350
|
-
document.body.classList.toggle("show-time", document.getElementById("showTime").checked);
|
|
351
|
-
|
|
352
|
-
// 用户滚动:贴底 → 恢复跟随并隐藏按钮;离开底部 → 取消自动贴底
|
|
353
|
-
const termPre = document.getElementById("term");
|
|
354
|
-
const toBottomBtn = document.getElementById("toBottom");
|
|
355
|
-
termPre.addEventListener("scroll", () => {
|
|
356
|
-
if (termPre.scrollTop + termPre.clientHeight >= termPre.scrollHeight - 30) {
|
|
357
|
-
stickToBottom = true;
|
|
358
|
-
toBottomBtn.style.display = "none";
|
|
359
|
-
pendingTop = 0; // 贴底 = 已读到最新,清除未读标记
|
|
360
|
-
} else {
|
|
361
|
-
stickToBottom = false;
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
// 点击悬浮按钮:滚动到新消息顶部(阅读未读内容),不强制回到底部
|
|
365
|
-
function scrollToNewest() {
|
|
366
|
-
termPre.scrollTop = pendingTop > 0 ? pendingTop : termPre.scrollHeight;
|
|
367
|
-
pendingTop = 0;
|
|
368
|
-
toBottomBtn.style.display = "none";
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
loadSessions();
|
|
372
|
-
setInterval(loadTranscript, 2000);
|
|
373
|
-
setInterval(loadSessions, 5000);
|
|
374
|
-
</script>
|
|
375
|
-
</body>
|
|
376
|
-
</html>`;
|
|
377
|
-
}
|
|
378
435
|
//# sourceMappingURL=server.js.map
|