返回 ViMax
server.mjs
根目录 / web / server.mjs
1 import {createReadStream, existsSync} from 'node:fs';
2 import {readFile} from 'node:fs/promises';
3 import {createServer} from 'node:http';
4 import path from 'node:path';
5 import {spawn} from 'node:child_process';
6 import {fileURLToPath} from 'node:url';
7 import {readAgentConfig, saveAgentConfig} from './config-store.mjs';
8 import {
9 artifactContentType,
10 deleteSession,
11 listSessionArtifacts,
12 readSessionHistory,
13 readSessionState,
14 resolveArtifactPath,
15 storeWorkspaceUpload,
16 } from './server-lib.mjs';
17
18 const webRoot = path.dirname(fileURLToPath(import.meta.url));
19 const repoRoot = path.resolve(webRoot, '..');
20 const isDev = process.argv.includes('--dev');
21 const host = process.env.VIMAX_WEB_HOST || '127.0.0.1';
22 const port = Number(process.env.VIMAX_WEB_PORT || 4173);
23 const configuredUploadLimit = Number(process.env.VIMAX_WEB_UPLOAD_MAX_BYTES || 100 * 1024 * 1024);
24 const uploadMaxBytes = Number.isFinite(configuredUploadLimit) && configuredUploadLimit > 0
25 ? configuredUploadLimit
26 : 100 * 1024 * 1024;
27 const subscribers = new Set();
28 let agentProcess = null;
29 let activeSessionId = '';
30
31 let vite = null;
32
33 const server = createServer(async (request, response) => {
34 const url = new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`);
35 try {
36 if (url.pathname === '/api/events' && request.method === 'GET') {
37 return openEventStream(request, response);
38 }
39 if (url.pathname === '/api/sessions' && request.method === 'GET') {
40 return sendJson(response, 200, await readSessionState(repoRoot));
41 }
42 if (url.pathname === '/api/config' && request.method === 'GET') {
43 return sendJson(response, 200, await readAgentConfig(repoRoot));
44 }
45 if (url.pathname === '/api/config' && request.method === 'PUT') {
46 const config = await saveAgentConfig(repoRoot, await readJsonBody(request));
47 stopAgent('config');
48 return sendJson(response, 200, config);
49 }
50 if (url.pathname === '/api/sessions' && request.method === 'DELETE') {
51 const sessionId = url.searchParams.get('session') || '';
52 const current = await readSessionState(repoRoot);
53 if (!current.sessions.some((session) => session.sessionId === sessionId)) {
54 return sendJson(response, 404, {error: 'Project not found'});
55 }
56 if (sessionId === activeSessionId) stopAgent('delete');
57 const state = await deleteSession(repoRoot, sessionId);
58 activeSessionId = state.activeSessionId;
59 broadcast({type: 'sessions_changed', ...state});
60 return sendJson(response, 200, state);
61 }
62 if (url.pathname === '/api/history' && request.method === 'GET') {
63 return sendJson(response, 200, {messages: await readSessionHistory(repoRoot, url.searchParams.get('session') || '')});
64 }
65 if (url.pathname === '/api/artifacts' && request.method === 'GET') {
66 return sendJson(response, 200, {artifacts: await listSessionArtifacts(repoRoot, url.searchParams.get('session') || '')});
67 }
68 if (url.pathname === '/api/artifact' && request.method === 'GET') {
69 return streamArtifact(response, url.searchParams.get('session') || '', url.searchParams.get('path') || '');
70 }
71 if (url.pathname === '/api/uploads' && request.method === 'POST') {
72 const sessionId = url.searchParams.get('session') || '';
73 const fileName = url.searchParams.get('name') || '';
74 const current = await readSessionState(repoRoot);
75 if (!current.sessions.some((session) => session.sessionId === sessionId)) {
76 return sendJson(response, 404, {error: 'Project not found'});
77 }
78 const declaredSize = Number(request.headers['content-length'] || 0);
79 if (declaredSize > uploadMaxBytes) {
80 return sendJson(response, 413, {error: `File exceeds the ${formatByteLimit(uploadMaxBytes)} upload limit`});
81 }
82 const data = await readBinaryBody(request, uploadMaxBytes);
83 const file = await storeWorkspaceUpload(repoRoot, sessionId, fileName, data);
84 return sendJson(response, 201, {file});
85 }
86 if (url.pathname === '/api/agent/start' && request.method === 'POST') {
87 const body = await readJsonBody(request);
88 const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
89 const projectName = typeof body.projectName === 'string' ? body.projectName.trim() : '';
90 if (projectName.length > 64) {
91 return sendJson(response, 400, {error: 'Project name must be 64 characters or fewer'});
92 }
93 await startAgent({newSession: body.newSession === true, sessionId, projectName});
94 return sendJson(response, 200, {ok: true});
95 }
96 if (url.pathname === '/api/messages' && request.method === 'POST') {
97 const body = await readJsonBody(request);
98 const text = String(body.text || '').trim();
99 if (!text) return sendJson(response, 400, {error: 'Message text is required'});
100 if (!agentProcess?.stdin.writable) return sendJson(response, 409, {error: 'Agent is not running'});
101 agentProcess.stdin.write(`${text}\n`);
102 return sendJson(response, 202, {ok: true});
103 }
104 if (url.pathname === '/api/agent/stop' && request.method === 'POST') {
105 stopAgent('user');
106 return sendJson(response, 200, {ok: true});
107 }
108 if (url.pathname === '/api/health' && request.method === 'GET') {
109 return sendJson(response, 200, {ok: true, agentRunning: Boolean(agentProcess), activeSessionId});
110 }
111 if (url.pathname === '/assets/vimax.png' && request.method === 'GET') {
112 response.writeHead(200, {'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600'});
113 createReadStream(path.join(repoRoot, 'assets', 'vimax.png')).pipe(response);
114 return;
115 }
116 if (vite) {
117 vite.middlewares(request, response, () => sendJson(response, 404, {error: 'Not found'}));
118 return;
119 }
120 return serveProductionApp(response, url.pathname);
121 } catch (error) {
122 const status = Number(error?.statusCode) || 500;
123 sendJson(response, status, {error: error instanceof Error ? error.message : String(error)});
124 }
125 });
126
127 if (isDev) {
128 vite = await (await import('vite')).createServer({
129 root: webRoot,
130 server: {middlewareMode: true, hmr: {server}},
131 appType: 'spa',
132 });
133 }
134
135 server.listen(port, host, () => {
136 console.log(`ViMax Web: http://${host}:${port}`);
137 });
138
139 process.on('SIGINT', shutdown);
140 process.on('SIGTERM', shutdown);
141
142 async function startAgent({newSession, sessionId, projectName = ''}) {
143 if (newSession && sessionId) throw new Error('Choose either a new or existing session');
144 stopAgent('switch');
145 const {command, args} = agentCommand();
146 const sessionArgs = newSession
147 ? ['--new-session', ...(projectName ? ['--new-session-name', projectName] : [])]
148 : sessionId
149 ? ['--session', sessionId]
150 : [];
151 activeSessionId = sessionId;
152 const child = spawn(command, [...args, 'main_agent.py', '--jsonl', '--stdin-repl', ...sessionArgs], {
153 cwd: repoRoot,
154 env: process.env,
155 stdio: ['pipe', 'pipe', 'pipe'],
156 });
157 agentProcess = child;
158 let childStdoutBuffer = '';
159 broadcast({type: 'bridge_status', status: 'starting', message: newSession ? 'Creating workspace' : 'Opening workspace'});
160 child.stdout.setEncoding('utf8');
161 child.stdout.on('data', (chunk) => {
162 if (agentProcess !== child) return;
163 childStdoutBuffer += String(chunk);
164 const lines = childStdoutBuffer.split(/\r?\n/);
165 childStdoutBuffer = lines.pop() || '';
166 for (const line of lines) consumeAgentLine(line);
167 });
168 child.stderr.setEncoding('utf8');
169 child.stderr.on('data', (chunk) => {
170 if (agentProcess !== child) return;
171 for (const line of String(chunk).split(/\r?\n/)) {
172 if (line.trim()) broadcast({type: 'terminal', stream: 'stderr', line});
173 }
174 });
175 child.on('error', (error) => {
176 if (agentProcess !== child) return;
177 broadcast({type: 'error', message: `Agent process error: ${error.message}`});
178 });
179 child.on('exit', (code, signal) => {
180 if (agentProcess !== child) return;
181 agentProcess = null;
182 broadcast({
183 type: 'bridge_status',
184 status: code === 0 || signal === 'SIGTERM' ? 'stopped' : 'error',
185 message: signal ? `Agent stopped by ${signal}` : `Agent exited with code ${code ?? 0}`,
186 });
187 });
188 setTimeout(async () => {
189 if (agentProcess !== child) return;
190 const state = await readSessionState(repoRoot);
191 activeSessionId = state.activeSessionId || sessionId || activeSessionId;
192 broadcast({type: 'sessions_changed', ...state, activeSessionId});
193 broadcast({type: 'bridge_status', status: 'ready', message: 'Agent ready'});
194 }, 350);
195 }
196
197 function consumeAgentLine(line) {
198 if (!line.trim()) return;
199 try {
200 const event = JSON.parse(line);
201 if (event.type === 'session') activeSessionId = event.session?.active_session_id || activeSessionId;
202 broadcast(event);
203 if (event.type === 'session') {
204 readSessionState(repoRoot).then((state) => broadcast({type: 'sessions_changed', ...state}));
205 }
206 } catch {
207 broadcast({type: 'terminal', stream: 'stdout', line});
208 }
209 }
210
211 function openEventStream(request, response) {
212 response.writeHead(200, {
213 'Content-Type': 'text/event-stream',
214 'Cache-Control': 'no-cache, no-transform',
215 Connection: 'keep-alive',
216 'X-Accel-Buffering': 'no',
217 });
218 response.write(`data: ${JSON.stringify({type: 'bridge_status', status: agentProcess ? 'ready' : 'idle', message: agentProcess ? 'Agent connected' : 'Agent idle'})}\n\n`);
219 subscribers.add(response);
220 const heartbeat = setInterval(() => response.write(': keepalive\n\n'), 15_000);
221 request.on('close', () => {
222 clearInterval(heartbeat);
223 subscribers.delete(response);
224 });
225 }
226
227 function broadcast(event) {
228 const payload = `data: ${JSON.stringify(event)}\n\n`;
229 for (const subscriber of subscribers) subscriber.write(payload);
230 }
231
232 function stopAgent(reason) {
233 if (!agentProcess) return;
234 const child = agentProcess;
235 agentProcess = null;
236 child.kill('SIGTERM');
237 const message = reason === 'switch'
238 ? 'Switching workspace'
239 : reason === 'config'
240 ? 'Configuration updated'
241 : 'Generation stopped';
242 broadcast({type: 'bridge_status', status: 'stopped', message});
243 }
244
245 function agentCommand() {
246 if (process.env.VIMAX_AGENT_COMMAND) {
247 return {command: process.env.VIMAX_AGENT_COMMAND, args: splitArgs(process.env.VIMAX_AGENT_ARGS || '')};
248 }
249 const configuredPython = process.env.VIMAX_PYTHON_CMD;
250 if (configuredPython) return {command: configuredPython, args: []};
251 const bundledUv = process.env.VIMAX_UV_CMD || path.join(process.env.HOME || '', '.local', 'bin', 'uv');
252 if (bundledUv && existsSync(bundledUv)) return {command: bundledUv, args: ['run', 'python']};
253 const venvPython = path.join(repoRoot, '.venv', 'bin', 'python3');
254 if (existsSync(venvPython)) return {command: venvPython, args: []};
255 return {command: 'uv', args: ['run', 'python']};
256 }
257
258 function splitArgs(value) {
259 return value.split(/\s+/).map((part) => part.trim()).filter(Boolean);
260 }
261
262 async function readJsonBody(request) {
263 const chunks = [];
264 for await (const chunk of request) chunks.push(chunk);
265 if (!chunks.length) return {};
266 const text = Buffer.concat(chunks).toString('utf8');
267 if (text.length > 1_000_000) throw new Error('Request body is too large');
268 return JSON.parse(text);
269 }
270
271 async function readBinaryBody(request, maxBytes) {
272 const chunks = [];
273 let size = 0;
274 for await (const chunk of request) {
275 size += chunk.length;
276 if (size > maxBytes) {
277 const error = new Error(`File exceeds the ${formatByteLimit(maxBytes)} upload limit`);
278 error.statusCode = 413;
279 throw error;
280 }
281 chunks.push(chunk);
282 }
283 return Buffer.concat(chunks, size);
284 }
285
286 function formatByteLimit(bytes) {
287 return `${Math.max(1, Math.round(bytes / (1024 * 1024)))} MB`;
288 }
289
290 function sendJson(response, status, payload) {
291 if (response.writableEnded) return;
292 response.writeHead(status, {'Content-Type': 'application/json; charset=utf-8'});
293 response.end(JSON.stringify(payload));
294 }
295
296 async function streamArtifact(response, sessionId, relativePath) {
297 const filePath = resolveArtifactPath(repoRoot, sessionId, relativePath);
298 if (!existsSync(filePath)) return sendJson(response, 404, {error: 'Artifact not found'});
299 response.writeHead(200, {
300 'Content-Type': artifactContentType(filePath),
301 'Cache-Control': 'private, max-age=60',
302 });
303 createReadStream(filePath).pipe(response);
304 }
305
306 async function serveProductionApp(response, pathname) {
307 const requested = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
308 const candidate = path.resolve(webRoot, 'dist', requested);
309 const distRoot = path.resolve(webRoot, 'dist');
310 const safeCandidate = candidate.startsWith(`${distRoot}${path.sep}`) ? candidate : path.join(distRoot, 'index.html');
311 const filePath = existsSync(safeCandidate) ? safeCandidate : path.join(distRoot, 'index.html');
312 const body = await readFile(filePath);
313 response.writeHead(200, {'Content-Type': artifactContentType(filePath)});
314 response.end(body);
315 }
316
317 function shutdown() {
318 stopAgent('shutdown');
319 server.close(() => process.exit(0));
320 setTimeout(() => process.exit(0), 1_000).unref();
321 }
322
322 lines Plain Text