cc-viewer 1.6.18 → 1.6.19

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/server.js CHANGED
@@ -4,11 +4,12 @@ import { createConnection } from 'node:net';
4
4
  import { randomBytes } from 'node:crypto';
5
5
  import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { dirname, join, extname } from 'node:path';
7
+ import { dirname, join, extname, resolve } from 'node:path';
8
8
  import { homedir, platform, networkInterfaces } from 'node:os';
9
9
  import { execFile, exec, spawn } from 'node:child_process';
10
10
  import { promisify } from 'node:util';
11
11
  import { Worker } from 'node:worker_threads';
12
+ import { isPathContained, readFileContent, writeFileContent, resolveFilePath, ERROR_STATUS_MAP } from './lib/file-api.js';
12
13
 
13
14
  const execFileAsync = promisify(execFile);
14
15
  const execAsync = promisify(exec);
@@ -38,11 +39,14 @@ import { LOG_DIR } from './findcc.js';
38
39
  import { t, detectLanguage } from './i18n.js';
39
40
  import { checkAndUpdate } from './lib/updater.js';
40
41
  import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, PLUGINS_DIR } from './lib/plugin-loader.js';
42
+ import { uploadPlugins, installPluginFromUrl } from './lib/plugin-manager.js';
41
43
  import { getUserProfile } from './lib/user-profile.js';
42
44
  import { getGitDiffs } from './lib/git-diff.js';
43
45
  import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel } from './lib/context-watcher.js';
44
46
  import { readLogFile, watchLogFile, startWatching, getWatchedFiles } from './lib/log-watcher.js';
45
47
  import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
48
+ import { listLocalLogs, readLocalLog, deleteLogFiles, mergeLogFiles } from './lib/log-management.js';
49
+ import { detectTargetLang, translate } from './lib/translator.js';
46
50
 
47
51
  const PREFS_FILE = join(LOG_DIR, 'preferences.json');
48
52
  const isCliMode = process.env.CCV_CLI_MODE === '1';
@@ -389,29 +393,12 @@ async function handleRequest(req, res) {
389
393
  }
390
394
 
391
395
  // 确定目标语言
392
- let targetLang = to;
393
- if (!targetLang) {
394
- try {
395
- if (existsSync(PREFS_FILE)) {
396
- const prefs = JSON.parse(readFileSync(PREFS_FILE, 'utf-8'));
397
- if (prefs.lang) targetLang = prefs.lang;
398
- }
399
- } catch { }
400
- if (!targetLang) targetLang = detectLanguage();
401
- }
402
-
403
- // 源语言与目标语言相同,直接返回
404
- if (targetLang === from) {
405
- res.writeHead(200, { 'Content-Type': 'application/json' });
406
- res.end(JSON.stringify({ text, from, to: targetLang }));
407
- return;
408
- }
396
+ const targetLang = to || detectTargetLang(PREFS_FILE);
409
397
 
410
398
  // 获取 API Key(仅 x-api-key 认证,不复用 session token 避免上下文污染)
411
399
  // 优先级: 环境变量 > 拦截缓存 > 从 authHeader 中提取 sk- 开头的 key
412
400
  let apiKey = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || _cachedApiKey;
413
401
  if (!apiKey && _cachedAuthHeader) {
414
- // Bearer sk-xxx 格式:提取实际的 API key
415
402
  const m = _cachedAuthHeader.match(/^Bearer\s+(sk-\S+)$/i);
416
403
  if (m) apiKey = m[1];
417
404
  }
@@ -421,53 +408,24 @@ async function handleRequest(req, res) {
421
408
  return;
422
409
  }
423
410
 
424
- const baseUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
425
- const inputText = Array.isArray(text) ? text.join('\n---SPLIT---\n') : text;
426
-
427
- const reqHeaders = {
428
- 'Content-Type': 'application/json',
429
- 'anthropic-version': '2023-06-01',
430
- 'x-api-key': apiKey,
431
- 'x-cc-viewer-internal': '1',
432
- };
433
-
434
- const apiRes = await fetch(`${baseUrl}/v1/messages`, {
435
- method: 'POST',
436
- headers: reqHeaders,
437
- body: JSON.stringify({
438
- model: _cachedHaikuModel || 'claude-haiku-4-5-20251001',
439
- max_tokens: 32000,
440
- tools: [],
441
- system: [{
442
- type: "text",
443
- text: `You are a translator. Translate the following text from ${from} to ${targetLang}. Output only the translated text, nothing else.`
444
- }],
445
- messages: [{ role: 'user', content: inputText }],
446
- stream: false,
447
- temperature: 1,
448
- }),
411
+ const result = await translate({
412
+ text,
413
+ from,
414
+ to: targetLang,
415
+ apiKey,
416
+ baseUrl: process.env.ANTHROPIC_BASE_URL,
417
+ model: _cachedHaikuModel,
449
418
  });
450
419
 
451
- if (!apiRes.ok) {
452
- const errBody = await apiRes.text();
453
- res.writeHead(502, { 'Content-Type': 'application/json' });
454
- res.end(JSON.stringify({ error: 'Translation API failed', status: apiRes.status, detail: errBody }));
455
- return;
456
- }
457
-
458
- const apiData = await apiRes.json();
459
- let translated = apiData.content?.[0]?.text || '';
460
-
461
- // 如果输入是数组,拆分回数组
462
- if (Array.isArray(text)) {
463
- translated = translated.split(/\n?---SPLIT---\n?/);
464
- }
465
-
466
420
  res.writeHead(200, { 'Content-Type': 'application/json' });
467
- res.end(JSON.stringify({ text: translated, from, to: targetLang }));
421
+ res.end(JSON.stringify(result));
468
422
  } catch (err) {
469
- res.writeHead(500, { 'Content-Type': 'application/json' });
470
- res.end(JSON.stringify({ error: 'Internal error', message: err.message }));
423
+ const status = err.status ? 502 : 500;
424
+ const payload = err.status
425
+ ? { error: 'Translation API failed', status: err.status, detail: err.detail }
426
+ : { error: 'Internal error', message: err.message };
427
+ res.writeHead(status, { 'Content-Type': 'application/json' });
428
+ res.end(JSON.stringify(payload));
471
429
  }
472
430
  });
473
431
  return;
@@ -655,6 +613,11 @@ async function handleRequest(req, res) {
655
613
 
656
614
  clients.push(res);
657
615
 
616
+ // SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
617
+ const pingTimer = setInterval(() => {
618
+ try { res.write('event: ping\ndata: {}\n\n'); } catch {}
619
+ }, 30000);
620
+
658
621
  // 如果有待决的 resume 选择,发送 resume_prompt 事件
659
622
  if (_resumeState) {
660
623
  res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
@@ -737,6 +700,7 @@ async function handleRequest(req, res) {
737
700
  }
738
701
 
739
702
  req.on('close', () => {
703
+ clearInterval(pingTimer);
740
704
  const idx = clients.indexOf(res);
741
705
  if (idx !== -1) clients.splice(idx, 1);
742
706
  });
@@ -1055,73 +1019,16 @@ async function handleRequest(req, res) {
1055
1019
  if (url === '/api/file-content' && method === 'GET') {
1056
1020
  const reqPath = parsedUrl.searchParams.get('path');
1057
1021
  const isEditorSession = parsedUrl.searchParams.get('editorSession') === 'true';
1058
- if (!reqPath) {
1059
- res.writeHead(400, { 'Content-Type': 'application/json' });
1060
- res.end(JSON.stringify({ error: 'Invalid path' }));
1061
- return;
1062
- }
1063
- // Allow absolute paths for editor sessions or when within project directory
1064
- if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
1065
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1066
- if (reqPath.startsWith(cwd + '/')) {
1067
- // 绝对路径在项目目录内,自动转为相对路径继续处理
1068
- const relPath = reqPath.slice(cwd.length + 1);
1069
- const targetFile = join(cwd, relPath);
1070
- try {
1071
- if (!existsSync(targetFile)) {
1072
- res.writeHead(404, { 'Content-Type': 'application/json' });
1073
- res.end(JSON.stringify({ error: `File not found: ${targetFile}` }));
1074
- return;
1075
- }
1076
- const stat = statSync(targetFile);
1077
- if (!stat.isFile()) {
1078
- res.writeHead(400, { 'Content-Type': 'application/json' });
1079
- res.end(JSON.stringify({ error: 'Not a file' }));
1080
- return;
1081
- }
1082
- if (stat.size > 5 * 1024 * 1024) {
1083
- res.writeHead(413, { 'Content-Type': 'application/json' });
1084
- res.end(JSON.stringify({ error: 'File too large' }));
1085
- return;
1086
- }
1087
- const content = readFileSync(targetFile, 'utf-8');
1088
- res.writeHead(200, { 'Content-Type': 'application/json' });
1089
- res.end(JSON.stringify({ path: relPath, content, size: stat.size }));
1090
- } catch (err) {
1091
- res.writeHead(500, { 'Content-Type': 'application/json' });
1092
- res.end(JSON.stringify({ error: `Cannot read file: ${err.message}` }));
1093
- }
1094
- return;
1095
- }
1096
- res.writeHead(400, { 'Content-Type': 'application/json' });
1097
- res.end(JSON.stringify({ error: 'Invalid path' }));
1098
- return;
1099
- }
1100
- const targetFile = isEditorSession && reqPath.startsWith('/') ? reqPath : join(process.env.CCV_PROJECT_DIR || process.cwd(), reqPath);
1022
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1101
1023
  try {
1102
- if (!existsSync(targetFile)) {
1103
- res.writeHead(404, { 'Content-Type': 'application/json' });
1104
- res.end(JSON.stringify({ error: `File not found: ${targetFile}` }));
1105
- return;
1106
- }
1107
- const stat = statSync(targetFile);
1108
- if (!stat.isFile()) {
1109
- res.writeHead(400, { 'Content-Type': 'application/json' });
1110
- res.end(JSON.stringify({ error: 'Not a file' }));
1111
- return;
1112
- }
1113
- // 限制文件大小 5MB
1114
- if (stat.size > 5 * 1024 * 1024) {
1115
- res.writeHead(413, { 'Content-Type': 'application/json' });
1116
- res.end(JSON.stringify({ error: 'File too large' }));
1117
- return;
1118
- }
1119
- const content = readFileSync(targetFile, 'utf-8');
1024
+ const result = readFileContent(cwd, reqPath, isEditorSession);
1120
1025
  res.writeHead(200, { 'Content-Type': 'application/json' });
1121
- res.end(JSON.stringify({ path: reqPath, content, size: stat.size }));
1026
+ res.end(JSON.stringify(result));
1122
1027
  } catch (err) {
1123
- res.writeHead(500, { 'Content-Type': 'application/json' });
1124
- res.end(JSON.stringify({ error: `Cannot read file: ${err.message}` }));
1028
+ const status = ERROR_STATUS_MAP[err.code] || 500;
1029
+ const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
1030
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({ error: message }));
1125
1032
  }
1126
1033
  return;
1127
1034
  }
@@ -1130,22 +1037,9 @@ async function handleRequest(req, res) {
1130
1037
  if (url === '/api/file-raw' && (method === 'GET' || method === 'HEAD')) {
1131
1038
  const reqPath = parsedUrl.searchParams.get('path');
1132
1039
  const isEditorSession = parsedUrl.searchParams.get('editorSession') === 'true';
1133
- if (!reqPath) {
1134
- res.writeHead(400, { 'Content-Type': 'application/json' });
1135
- res.end(JSON.stringify({ error: 'Invalid path' }));
1136
- return;
1137
- }
1138
- if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
1139
- // 允许项目目录内的绝对路径
1140
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1141
- if (!reqPath.startsWith(cwd + '/')) {
1142
- res.writeHead(400, { 'Content-Type': 'application/json' });
1143
- res.end(JSON.stringify({ error: 'Invalid path' }));
1144
- return;
1145
- }
1146
- }
1147
- const targetFile = (isEditorSession || reqPath.startsWith('/')) ? reqPath : join(process.env.CCV_PROJECT_DIR || process.cwd(), reqPath);
1040
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1148
1041
  try {
1042
+ const targetFile = resolveFilePath(cwd, reqPath, isEditorSession);
1149
1043
  if (!existsSync(targetFile)) {
1150
1044
  res.writeHead(404, { 'Content-Type': 'application/json' });
1151
1045
  res.end(JSON.stringify({ error: `File not found: ${targetFile}` }));
@@ -1174,8 +1068,10 @@ async function handleRequest(req, res) {
1174
1068
  res.writeHead(200, { 'Content-Type': mime, 'Content-Length': size });
1175
1069
  res.end(data);
1176
1070
  } catch (err) {
1177
- res.writeHead(500, { 'Content-Type': 'application/json' });
1178
- res.end(JSON.stringify({ error: `Cannot read file: ${err.message}` }));
1071
+ const status = ERROR_STATUS_MAP[err.code] || 500;
1072
+ const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
1073
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1074
+ res.end(JSON.stringify({ error: message }));
1179
1075
  }
1180
1076
  return;
1181
1077
  }
@@ -1197,30 +1093,15 @@ async function handleRequest(req, res) {
1197
1093
  }
1198
1094
  try {
1199
1095
  const { path: reqPath, content, editorSession } = JSON.parse(body);
1200
- if (!reqPath) {
1201
- res.writeHead(400, { 'Content-Type': 'application/json' });
1202
- res.end(JSON.stringify({ error: 'Invalid path' }));
1203
- return;
1204
- }
1205
- // Allow absolute paths only for editor sessions
1206
- if (!editorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
1207
- res.writeHead(400, { 'Content-Type': 'application/json' });
1208
- res.end(JSON.stringify({ error: 'Invalid path' }));
1209
- return;
1210
- }
1211
- if (typeof content !== 'string') {
1212
- res.writeHead(400, { 'Content-Type': 'application/json' });
1213
- res.end(JSON.stringify({ error: 'Content must be a string' }));
1214
- return;
1215
- }
1216
- const targetFile = editorSession && reqPath.startsWith('/') ? reqPath : join(process.env.CCV_PROJECT_DIR || process.cwd(), reqPath);
1217
- writeFileSync(targetFile, content, 'utf-8');
1218
- const stat = statSync(targetFile);
1096
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1097
+ const result = writeFileContent(cwd, reqPath, content, editorSession);
1219
1098
  res.writeHead(200, { 'Content-Type': 'application/json' });
1220
- res.end(JSON.stringify({ ok: true, size: stat.size }));
1099
+ res.end(JSON.stringify({ ok: true, size: result.size }));
1221
1100
  } catch (err) {
1222
- res.writeHead(500, { 'Content-Type': 'application/json' });
1223
- res.end(JSON.stringify({ error: `Cannot save file: ${err.message}` }));
1101
+ const status = ERROR_STATUS_MAP[err.code] || 500;
1102
+ const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
1103
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1104
+ res.end(JSON.stringify({ error: message }));
1224
1105
  }
1225
1106
  });
1226
1107
  return;
@@ -1330,36 +1211,14 @@ async function handleRequest(req, res) {
1330
1211
  req.on('end', async () => {
1331
1212
  try {
1332
1213
  const { files: fileList } = JSON.parse(body);
1333
- if (!Array.isArray(fileList) || fileList.length === 0) {
1334
- res.writeHead(400, { 'Content-Type': 'application/json' });
1335
- res.end(JSON.stringify({ error: 'No files provided' }));
1336
- return;
1337
- }
1338
- // 确保插件目录存在
1339
- if (!existsSync(PLUGINS_DIR)) {
1340
- mkdirSync(PLUGINS_DIR, { recursive: true });
1341
- }
1342
- for (const { name, content } of fileList) {
1343
- if (!name || typeof content !== 'string') continue;
1344
- const filename = name.replace(/.*[/\\]/, '');
1345
- if (!filename.endsWith('.js') && !filename.endsWith('.mjs')) {
1346
- res.writeHead(400, { 'Content-Type': 'application/json' });
1347
- res.end(JSON.stringify({ error: 'Only .js or .mjs files are allowed' }));
1348
- return;
1349
- }
1350
- if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
1351
- res.writeHead(400, { 'Content-Type': 'application/json' });
1352
- res.end(JSON.stringify({ error: 'Invalid file name' }));
1353
- return;
1354
- }
1355
- writeFileSync(join(PLUGINS_DIR, filename), content, 'utf-8');
1356
- }
1214
+ uploadPlugins(PLUGINS_DIR, fileList);
1357
1215
  await loadPlugins();
1358
1216
  const plugins = getPluginsInfo();
1359
1217
  res.writeHead(200, { 'Content-Type': 'application/json' });
1360
1218
  res.end(JSON.stringify({ ok: true, plugins, pluginsDir: PLUGINS_DIR }));
1361
1219
  } catch (err) {
1362
- res.writeHead(500, { 'Content-Type': 'application/json' });
1220
+ const status = err.statusCode || 500;
1221
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1363
1222
  res.end(JSON.stringify({ error: err.message }));
1364
1223
  }
1365
1224
  });
@@ -1372,90 +1231,15 @@ async function handleRequest(req, res) {
1372
1231
  req.on('end', async () => {
1373
1232
  try {
1374
1233
  const { url: fileUrl } = JSON.parse(body);
1375
- if (!fileUrl) {
1376
- res.writeHead(400, { 'Content-Type': 'application/json' });
1377
- res.end(JSON.stringify({ error: 'URL is required' }));
1378
- return;
1379
- }
1380
- // 验证 URL 格式
1381
- let parsedUrl;
1382
- try {
1383
- parsedUrl = new URL(fileUrl);
1384
- } catch {
1385
- res.writeHead(400, { 'Content-Type': 'application/json' });
1386
- res.end(JSON.stringify({ error: 'Invalid URL' }));
1387
- return;
1388
- }
1389
- if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
1390
- res.writeHead(400, { 'Content-Type': 'application/json' });
1391
- res.end(JSON.stringify({ error: 'Invalid URL' }));
1392
- return;
1393
- }
1394
- // 下载远程文件(限制 5MB,超时 30s)
1395
- const MAX_PLUGIN_SIZE = 5 * 1024 * 1024;
1396
- let content;
1397
- try {
1398
- const resp = await fetch(fileUrl, { signal: AbortSignal.timeout(30000) });
1399
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
1400
- const text = await resp.text();
1401
- if (text.length > MAX_PLUGIN_SIZE) throw new Error('File too large (max 5MB)');
1402
- content = text;
1403
- } catch (fetchErr) {
1404
- res.writeHead(500, { 'Content-Type': 'application/json' });
1405
- res.end(JSON.stringify({ error: 'Failed to fetch: ' + fetchErr.message }));
1406
- return;
1407
- }
1408
- // 通过子进程 import() 提取插件内部 name
1409
- let saveName = '';
1410
- const { tmpdir } = await import('node:os');
1411
- const tmpFile = join(tmpdir(), `ccv-install-${Date.now()}.mjs`);
1412
- writeFileSync(tmpFile, content, 'utf-8');
1413
- try {
1414
- const extractScript = join(__dirname, 'lib', 'extract-plugin-name.mjs');
1415
- const result = await new Promise((resolve, reject) => {
1416
- execFile('node', [extractScript, tmpFile], { timeout: 5000 }, (err, stdout) => {
1417
- if (err) return reject(err);
1418
- resolve(stdout);
1419
- });
1420
- });
1421
- const parsed = JSON.parse(result);
1422
- if (parsed.name) saveName = parsed.name;
1423
- } catch { }
1424
- try { unlinkSync(tmpFile); } catch { }
1425
- // fallback:从 URL 路径提取文件名,排除通用名称
1426
- if (!saveName) {
1427
- const urlFilename = parsedUrl.pathname.split('/').pop();
1428
- if (urlFilename && (urlFilename.endsWith('.js') || urlFilename.endsWith('.mjs'))
1429
- && urlFilename !== 'index.js' && urlFilename !== 'index.mjs') {
1430
- saveName = urlFilename.replace(/\.(js|mjs)$/, '');
1431
- }
1432
- }
1433
- // 最终 fallback:使用 plugin-<timestamp>
1434
- if (!saveName) {
1435
- saveName = `plugin-${Date.now()}`;
1436
- }
1437
- let filename = (saveName.endsWith('.js') || saveName.endsWith('.mjs')) ? saveName : saveName + '.js';
1438
- // 安全校验
1439
- if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
1440
- filename = `plugin-${Date.now()}.js`;
1441
- }
1442
- // 确保插件目录存在
1443
- if (!existsSync(PLUGINS_DIR)) {
1444
- mkdirSync(PLUGINS_DIR, { recursive: true });
1445
- }
1446
- // 同名文件去重:追加唯一标识
1447
- if (existsSync(join(PLUGINS_DIR, filename))) {
1448
- const ext = filename.endsWith('.mjs') ? '.mjs' : '.js';
1449
- const base = filename.slice(0, -ext.length);
1450
- filename = `${base}-${Date.now()}${ext}`;
1451
- }
1452
- writeFileSync(join(PLUGINS_DIR, filename), content, 'utf-8');
1234
+ const extractScript = join(__dirname, 'lib', 'extract-plugin-name.mjs');
1235
+ await installPluginFromUrl(PLUGINS_DIR, fileUrl, extractScript);
1453
1236
  await loadPlugins();
1454
1237
  const plugins = getPluginsInfo();
1455
1238
  res.writeHead(200, { 'Content-Type': 'application/json' });
1456
1239
  res.end(JSON.stringify({ ok: true, plugins, pluginsDir: PLUGINS_DIR }));
1457
1240
  } catch (err) {
1458
- res.writeHead(500, { 'Content-Type': 'application/json' });
1241
+ const status = err.statusCode || 500;
1242
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1459
1243
  res.end(JSON.stringify({ error: err.message }));
1460
1244
  }
1461
1245
  });
@@ -1475,40 +1259,9 @@ async function handleRequest(req, res) {
1475
1259
  // 列出本地日志文件(按项目分组,遍历项目子目录)
1476
1260
  if (url === '/api/local-logs' && method === 'GET') {
1477
1261
  try {
1478
- const grouped = {};
1479
- if (existsSync(LOG_DIR)) {
1480
- const entries = readdirSync(LOG_DIR, { withFileTypes: true });
1481
- for (const entry of entries) {
1482
- if (!entry.isDirectory()) continue;
1483
- const project = entry.name;
1484
- const projectDir = join(LOG_DIR, project);
1485
- const files = readdirSync(projectDir)
1486
- .filter(f => f.endsWith('.jsonl'))
1487
- .sort()
1488
- .reverse();
1489
- // 从项目统计缓存中读取 per-file 数据,避免逐文件扫描
1490
- let statsFiles = null;
1491
- try {
1492
- const statsFile = join(projectDir, `${project}.json`);
1493
- if (existsSync(statsFile)) {
1494
- statsFiles = JSON.parse(readFileSync(statsFile, 'utf-8')).files;
1495
- }
1496
- } catch { }
1497
- for (const f of files) {
1498
- const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl$/);
1499
- if (!match) continue;
1500
- const ts = match[2];
1501
- const filePath = join(projectDir, f);
1502
- const size = statSync(filePath).size;
1503
- if (size === 0) continue; // 跳过空文件
1504
- const turns = statsFiles?.[f]?.summary?.sessionCount || 0;
1505
- if (!grouped[project]) grouped[project] = [];
1506
- grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: statsFiles?.[f]?.preview || [] });
1507
- }
1508
- }
1509
- }
1262
+ const result = listLocalLogs(LOG_DIR, _projectName);
1510
1263
  res.writeHead(200, { 'Content-Type': 'application/json' });
1511
- res.end(JSON.stringify({ ...grouped, _currentProject: _projectName || '' }));
1264
+ res.end(JSON.stringify(result));
1512
1265
  } catch (err) {
1513
1266
  res.writeHead(500, { 'Content-Type': 'application/json' });
1514
1267
  res.end(JSON.stringify({ error: err.message }));
@@ -1575,31 +1328,13 @@ async function handleRequest(req, res) {
1575
1328
  return;
1576
1329
  }
1577
1330
 
1578
- const filePath = join(LOG_DIR, file);
1579
1331
  try {
1580
- if (!existsSync(filePath)) {
1581
- res.writeHead(404, { 'Content-Type': 'application/json' });
1582
- res.end(JSON.stringify({ error: 'File not found' }));
1583
- return;
1584
- }
1585
-
1586
- // 验证文件确实在 LOG_DIR 内(防止路径穿越)
1587
- const realPath = realpathSync(filePath);
1588
- const realLogDir = realpathSync(LOG_DIR);
1589
- if (!realPath.startsWith(realLogDir)) {
1590
- res.writeHead(403, { 'Content-Type': 'application/json' });
1591
- res.end(JSON.stringify({ error: 'Access denied' }));
1592
- return;
1593
- }
1594
-
1595
- const content = readFileSync(filePath, 'utf-8');
1596
- const entries = content.split('\n---\n').filter(line => line.trim()).map(entry => {
1597
- try { return JSON.parse(entry); } catch { return null; }
1598
- }).filter(Boolean);
1332
+ const entries = readLocalLog(LOG_DIR, file);
1599
1333
  res.writeHead(200, { 'Content-Type': 'application/json' });
1600
1334
  res.end(JSON.stringify(entries));
1601
1335
  } catch (err) {
1602
- res.writeHead(500, { 'Content-Type': 'application/json' });
1336
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
1337
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1603
1338
  res.end(JSON.stringify({ error: err.message }));
1604
1339
  }
1605
1340
  return;
@@ -1617,30 +1352,7 @@ async function handleRequest(req, res) {
1617
1352
  res.end(JSON.stringify({ error: 'No files specified' }));
1618
1353
  return;
1619
1354
  }
1620
- const results = [];
1621
- for (const file of files) {
1622
- if (!file || file.includes('..') || !file.endsWith('.jsonl')) {
1623
- results.push({ file, error: 'Invalid file name' });
1624
- continue;
1625
- }
1626
- const filePath = join(LOG_DIR, file);
1627
- try {
1628
- if (!existsSync(filePath)) {
1629
- results.push({ file, error: 'Not found' });
1630
- continue;
1631
- }
1632
- const realPath = realpathSync(filePath);
1633
- const realLogDir = realpathSync(LOG_DIR);
1634
- if (!realPath.startsWith(realLogDir)) {
1635
- results.push({ file, error: 'Access denied' });
1636
- continue;
1637
- }
1638
- unlinkSync(realPath);
1639
- results.push({ file, ok: true });
1640
- } catch (err) {
1641
- results.push({ file, error: err.message });
1642
- }
1643
- }
1355
+ const results = deleteLogFiles(LOG_DIR, files);
1644
1356
  res.writeHead(200, { 'Content-Type': 'application/json' });
1645
1357
  res.end(JSON.stringify({ results }));
1646
1358
  } catch {
@@ -1658,55 +1370,12 @@ async function handleRequest(req, res) {
1658
1370
  req.on('end', () => {
1659
1371
  try {
1660
1372
  const { files } = JSON.parse(body);
1661
- if (!Array.isArray(files) || files.length < 2) {
1662
- res.writeHead(400, { 'Content-Type': 'application/json' });
1663
- res.end(JSON.stringify({ error: 'At least 2 files required' }));
1664
- return;
1665
- }
1666
- // 校验所有文件属于同一 project
1667
- const projects = new Set(files.map(f => f.split('/')[0]));
1668
- if (projects.size !== 1) {
1669
- res.writeHead(400, { 'Content-Type': 'application/json' });
1670
- res.end(JSON.stringify({ error: 'All files must belong to the same project' }));
1671
- return;
1672
- }
1673
- // 校验文件存在且无路径穿越
1674
- for (const f of files) {
1675
- if (f.includes('..')) {
1676
- res.writeHead(400, { 'Content-Type': 'application/json' });
1677
- res.end(JSON.stringify({ error: 'Invalid file path' }));
1678
- return;
1679
- }
1680
- if (!existsSync(join(LOG_DIR, f))) {
1681
- res.writeHead(404, { 'Content-Type': 'application/json' });
1682
- res.end(JSON.stringify({ error: `File not found: ${f}` }));
1683
- return;
1684
- }
1685
- }
1686
- // files 已按时间正序传入,校验合并后总大小不超过 300MB
1687
- const MAX_MERGE_SIZE = 300 * 1024 * 1024;
1688
- let totalSize = 0;
1689
- for (const f of files) {
1690
- totalSize += statSync(join(LOG_DIR, f)).size;
1691
- }
1692
- if (totalSize > MAX_MERGE_SIZE) {
1693
- res.writeHead(400, { 'Content-Type': 'application/json' });
1694
- res.end(JSON.stringify({ error: `Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds 300MB limit` }));
1695
- return;
1696
- }
1697
- // 合并内容写入第一个文件
1698
- const targetFile = files[0];
1699
- const targetPath = join(LOG_DIR, targetFile);
1700
- const contents = files.map(f => readFileSync(join(LOG_DIR, f), 'utf-8').trimEnd());
1701
- writeFileSync(targetPath, contents.join('\n---\n') + '\n');
1702
- // 删除其余文件
1703
- for (let i = 1; i < files.length; i++) {
1704
- unlinkSync(join(LOG_DIR, files[i]));
1705
- }
1373
+ const merged = mergeLogFiles(LOG_DIR, files);
1706
1374
  res.writeHead(200, { 'Content-Type': 'application/json' });
1707
- res.end(JSON.stringify({ ok: true, merged: targetFile }));
1375
+ res.end(JSON.stringify({ ok: true, merged }));
1708
1376
  } catch (err) {
1709
- res.writeHead(500, { 'Content-Type': 'application/json' });
1377
+ const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'INVALID_INPUT' ? 400 : 500;
1378
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1710
1379
  res.end(JSON.stringify({ error: err.message }));
1711
1380
  }
1712
1381
  });