@xcanwin/manyoyo 3.8.7 → 3.9.3
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 +2 -0
- package/bin/manyoyo.js +129 -8
- package/config.example.json +2 -0
- package/docker/manyoyo.Dockerfile +3 -0
- package/lib/web/server.js +677 -0
- package/lib/web/static/app.css +315 -0
- package/lib/web/static/app.html +40 -0
- package/lib/web/static/app.js +276 -0
- package/lib/web/static/login.css +81 -0
- package/lib/web/static/login.html +22 -0
- package/lib/web/static/login.js +28 -0
- package/package.json +2 -1
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
|
|
10
|
+
const WEB_HISTORY_MAX_MESSAGES = 500;
|
|
11
|
+
const WEB_OUTPUT_MAX_CHARS = 16000;
|
|
12
|
+
const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
|
|
13
|
+
const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
|
|
14
|
+
const STATIC_DIR = path.join(__dirname, 'static');
|
|
15
|
+
|
|
16
|
+
const MIME_TYPES = {
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
19
|
+
'.html': 'text/html; charset=utf-8'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function ensureWebHistoryDir(webHistoryDir) {
|
|
23
|
+
fs.mkdirSync(webHistoryDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getWebHistoryFile(webHistoryDir, containerName) {
|
|
27
|
+
return path.join(webHistoryDir, `${containerName}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadWebSessionHistory(webHistoryDir, containerName) {
|
|
31
|
+
ensureWebHistoryDir(webHistoryDir);
|
|
32
|
+
const filePath = getWebHistoryFile(webHistoryDir, containerName);
|
|
33
|
+
if (!fs.existsSync(filePath)) {
|
|
34
|
+
return { containerName, updatedAt: null, messages: [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
39
|
+
return {
|
|
40
|
+
containerName,
|
|
41
|
+
updatedAt: data.updatedAt || null,
|
|
42
|
+
messages: Array.isArray(data.messages) ? data.messages : []
|
|
43
|
+
};
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { containerName, updatedAt: null, messages: [] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveWebSessionHistory(webHistoryDir, containerName, history) {
|
|
50
|
+
ensureWebHistoryDir(webHistoryDir);
|
|
51
|
+
const filePath = getWebHistoryFile(webHistoryDir, containerName);
|
|
52
|
+
fs.writeFileSync(filePath, JSON.stringify(history, null, 4));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function removeWebSessionHistory(webHistoryDir, containerName) {
|
|
56
|
+
ensureWebHistoryDir(webHistoryDir);
|
|
57
|
+
const filePath = getWebHistoryFile(webHistoryDir, containerName);
|
|
58
|
+
if (fs.existsSync(filePath)) {
|
|
59
|
+
fs.unlinkSync(filePath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function listWebHistorySessionNames(webHistoryDir, isValidContainerName) {
|
|
64
|
+
ensureWebHistoryDir(webHistoryDir);
|
|
65
|
+
return fs.readdirSync(webHistoryDir)
|
|
66
|
+
.filter(file => file.endsWith('.json'))
|
|
67
|
+
.map(file => path.basename(file, '.json'))
|
|
68
|
+
.filter(name => isValidContainerName(name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendWebSessionMessage(webHistoryDir, containerName, role, content, extra = {}) {
|
|
72
|
+
const history = loadWebSessionHistory(webHistoryDir, containerName);
|
|
73
|
+
const timestamp = new Date().toISOString();
|
|
74
|
+
history.messages.push({
|
|
75
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
76
|
+
role,
|
|
77
|
+
content,
|
|
78
|
+
timestamp,
|
|
79
|
+
...extra
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (history.messages.length > WEB_HISTORY_MAX_MESSAGES) {
|
|
83
|
+
history.messages = history.messages.slice(-WEB_HISTORY_MAX_MESSAGES);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
history.updatedAt = timestamp;
|
|
87
|
+
saveWebSessionHistory(webHistoryDir, containerName, history);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stripAnsi(text) {
|
|
91
|
+
if (typeof text !== 'string') return '';
|
|
92
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clipText(text, maxChars = WEB_OUTPUT_MAX_CHARS) {
|
|
96
|
+
if (typeof text !== 'string') return '';
|
|
97
|
+
if (text.length <= maxChars) return text;
|
|
98
|
+
return `${text.slice(0, maxChars)}\n...[truncated]`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function secureStringEqual(a, b) {
|
|
102
|
+
const aStr = String(a || '');
|
|
103
|
+
const bStr = String(b || '');
|
|
104
|
+
const aBuffer = Buffer.from(aStr, 'utf-8');
|
|
105
|
+
const bBuffer = Buffer.from(bStr, 'utf-8');
|
|
106
|
+
if (aBuffer.length !== bBuffer.length) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return crypto.timingSafeEqual(aBuffer, bBuffer);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseCookies(req) {
|
|
113
|
+
const cookieHeader = req.headers.cookie || '';
|
|
114
|
+
if (!cookieHeader) {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cookies = {};
|
|
119
|
+
cookieHeader.split(';').forEach(part => {
|
|
120
|
+
const index = part.indexOf('=');
|
|
121
|
+
if (index <= 0) return;
|
|
122
|
+
const key = part.slice(0, index).trim();
|
|
123
|
+
const value = part.slice(index + 1).trim();
|
|
124
|
+
if (!key) return;
|
|
125
|
+
try {
|
|
126
|
+
cookies[key] = decodeURIComponent(value);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
cookies[key] = value;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return cookies;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pruneExpiredWebAuthSessions(state) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
for (const [sessionId, session] of state.authSessions.entries()) {
|
|
137
|
+
if (!session || session.expiresAt <= now) {
|
|
138
|
+
state.authSessions.delete(sessionId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createWebAuthSession(state, username) {
|
|
144
|
+
pruneExpiredWebAuthSessions(state);
|
|
145
|
+
const sessionId = crypto.randomBytes(24).toString('hex');
|
|
146
|
+
state.authSessions.set(sessionId, {
|
|
147
|
+
username,
|
|
148
|
+
expiresAt: Date.now() + WEB_AUTH_TTL_SECONDS * 1000
|
|
149
|
+
});
|
|
150
|
+
return sessionId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getWebAuthSession(state, req) {
|
|
154
|
+
pruneExpiredWebAuthSessions(state);
|
|
155
|
+
const cookies = parseCookies(req);
|
|
156
|
+
const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
|
|
157
|
+
if (!sessionId) return null;
|
|
158
|
+
|
|
159
|
+
const session = state.authSessions.get(sessionId);
|
|
160
|
+
if (!session) return null;
|
|
161
|
+
if (session.expiresAt <= Date.now()) {
|
|
162
|
+
state.authSessions.delete(sessionId);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sliding session expiration
|
|
167
|
+
session.expiresAt = Date.now() + WEB_AUTH_TTL_SECONDS * 1000;
|
|
168
|
+
return { sessionId, username: session.username };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function clearWebAuthSession(state, req) {
|
|
172
|
+
const cookies = parseCookies(req);
|
|
173
|
+
const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
|
|
174
|
+
if (sessionId) {
|
|
175
|
+
state.authSessions.delete(sessionId);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getWebAuthCookie(sessionId) {
|
|
180
|
+
return `${WEB_AUTH_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${WEB_AUTH_TTL_SECONDS}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getWebAuthClearCookie() {
|
|
184
|
+
return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function listWebManyoyoContainers(ctx) {
|
|
188
|
+
const output = ctx.dockerExecArgs(
|
|
189
|
+
['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
|
|
190
|
+
{ ignoreError: true }
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const map = {};
|
|
194
|
+
if (!output.trim()) {
|
|
195
|
+
return map;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
output.trim().split('\n').forEach(line => {
|
|
199
|
+
const [name, status, image] = line.split('\t');
|
|
200
|
+
if (!ctx.isValidContainerName(name)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
map[name] = {
|
|
204
|
+
name,
|
|
205
|
+
status: status || 'unknown',
|
|
206
|
+
image: image || ''
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return map;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ensureWebContainer(ctx, state, containerName) {
|
|
214
|
+
if (!ctx.containerExists(containerName)) {
|
|
215
|
+
const webDefaultCommand = `${ctx.execCommandPrefix}${ctx.execCommand}${ctx.execCommandSuffix}`.trim() || '/bin/bash';
|
|
216
|
+
const safeLabelCmd = webDefaultCommand.replace(/[\r\n]/g, ' ');
|
|
217
|
+
const args = [
|
|
218
|
+
'run', '-d',
|
|
219
|
+
'--name', containerName,
|
|
220
|
+
'--entrypoint', '',
|
|
221
|
+
...ctx.contModeArgs,
|
|
222
|
+
...ctx.containerEnvs,
|
|
223
|
+
...ctx.containerVolumes,
|
|
224
|
+
'--volume', `${ctx.hostPath}:${ctx.containerPath}`,
|
|
225
|
+
'--workdir', ctx.containerPath,
|
|
226
|
+
'--label', `manyoyo.default_cmd=${safeLabelCmd}`,
|
|
227
|
+
`${ctx.imageName}:${ctx.imageVersion}`,
|
|
228
|
+
'tail', '-f', '/dev/null'
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
ctx.dockerExecArgs(args, { stdio: 'pipe' });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
ctx.showImagePullHint(e);
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await ctx.waitForContainerReady(containerName);
|
|
239
|
+
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已创建并启动。`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const status = ctx.getContainerStatus(containerName);
|
|
244
|
+
if (status !== 'running') {
|
|
245
|
+
ctx.dockerExecArgs(['start', containerName], { stdio: 'pipe' });
|
|
246
|
+
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已启动。`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function execCommandInWebContainer(ctx, containerName, command) {
|
|
251
|
+
const result = spawnSync(ctx.dockerCmd, ['exec', containerName, '/bin/bash', '-lc', command], {
|
|
252
|
+
encoding: 'utf-8',
|
|
253
|
+
maxBuffer: 32 * 1024 * 1024
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (result.error) {
|
|
257
|
+
throw result.error;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const exitCode = typeof result.status === 'number' ? result.status : 1;
|
|
261
|
+
const rawOutput = `${result.stdout || ''}${result.stderr || ''}`;
|
|
262
|
+
const output = clipText(stripAnsi(rawOutput).trim() || '(无输出)');
|
|
263
|
+
|
|
264
|
+
return { exitCode, output };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readRequestBody(req) {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
let body = '';
|
|
270
|
+
req.on('data', chunk => {
|
|
271
|
+
body += chunk.toString('utf-8');
|
|
272
|
+
if (body.length > 1024 * 1024) {
|
|
273
|
+
reject(new Error('请求体过大'));
|
|
274
|
+
req.destroy();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
req.on('end', () => resolve(body));
|
|
278
|
+
req.on('error', reject);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readJsonBody(req) {
|
|
283
|
+
const body = await readRequestBody(req);
|
|
284
|
+
if (!body.trim()) {
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return JSON.parse(body);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
throw new Error('JSON body 格式错误');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sendJson(res, statusCode, payload, extraHeaders = {}) {
|
|
295
|
+
res.writeHead(statusCode, {
|
|
296
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
297
|
+
'Cache-Control': 'no-store',
|
|
298
|
+
...extraHeaders
|
|
299
|
+
});
|
|
300
|
+
res.end(JSON.stringify(payload));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sendHtml(res, statusCode, html, extraHeaders = {}) {
|
|
304
|
+
res.writeHead(statusCode, {
|
|
305
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
306
|
+
'Cache-Control': 'no-store',
|
|
307
|
+
...extraHeaders
|
|
308
|
+
});
|
|
309
|
+
res.end(html);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function decodeSessionName(encoded) {
|
|
313
|
+
try {
|
|
314
|
+
return decodeURIComponent(encoded);
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return encoded;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildSessionSummary(ctx, state, containerMap, name) {
|
|
321
|
+
const history = loadWebSessionHistory(state.webHistoryDir, name);
|
|
322
|
+
const latestMessage = history.messages.length ? history.messages[history.messages.length - 1] : null;
|
|
323
|
+
const containerInfo = containerMap[name] || {};
|
|
324
|
+
const updatedAt = history.updatedAt || (latestMessage && latestMessage.timestamp) || null;
|
|
325
|
+
return {
|
|
326
|
+
name,
|
|
327
|
+
status: containerInfo.status || 'history',
|
|
328
|
+
image: containerInfo.image || '',
|
|
329
|
+
updatedAt,
|
|
330
|
+
messageCount: history.messages.length
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isSafeStaticAssetName(name) {
|
|
335
|
+
return /^[A-Za-z0-9._-]+$/.test(name);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function resolveStaticAsset(name) {
|
|
339
|
+
if (!isSafeStaticAssetName(name)) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const fullPath = path.join(STATIC_DIR, name);
|
|
343
|
+
return fs.existsSync(fullPath) ? fullPath : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function sendStaticAsset(res, assetName) {
|
|
347
|
+
const filePath = resolveStaticAsset(assetName);
|
|
348
|
+
if (!filePath) {
|
|
349
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
354
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
355
|
+
const content = fs.readFileSync(filePath);
|
|
356
|
+
res.writeHead(200, {
|
|
357
|
+
'Content-Type': contentType,
|
|
358
|
+
'Cache-Control': 'no-store'
|
|
359
|
+
});
|
|
360
|
+
res.end(content);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function loadTemplate(name) {
|
|
364
|
+
const filePath = resolveStaticAsset(name);
|
|
365
|
+
if (!filePath) {
|
|
366
|
+
return '<h1>Template Not Found</h1>';
|
|
367
|
+
}
|
|
368
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
372
|
+
if (req.method === 'GET' && pathname === '/auth/login') {
|
|
373
|
+
sendHtml(res, 200, loadTemplate('login.html'));
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const authStaticMatch = pathname.match(/^\/auth\/static\/([A-Za-z0-9._-]+)$/);
|
|
378
|
+
if (req.method === 'GET' && authStaticMatch) {
|
|
379
|
+
const assetName = authStaticMatch[1];
|
|
380
|
+
if (!(assetName === 'login.css' || assetName === 'login.js')) {
|
|
381
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
sendStaticAsset(res, assetName);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (req.method === 'POST' && pathname === '/auth/login') {
|
|
389
|
+
const payload = await readJsonBody(req);
|
|
390
|
+
const username = String(payload.username || '').trim();
|
|
391
|
+
const password = String(payload.password || '');
|
|
392
|
+
|
|
393
|
+
if (!username || !password) {
|
|
394
|
+
sendJson(res, 400, { error: '用户名和密码不能为空' });
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const userOk = secureStringEqual(username, ctx.authUser);
|
|
399
|
+
const passOk = secureStringEqual(password, ctx.authPass);
|
|
400
|
+
if (!(userOk && passOk)) {
|
|
401
|
+
sendJson(res, 401, { error: '用户名或密码错误' });
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const sessionId = createWebAuthSession(state, username);
|
|
406
|
+
sendJson(
|
|
407
|
+
res,
|
|
408
|
+
200,
|
|
409
|
+
{ ok: true, username },
|
|
410
|
+
{ 'Set-Cookie': getWebAuthCookie(sessionId) }
|
|
411
|
+
);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (req.method === 'POST' && pathname === '/auth/logout') {
|
|
416
|
+
clearWebAuthSession(state, req);
|
|
417
|
+
sendJson(
|
|
418
|
+
res,
|
|
419
|
+
200,
|
|
420
|
+
{ ok: true },
|
|
421
|
+
{ 'Set-Cookie': getWebAuthClearCookie() }
|
|
422
|
+
);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sendWebUnauthorized(res, pathname) {
|
|
430
|
+
if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
|
|
431
|
+
sendJson(res, 401, { error: 'UNAUTHORIZED' });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
sendHtml(
|
|
435
|
+
res,
|
|
436
|
+
401,
|
|
437
|
+
loadTemplate('login.html'),
|
|
438
|
+
{ 'Set-Cookie': getWebAuthClearCookie() }
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function handleWebApi(req, res, pathname, ctx, state) {
|
|
443
|
+
if (req.method === 'GET' && pathname === '/api/sessions') {
|
|
444
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
445
|
+
const names = new Set([
|
|
446
|
+
...Object.keys(containerMap),
|
|
447
|
+
...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
const sessions = Array.from(names)
|
|
451
|
+
.map(name => buildSessionSummary(ctx, state, containerMap, name))
|
|
452
|
+
.sort((a, b) => {
|
|
453
|
+
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
454
|
+
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
455
|
+
return timeB - timeA;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
sendJson(res, 200, { sessions });
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (req.method === 'POST' && pathname === '/api/sessions') {
|
|
463
|
+
const payload = await readJsonBody(req);
|
|
464
|
+
let containerName = (payload.name || '').trim();
|
|
465
|
+
if (!containerName) {
|
|
466
|
+
containerName = `myy-${ctx.formatDate()}`;
|
|
467
|
+
}
|
|
468
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
469
|
+
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await ensureWebContainer(ctx, state, containerName);
|
|
474
|
+
sendJson(res, 200, { name: containerName });
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const messagesMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
|
|
479
|
+
if (req.method === 'GET' && messagesMatch) {
|
|
480
|
+
const containerName = decodeSessionName(messagesMatch[1]);
|
|
481
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
482
|
+
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
487
|
+
sendJson(res, 200, { name: containerName, messages: history.messages });
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const runMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/run$/);
|
|
492
|
+
if (req.method === 'POST' && runMatch) {
|
|
493
|
+
const containerName = decodeSessionName(runMatch[1]);
|
|
494
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
495
|
+
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const payload = await readJsonBody(req);
|
|
500
|
+
const command = (payload.command || '').trim();
|
|
501
|
+
if (!command) {
|
|
502
|
+
sendJson(res, 400, { error: 'command 不能为空' });
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await ensureWebContainer(ctx, state, containerName);
|
|
507
|
+
appendWebSessionMessage(state.webHistoryDir, containerName, 'user', command);
|
|
508
|
+
const result = execCommandInWebContainer(ctx, containerName, command);
|
|
509
|
+
appendWebSessionMessage(
|
|
510
|
+
state.webHistoryDir,
|
|
511
|
+
containerName,
|
|
512
|
+
'assistant',
|
|
513
|
+
`${result.output}\n\n[exit ${result.exitCode}]`,
|
|
514
|
+
{ exitCode: result.exitCode }
|
|
515
|
+
);
|
|
516
|
+
sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const removeMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/remove$/);
|
|
521
|
+
if (req.method === 'POST' && removeMatch) {
|
|
522
|
+
const containerName = decodeSessionName(removeMatch[1]);
|
|
523
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
524
|
+
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (ctx.containerExists(containerName)) {
|
|
529
|
+
ctx.removeContainer(containerName);
|
|
530
|
+
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已删除。`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
sendJson(res, 200, { removed: true, name: containerName });
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const removeAllMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/);
|
|
538
|
+
if (req.method === 'POST' && removeAllMatch) {
|
|
539
|
+
const containerName = decodeSessionName(removeAllMatch[1]);
|
|
540
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
541
|
+
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (ctx.containerExists(containerName)) {
|
|
546
|
+
ctx.removeContainer(containerName);
|
|
547
|
+
}
|
|
548
|
+
removeWebSessionHistory(state.webHistoryDir, containerName);
|
|
549
|
+
|
|
550
|
+
sendJson(res, 200, { removed: true, removedHistory: true, name: containerName });
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function startWebServer(options) {
|
|
558
|
+
const ctx = {
|
|
559
|
+
serverPort: options.serverPort,
|
|
560
|
+
authUser: options.authUser,
|
|
561
|
+
authPass: options.authPass,
|
|
562
|
+
authPassAuto: options.authPassAuto,
|
|
563
|
+
dockerCmd: options.dockerCmd,
|
|
564
|
+
hostPath: options.hostPath,
|
|
565
|
+
containerPath: options.containerPath,
|
|
566
|
+
imageName: options.imageName,
|
|
567
|
+
imageVersion: options.imageVersion,
|
|
568
|
+
execCommandPrefix: options.execCommandPrefix,
|
|
569
|
+
execCommand: options.execCommand,
|
|
570
|
+
execCommandSuffix: options.execCommandSuffix,
|
|
571
|
+
contModeArgs: options.contModeArgs,
|
|
572
|
+
containerEnvs: options.containerEnvs,
|
|
573
|
+
containerVolumes: options.containerVolumes,
|
|
574
|
+
validateHostPath: options.validateHostPath,
|
|
575
|
+
formatDate: options.formatDate,
|
|
576
|
+
isValidContainerName: options.isValidContainerName,
|
|
577
|
+
containerExists: options.containerExists,
|
|
578
|
+
getContainerStatus: options.getContainerStatus,
|
|
579
|
+
waitForContainerReady: options.waitForContainerReady,
|
|
580
|
+
dockerExecArgs: options.dockerExecArgs,
|
|
581
|
+
showImagePullHint: options.showImagePullHint,
|
|
582
|
+
removeContainer: options.removeContainer,
|
|
583
|
+
colors: options.colors || {
|
|
584
|
+
GREEN: '',
|
|
585
|
+
CYAN: '',
|
|
586
|
+
YELLOW: '',
|
|
587
|
+
NC: ''
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
if (!ctx.authUser || !ctx.authPass) {
|
|
592
|
+
throw new Error('Web 认证配置缺失,请设置 --server-user / --server-pass');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const state = {
|
|
596
|
+
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
597
|
+
authSessions: new Map()
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
ctx.validateHostPath();
|
|
601
|
+
ensureWebHistoryDir(state.webHistoryDir);
|
|
602
|
+
|
|
603
|
+
const server = http.createServer(async (req, res) => {
|
|
604
|
+
try {
|
|
605
|
+
const url = new URL(req.url, `http://${req.headers.host || `127.0.0.1:${ctx.serverPort}`}`);
|
|
606
|
+
const pathname = url.pathname;
|
|
607
|
+
|
|
608
|
+
// 全局认证入口:除登录路由外,默认全部请求都要求认证
|
|
609
|
+
if (await handleWebAuthRoutes(req, res, pathname, ctx, state)) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const authSession = getWebAuthSession(state, req);
|
|
614
|
+
if (!authSession) {
|
|
615
|
+
sendWebUnauthorized(res, pathname);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
620
|
+
sendHtml(res, 200, loadTemplate('app.html'));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const appStaticMatch = pathname.match(/^\/app\/static\/([A-Za-z0-9._-]+)$/);
|
|
625
|
+
if (req.method === 'GET' && appStaticMatch) {
|
|
626
|
+
const assetName = appStaticMatch[1];
|
|
627
|
+
if (!(assetName === 'app.css' || assetName === 'app.js')) {
|
|
628
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
sendStaticAsset(res, assetName);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (pathname === '/healthz') {
|
|
636
|
+
sendJson(res, 200, { ok: true });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (pathname.startsWith('/api/')) {
|
|
641
|
+
const handled = await handleWebApi(req, res, pathname, ctx, state);
|
|
642
|
+
if (!handled) {
|
|
643
|
+
sendJson(res, 404, { error: 'Not Found' });
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
649
|
+
} catch (e) {
|
|
650
|
+
if ((req.url || '').startsWith('/api/')) {
|
|
651
|
+
sendJson(res, 500, { error: e.message || 'Server Error' });
|
|
652
|
+
} else {
|
|
653
|
+
sendHtml(res, 500, '<h1>500 Server Error</h1>');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
await new Promise((resolve, reject) => {
|
|
659
|
+
server.once('error', reject);
|
|
660
|
+
server.listen(ctx.serverPort, '127.0.0.1', () => {
|
|
661
|
+
const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
|
|
662
|
+
console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://127.0.0.1:${ctx.serverPort}${NC}`);
|
|
663
|
+
console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,右侧可发送命令并查看输出。${NC}`);
|
|
664
|
+
console.log(`${CYAN}🔐 登录用户名: ${YELLOW}${ctx.authUser}${NC}`);
|
|
665
|
+
if (ctx.authPassAuto) {
|
|
666
|
+
console.log(`${CYAN}🔐 登录密码(本次随机): ${YELLOW}${ctx.authPass}${NC}`);
|
|
667
|
+
} else {
|
|
668
|
+
console.log(`${CYAN}🔐 登录密码: 使用你配置的 --server-pass / serverPass / MANYOYO_SERVER_PASS${NC}`);
|
|
669
|
+
}
|
|
670
|
+
resolve();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
module.exports = {
|
|
676
|
+
startWebServer
|
|
677
|
+
};
|