cc-viewer 1.5.3 → 1.5.5

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/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-BTCSaxml.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-CWx2rxKd.css">
9
+ <script type="module" crossorigin src="/assets/index-J-HTEXmo.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-48freCdh.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/i18n.js CHANGED
@@ -567,24 +567,24 @@ const i18nData = {
567
567
  "uk": "Імпорт локальних логів"
568
568
  },
569
569
  "ui.loadLocalJsonl": {
570
- "zh": "直接选择加载本地jsonl文件",
571
- "en": "Load local JSONL file",
572
- "zh-TW": "直接選擇載入本地jsonl檔案",
573
- "ko": "로컬 JSONL 파일 선택",
574
- "ja": "ローカルJSONLファイルを選択",
575
- "de": "Lokale JSONL-Datei auswählen",
576
- "es": "Seleccionar archivo JSONL local",
577
- "fr": "Sélectionner un fichier JSONL local",
578
- "it": "Seleziona file JSONL locale",
579
- "da": "Vælg lokal JSONL-fil",
580
- "pl": "Wybierz lokalny plik JSONL",
581
- "ru": "Выбрать локальный JSONL файл",
582
- "ar": "اختيار ملف JSONL محلي",
583
- "no": "Velg lokal JSONL-fil",
584
- "pt-BR": "Selecionar arquivo JSONL local",
585
- "th": "เลือกไฟล์ JSONL ในเครื่อง",
586
- "tr": "Yerel JSONL dosyası seç",
587
- "uk": "Вибрати локальний JSONL файл"
570
+ "zh": "上传日志并解析",
571
+ "en": "Upload and parse log",
572
+ "zh-TW": "上傳日誌並解析",
573
+ "ko": "로그 업로드 분석",
574
+ "ja": "ログをアップロードして解析",
575
+ "de": "Log hochladen und analysieren",
576
+ "es": "Subir y analizar registro",
577
+ "fr": "Télécharger et analyser le journal",
578
+ "it": "Carica e analizza il log",
579
+ "da": "Upload og analysér log",
580
+ "pl": "Prześlij i przeanalizuj log",
581
+ "ru": "Загрузить и разобрать журнал",
582
+ "ar": "تحميل وتحليل السجل",
583
+ "no": "Last opp og analyser logg",
584
+ "pt-BR": "Enviar e analisar log",
585
+ "th": "อัปโหลดและวิเคราะห์ล็อก",
586
+ "tr": "Günlüğü yükle ve ayrıştır",
587
+ "uk": "Завантажити та проаналізувати журнал"
588
588
  },
589
589
  "ui.fileTooLarge": {
590
590
  "zh": "文件大小不能超过 500MB",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/proxy.js CHANGED
@@ -127,6 +127,8 @@ export function startProxy() {
127
127
  const { Readable, pipeline } = await import('node:stream');
128
128
  // @ts-ignore
129
129
  const nodeStream = Readable.fromWeb(response.body);
130
+ // 持久 error handler 兜底:防止 pipeline 清理后延迟到达的 error 事件导致进程崩溃
131
+ nodeStream.on('error', () => {});
130
132
  // pipeline handles stream errors; without this, unhandled 'error' events crash the process.
131
133
  pipeline(nodeStream, res, (err) => {
132
134
  if (err && process.env.CCV_DEBUG) {
package/pty-manager.js CHANGED
@@ -14,6 +14,45 @@ const MAX_BUFFER = 200000;
14
14
  let batchBuffer = '';
15
15
  let batchScheduled = false;
16
16
 
17
+ /**
18
+ * 在 outputBuffer 截断时,找到安全的截断位置,
19
+ * 避免从 ANSI 转义序列中间开始导致终端状态紊乱。
20
+ * 策略:从截断点向后扫描,跳过可能被截断的不完整转义序列。
21
+ */
22
+ function findSafeSliceStart(buf, rawStart) {
23
+ // 从 rawStart 开始,向后最多扫描 64 字节寻找安全起点
24
+ const scanLimit = Math.min(rawStart + 64, buf.length);
25
+ let i = rawStart;
26
+ while (i < scanLimit) {
27
+ const ch = buf.charCodeAt(i);
28
+ // 如果当前字符是 ESC (0x1b),可能是新转义序列的开头,
29
+ // 但也可能是被截断的序列的中间部分,跳过整个序列
30
+ if (ch === 0x1b) {
31
+ // 找到 ESC,向后寻找序列结束符(字母字符)
32
+ let j = i + 1;
33
+ while (j < scanLimit && !((buf.charCodeAt(j) >= 0x40 && buf.charCodeAt(j) <= 0x7e) && j > i + 1)) {
34
+ j++;
35
+ }
36
+ if (j < scanLimit) {
37
+ // 找到完整序列末尾,从下一个字符开始是安全的
38
+ return j + 1;
39
+ }
40
+ // 序列不完整,继续扫描
41
+ i = j;
42
+ continue;
43
+ }
44
+ // 如果字符是 CSI 参数字符 (0x30-0x3f) 或中间字符 (0x20-0x2f),
45
+ // 说明我们在转义序列中间,继续向后
46
+ if ((ch >= 0x20 && ch <= 0x3f)) {
47
+ i++;
48
+ continue;
49
+ }
50
+ // 普通可见字符或控制字符(非转义相关),这是安全位置
51
+ break;
52
+ }
53
+ return i < buf.length ? i : rawStart;
54
+ }
55
+
17
56
  function flushBatch() {
18
57
  batchScheduled = false;
19
58
  if (!batchBuffer) return;
@@ -87,7 +126,9 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
87
126
  ptyProcess.onData((data) => {
88
127
  outputBuffer += data;
89
128
  if (outputBuffer.length > MAX_BUFFER) {
90
- outputBuffer = outputBuffer.slice(-MAX_BUFFER);
129
+ const rawStart = outputBuffer.length - MAX_BUFFER;
130
+ const safeStart = findSafeSliceStart(outputBuffer, rawStart);
131
+ outputBuffer = outputBuffer.slice(safeStart);
91
132
  }
92
133
  batchBuffer += data;
93
134
  if (!batchScheduled) {
package/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createConnection } from 'node:net';
3
3
  import { randomBytes } from 'node:crypto';
4
- import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, openSync, readSync, closeSync, realpathSync, mkdirSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream } from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { dirname, join, extname } from 'node:path';
7
7
  import { homedir, userInfo, platform, networkInterfaces } from 'node:os';
@@ -1141,6 +1141,49 @@ async function handleRequest(req, res) {
1141
1141
  return;
1142
1142
  }
1143
1143
 
1144
+ // 下载指定本地日志文件(原始 JSONL 格式)
1145
+ if (url === '/api/download-log' && method === 'GET') {
1146
+ const file = parsedUrl.searchParams.get('file');
1147
+ if (!file || file.includes('..')) {
1148
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1149
+ res.end(JSON.stringify({ error: 'Invalid file name' }));
1150
+ return;
1151
+ }
1152
+ if (!file.endsWith('.jsonl')) {
1153
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1154
+ res.end(JSON.stringify({ error: 'Invalid file type' }));
1155
+ return;
1156
+ }
1157
+ const filePath = join(LOG_DIR, file);
1158
+ try {
1159
+ if (!existsSync(filePath)) {
1160
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1161
+ res.end(JSON.stringify({ error: 'File not found' }));
1162
+ return;
1163
+ }
1164
+ const realPath = realpathSync(filePath);
1165
+ const realLogDir = realpathSync(LOG_DIR);
1166
+ if (!realPath.startsWith(realLogDir)) {
1167
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1168
+ res.end(JSON.stringify({ error: 'Access denied' }));
1169
+ return;
1170
+ }
1171
+ const fileName = file.split('/').pop();
1172
+ const stat = statSync(realPath);
1173
+ res.writeHead(200, {
1174
+ 'Content-Type': 'application/octet-stream',
1175
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
1176
+ 'Content-Length': stat.size,
1177
+ });
1178
+ const stream = createReadStream(realPath);
1179
+ stream.pipe(res);
1180
+ } catch (err) {
1181
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1182
+ res.end(JSON.stringify({ error: err.message }));
1183
+ }
1184
+ return;
1185
+ }
1186
+
1144
1187
  // 读取指定本地日志文件(支持 project/file 路径)
1145
1188
  if (url === '/api/local-log' && method === 'GET') {
1146
1189
  const file = parsedUrl.searchParams.get('file');
@@ -1187,6 +1230,52 @@ async function handleRequest(req, res) {
1187
1230
  return;
1188
1231
  }
1189
1232
 
1233
+ // 删除日志文件
1234
+ if (url === '/api/delete-logs' && method === 'POST') {
1235
+ let body = '';
1236
+ req.on('data', chunk => { body += chunk; });
1237
+ req.on('end', () => {
1238
+ try {
1239
+ const { files } = JSON.parse(body);
1240
+ if (!Array.isArray(files) || files.length === 0) {
1241
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1242
+ res.end(JSON.stringify({ error: 'No files specified' }));
1243
+ return;
1244
+ }
1245
+ const results = [];
1246
+ for (const file of files) {
1247
+ if (!file || file.includes('..') || !file.endsWith('.jsonl')) {
1248
+ results.push({ file, error: 'Invalid file name' });
1249
+ continue;
1250
+ }
1251
+ const filePath = join(LOG_DIR, file);
1252
+ try {
1253
+ if (!existsSync(filePath)) {
1254
+ results.push({ file, error: 'Not found' });
1255
+ continue;
1256
+ }
1257
+ const realPath = realpathSync(filePath);
1258
+ const realLogDir = realpathSync(LOG_DIR);
1259
+ if (!realPath.startsWith(realLogDir)) {
1260
+ results.push({ file, error: 'Access denied' });
1261
+ continue;
1262
+ }
1263
+ unlinkSync(realPath);
1264
+ results.push({ file, ok: true });
1265
+ } catch (err) {
1266
+ results.push({ file, error: err.message });
1267
+ }
1268
+ }
1269
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1270
+ res.end(JSON.stringify({ results }));
1271
+ } catch {
1272
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1273
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1274
+ }
1275
+ });
1276
+ return;
1277
+ }
1278
+
1190
1279
  // 合并日志文件
1191
1280
  if (url === '/api/merge-logs' && method === 'POST') {
1192
1281
  let body = '';