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/dist/assets/{index-_YY_bV5h.css → index-B0PFQOGX.css} +2 -2
- package/dist/assets/{index-7Mvpu6NN.js → index-BU4Xu0xM.js} +78 -78
- package/dist/index.html +2 -2
- package/i18n.js +1 -0
- package/lib/file-api.js +128 -0
- package/lib/log-management.js +173 -0
- package/lib/plugin-manager.js +118 -0
- package/lib/translator.js +84 -0
- package/package.json +1 -1
- package/server.js +63 -394
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
|
-
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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(
|
|
421
|
+
res.end(JSON.stringify(result));
|
|
468
422
|
} catch (err) {
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1026
|
+
res.end(JSON.stringify(result));
|
|
1122
1027
|
} catch (err) {
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
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:
|
|
1099
|
+
res.end(JSON.stringify({ ok: true, size: result.size }));
|
|
1221
1100
|
} catch (err) {
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1375
|
+
res.end(JSON.stringify({ ok: true, merged }));
|
|
1708
1376
|
} catch (err) {
|
|
1709
|
-
|
|
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
|
});
|