返回 ViMax
server-lib.mjs
根目录 / web / server-lib.mjs
1 import {lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile} from 'node:fs/promises';
2 import path from 'node:path';
3
4 const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
5 const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov']);
6 const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.json']);
7
8 export async function readSessionState(repoRoot) {
9 const fallback = {activeSessionId: '', sessions: []};
10 try {
11 const payload = JSON.parse(await readFile(path.join(repoRoot, '.vimax', 'sessions.json'), 'utf8'));
12 const records = Object.values(payload.sessions ?? {})
13 .filter((record) => record && typeof record === 'object')
14 .map(sanitizeSession);
15 records.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
16 return {
17 activeSessionId: String(payload.active_session_id ?? ''),
18 sessions: records,
19 };
20 } catch {
21 return fallback;
22 }
23 }
24
25 export async function deleteSession(repoRoot, sessionId) {
26 assertSessionId(sessionId);
27 const statePath = path.join(repoRoot, '.vimax', 'sessions.json');
28 const payload = JSON.parse(await readFile(statePath, 'utf8'));
29 const sessions = payload.sessions && typeof payload.sessions === 'object' ? payload.sessions : {};
30 if (!sessions[sessionId]) throw new Error('Project not found');
31
32 delete sessions[sessionId];
33 const remaining = Object.values(sessions)
34 .filter((record) => record && typeof record === 'object')
35 .sort((left, right) => String(right.updated_at ?? right.created_at ?? '').localeCompare(String(left.updated_at ?? left.created_at ?? '')));
36 if (payload.active_session_id === sessionId || !sessions[payload.active_session_id]) {
37 payload.active_session_id = String(remaining[0]?.session_id ?? '');
38 }
39 payload.sessions = sessions;
40
41 const temporaryPath = `${statePath}.${process.pid}.tmp`;
42 await writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, {mode: 0o600});
43 await rename(temporaryPath, statePath);
44 await rm(resolveSessionRoot(repoRoot, sessionId), {recursive: true, force: true});
45 await removeSessionLogRecords(repoRoot, sessionId);
46 return readSessionState(repoRoot);
47 }
48
49 export async function readSessionHistory(repoRoot, sessionId) {
50 assertSessionId(sessionId);
51 const logPath = path.join(repoRoot, '.vimax', 'logs', 'loop_history.jsonl');
52 try {
53 const lines = (await readFile(logPath, 'utf8')).split(/\r?\n/).filter(Boolean);
54 const messages = [];
55 for (const line of lines) {
56 let record;
57 try {
58 record = JSON.parse(line);
59 } catch {
60 continue;
61 }
62 if (record.session_id !== sessionId || !record.raw_user_input) continue;
63 const turnId = String(record.turn_id || `turn-${messages.length}`);
64 messages.push({
65 id: `${turnId}-user`,
66 role: 'user',
67 text: displayUserInput(record.raw_user_input),
68 createdAt: String(record.created_at || record.timestamp || ''),
69 });
70 for (const round of Array.isArray(record.tool_rounds) ? record.tool_rounds : []) {
71 for (const result of Array.isArray(round.tool_results) ? round.tool_results : []) {
72 messages.push({
73 id: `${turnId}-tool-${messages.length}`,
74 role: 'activity',
75 text: historyToolResultText(result),
76 tool: String(result.name || 'tool'),
77 status: result.ok === false ? 'error' : 'done',
78 stage: result.ok === false ? 'failed' : 'completed',
79 createdAt: String(record.created_at || record.timestamp || ''),
80 });
81 }
82 }
83 if (record.final_assistant_text) {
84 messages.push({
85 id: `${turnId}-assistant`,
86 role: record.status === 'failed' ? 'error' : 'assistant',
87 text: String(record.final_assistant_text),
88 createdAt: String(record.created_at || record.timestamp || ''),
89 });
90 }
91 }
92 return messages.slice(-120);
93 } catch {
94 return [];
95 }
96 }
97
98 export async function storeWorkspaceUpload(repoRoot, sessionId, fileName, data) {
99 const sessionRoot = resolveSessionRoot(repoRoot, sessionId);
100 const sessionInfo = await stat(sessionRoot);
101 if (!sessionInfo.isDirectory()) throw new Error('Session workspace is not a directory');
102
103 const safeName = validateUploadName(fileName);
104 const uploadRoot = path.join(sessionRoot, 'uploads');
105 await mkdir(uploadRoot, {recursive: true, mode: 0o700});
106 const uploadRootInfo = await lstat(uploadRoot);
107 if (!uploadRootInfo.isDirectory() || uploadRootInfo.isSymbolicLink()) {
108 throw new Error('Workspace upload directory is not safe');
109 }
110
111 const extension = path.extname(safeName);
112 const stem = safeName.slice(0, safeName.length - extension.length);
113 const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
114 for (let attempt = 1; attempt <= 1_000; attempt += 1) {
115 const storedName = attempt === 1 ? safeName : `${stem} (${attempt})${extension}`;
116 const destination = path.join(uploadRoot, storedName);
117 try {
118 await writeFile(destination, payload, {flag: 'wx', mode: 0o600});
119 return {
120 name: storedName,
121 path: path.relative(sessionRoot, destination).split(path.sep).join('/'),
122 size: payload.byteLength,
123 };
124 } catch (error) {
125 if (error?.code === 'EEXIST') continue;
126 throw error;
127 }
128 }
129 throw new Error('Could not allocate a unique upload filename');
130 }
131
132 function historyToolResultText(result) {
133 if (result.ok !== false) return 'Completed';
134 const content = String(result.content || 'Tool failed').trim();
135 try {
136 const payload = JSON.parse(content);
137 const detail = payload?.error ?? payload?.message;
138 if (detail) return conciseText(detail);
139 } catch {
140 // The provider may return a plain-text error.
141 }
142 return conciseText(content);
143 }
144
145 function conciseText(value) {
146 const text = String(value || 'Tool failed').replace(/\s+/g, ' ').trim();
147 return text.length > 280 ? `${text.slice(0, 277)}…` : text;
148 }
149
150 function displayUserInput(value) {
151 return String(value || '')
152 .replace(/\s*<workspace_uploads>.*<\/workspace_uploads>\s*$/s, '')
153 .trim();
154 }
155
156 export async function listSessionArtifacts(repoRoot, sessionId) {
157 const sessionRoot = resolveSessionRoot(repoRoot, sessionId);
158 const artifacts = [];
159
160 async function walk(directory) {
161 let entries;
162 try {
163 entries = await readdir(directory, {withFileTypes: true});
164 } catch {
165 return;
166 }
167 for (const entry of entries) {
168 if (artifacts.length >= 400 || entry.name.startsWith('.')) continue;
169 const absolute = path.join(directory, entry.name);
170 if (entry.isDirectory()) {
171 await walk(absolute);
172 continue;
173 }
174 if (!entry.isFile()) continue;
175 const extension = path.extname(entry.name).toLowerCase();
176 const kind = IMAGE_EXTENSIONS.has(extension)
177 ? 'image'
178 : VIDEO_EXTENSIONS.has(extension)
179 ? 'video'
180 : TEXT_EXTENSIONS.has(extension)
181 ? 'document'
182 : null;
183 if (!kind) continue;
184 const info = await stat(absolute);
185 const relativePath = path.relative(sessionRoot, absolute).split(path.sep).join('/');
186 artifacts.push({
187 path: relativePath,
188 name: entry.name,
189 kind,
190 size: info.size,
191 updatedAt: info.mtime.toISOString(),
192 url: `/api/artifact?session=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(relativePath)}`,
193 });
194 }
195 }
196
197 await walk(sessionRoot);
198 return artifacts.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
199 }
200
201 export function resolveArtifactPath(repoRoot, sessionId, relativePath) {
202 const sessionRoot = resolveSessionRoot(repoRoot, sessionId);
203 const candidate = path.resolve(sessionRoot, String(relativePath || ''));
204 if (candidate === sessionRoot || !candidate.startsWith(`${sessionRoot}${path.sep}`)) {
205 throw new Error('Artifact path escapes the active session');
206 }
207 return candidate;
208 }
209
210 export function artifactContentType(filePath) {
211 const extension = path.extname(filePath).toLowerCase();
212 return {
213 '.html': 'text/html; charset=utf-8',
214 '.js': 'text/javascript; charset=utf-8',
215 '.css': 'text/css; charset=utf-8',
216 '.svg': 'image/svg+xml',
217 '.woff': 'font/woff',
218 '.woff2': 'font/woff2',
219 '.png': 'image/png',
220 '.jpg': 'image/jpeg',
221 '.jpeg': 'image/jpeg',
222 '.webp': 'image/webp',
223 '.gif': 'image/gif',
224 '.mp4': 'video/mp4',
225 '.webm': 'video/webm',
226 '.mov': 'video/quicktime',
227 '.json': 'application/json; charset=utf-8',
228 '.txt': 'text/plain; charset=utf-8',
229 '.md': 'text/markdown; charset=utf-8',
230 }[extension] ?? 'application/octet-stream';
231 }
232
233 function resolveSessionRoot(repoRoot, sessionId) {
234 assertSessionId(sessionId);
235 const workingRoot = path.resolve(repoRoot, '.working_dir');
236 const candidate = path.resolve(workingRoot, sessionId);
237 if (!candidate.startsWith(`${workingRoot}${path.sep}`)) {
238 throw new Error('Session path escapes .working_dir');
239 }
240 return candidate;
241 }
242
243 function assertSessionId(sessionId) {
244 if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,95}$/.test(String(sessionId || ''))) {
245 throw new Error('Invalid session id');
246 }
247 }
248
249 function validateUploadName(fileName) {
250 const value = String(fileName || '').normalize('NFC').trim();
251 if (!value || value === '.' || value === '..') throw new Error('A valid filename is required');
252 if (value.length > 180) throw new Error('Filename must be 180 characters or fewer');
253 if (/[\\/\u0000-\u001f\u007f]/.test(value)) throw new Error('Filename contains unsupported characters');
254 return value;
255 }
256
257 async function removeSessionLogRecords(repoRoot, sessionId) {
258 const logsRoot = path.join(repoRoot, '.vimax', 'logs');
259 let entries;
260 try {
261 entries = await readdir(logsRoot, {withFileTypes: true});
262 } catch {
263 return;
264 }
265 for (const entry of entries) {
266 if (!entry.isFile() || path.extname(entry.name) !== '.jsonl') continue;
267 const logPath = path.join(logsRoot, entry.name);
268 const lines = (await readFile(logPath, 'utf8')).split(/\r?\n/).filter(Boolean);
269 const retained = lines.filter((line) => {
270 try {
271 const record = JSON.parse(line);
272 const recordSessionId = record.session_id
273 ?? record.sessionId
274 ?? record.session?.session_id
275 ?? record.context?.session_id
276 ?? record.metadata?.session_id;
277 return recordSessionId !== sessionId;
278 } catch {
279 return true;
280 }
281 });
282 if (retained.length === lines.length) continue;
283 const temporaryPath = `${logPath}.${process.pid}.tmp`;
284 await writeFile(temporaryPath, retained.length ? `${retained.join('\n')}\n` : '', {mode: 0o600});
285 await rename(temporaryPath, logPath);
286 }
287 }
288
289 function sanitizeSession(record) {
290 return {
291 sessionId: String(record.session_id ?? ''),
292 projectName: String(record.project_name ?? ''),
293 workingDir: String(record.working_dir ?? ''),
294 stage: String(record.stage ?? 'created'),
295 summary: String(record.summary ?? ''),
296 idea: String(record.idea ?? ''),
297 updatedAt: String(record.updated_at ?? record.created_at ?? ''),
298 createdAt: String(record.created_at ?? ''),
299 compactionTurns: Number(record.compacted_turns ?? 0),
300 };
301 }
302
302 lines Plain Text