cc-viewer 1.6.295 → 1.6.296
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/{App-Br-u2TKk.js → App-BeCGow-I.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Cy4egsQx.js → MdxEditorPanel-D52b5qxi.js} +1 -1
- package/dist/assets/{Mobile-ZHF74GQs.js → Mobile-8fflztx7.js} +1 -1
- package/dist/assets/{index-DMuCrfTo.js → index-DtpelJc4.js} +2 -2
- package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +2 -1
- package/server/lib/log-management.js +46 -99
- package/server/lib/log-stream.js +102 -8
- package/server/lib/log-watcher.js +18 -12
- package/server/routes/events.js +3 -3
- package/server/routes/logs.js +5 -5
- package/server/routes/workspaces.js +3 -3
- package/server/server.js +10 -5
- package/server/workspace-registry.js +18 -20
- package/dist/assets/seqResourceLoaders-C7X23dCJ.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
+
<script type="module" crossorigin src="/assets/index-DtpelJc4.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
|
package/package.json
CHANGED
package/server/interceptor.js
CHANGED
|
@@ -407,7 +407,8 @@ export function resetWorkspace() {
|
|
|
407
407
|
_loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
|
|
410
|
+
// Windows NTFS + Defender 下大文件 I/O 代价远高于 Mac/Linux,降低分割阈值减轻压力
|
|
411
|
+
const MAX_LOG_SIZE = (process.platform === 'win32' ? 150 : 300) * 1024 * 1024;
|
|
411
412
|
|
|
412
413
|
async function checkAndRotateLogFile() {
|
|
413
414
|
// Teammate 不做日志轮转,由 leader 负责
|
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, realpathSync, unlinkSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { readFile, writeFile, appendFile, stat, readdir } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
2
4
|
import { renameSyncWithRetry } from './file-api.js';
|
|
3
5
|
import { join } from 'node:path';
|
|
4
6
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
5
|
-
import {
|
|
7
|
+
import { streamReconstructedEntriesAsync } from './log-stream.js';
|
|
6
8
|
import { archiveJsonl, resolveJsonlPath } from './jsonl-archive.js';
|
|
7
9
|
|
|
8
|
-
/**
|
|
9
|
-
* Validate that a resolved file path is contained within logDir.
|
|
10
|
-
* Throws on invalid path (not found or path traversal).
|
|
11
|
-
* @param {string} logDir - base log directory
|
|
12
|
-
* @param {string} file - relative file path (e.g. "project/file.jsonl")
|
|
13
|
-
* @returns {string} the real (resolved) path
|
|
14
|
-
*/
|
|
15
10
|
export function validateLogPath(logDir, file) {
|
|
16
11
|
const filePath = join(logDir, file);
|
|
17
12
|
if (!existsSync(filePath)) {
|
|
@@ -33,65 +28,51 @@ function isLogFileName(name) {
|
|
|
33
28
|
return name.endsWith('.jsonl') || name.endsWith('.jsonl.zip');
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
* List local log files grouped by project.
|
|
38
|
-
* @param {string} logDir - base log directory
|
|
39
|
-
* @param {string} currentProjectName - current project name (may be empty)
|
|
40
|
-
* @returns {{ [project: string]: Array, _currentProject: string }}
|
|
41
|
-
*/
|
|
42
|
-
export function listLocalLogs(logDir, currentProjectName) {
|
|
31
|
+
export async function listLocalLogs(logDir, currentProjectName) {
|
|
43
32
|
const grouped = {};
|
|
44
|
-
if (existsSync(logDir)) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
} catch { }
|
|
62
|
-
for (const f of files) {
|
|
63
|
-
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
|
|
64
|
-
if (!match) continue;
|
|
65
|
-
const ts = match[2];
|
|
66
|
-
const archived = !!match[3];
|
|
67
|
-
const filePath = join(projectDir, f);
|
|
68
|
-
const size = statSync(filePath).size;
|
|
69
|
-
if (size === 0) continue; // 跳过空文件
|
|
70
|
-
// 归档前的统计缓存 key 是 `.jsonl`;归档后切到 `.jsonl.zip`;两种都尝试
|
|
71
|
-
const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
|
|
72
|
-
const turns = stats?.summary?.sessionCount || 0;
|
|
73
|
-
if (!grouped[project]) grouped[project] = [];
|
|
74
|
-
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
|
|
33
|
+
if (!existsSync(logDir)) return { ...grouped, _currentProject: currentProjectName || '' };
|
|
34
|
+
|
|
35
|
+
const entries = await readdir(logDir, { withFileTypes: true });
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!entry.isDirectory()) continue;
|
|
38
|
+
const project = entry.name;
|
|
39
|
+
const projectDir = join(logDir, project);
|
|
40
|
+
const files = (await readdir(projectDir))
|
|
41
|
+
.filter(isLogFileName)
|
|
42
|
+
.sort()
|
|
43
|
+
.reverse();
|
|
44
|
+
let statsFiles = null;
|
|
45
|
+
try {
|
|
46
|
+
const statsFile = join(projectDir, `${project}.json`);
|
|
47
|
+
if (existsSync(statsFile)) {
|
|
48
|
+
statsFiles = JSON.parse(await readFile(statsFile, 'utf-8')).files;
|
|
75
49
|
}
|
|
50
|
+
} catch { }
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
const ts = match[2];
|
|
55
|
+
const archived = !!match[3];
|
|
56
|
+
const filePath = join(projectDir, f);
|
|
57
|
+
let size;
|
|
58
|
+
try { size = (await stat(filePath)).size; } catch { continue; }
|
|
59
|
+
if (size === 0) continue;
|
|
60
|
+
const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
|
|
61
|
+
const turns = stats?.summary?.sessionCount || 0;
|
|
62
|
+
if (!grouped[project]) grouped[project] = [];
|
|
63
|
+
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
|
|
76
64
|
}
|
|
77
65
|
}
|
|
78
66
|
return { ...grouped, _currentProject: currentProjectName || '' };
|
|
79
67
|
}
|
|
80
68
|
|
|
81
|
-
|
|
82
|
-
* Read and parse a local log file.
|
|
83
|
-
* @param {string} logDir - base log directory
|
|
84
|
-
* @param {string} file - relative file path (e.g. "project/file.jsonl")
|
|
85
|
-
* @returns {Array<Object>} parsed entries
|
|
86
|
-
*/
|
|
87
|
-
export function readLocalLog(logDir, file) {
|
|
69
|
+
export async function readLocalLog(logDir, file) {
|
|
88
70
|
validateLogPath(logDir, file);
|
|
89
71
|
const filePath = resolveJsonlPath(join(logDir, file));
|
|
90
|
-
const content =
|
|
72
|
+
const content = await readFile(filePath, 'utf-8');
|
|
91
73
|
const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
|
|
92
74
|
try { return JSON.parse(entry); } catch { return null; }
|
|
93
75
|
}).filter(Boolean);
|
|
94
|
-
// Delta storage: 先去重(timestamp|url),再重建 delta 条目
|
|
95
76
|
const map = new Map();
|
|
96
77
|
for (const entry of parsed) {
|
|
97
78
|
const key = `${entry.timestamp}|${entry.url}`;
|
|
@@ -100,12 +81,6 @@ export function readLocalLog(logDir, file) {
|
|
|
100
81
|
return reconstructEntries(Array.from(map.values()));
|
|
101
82
|
}
|
|
102
83
|
|
|
103
|
-
/**
|
|
104
|
-
* Delete log files. Returns per-file results.
|
|
105
|
-
* @param {string} logDir - base log directory
|
|
106
|
-
* @param {string[]} files - array of relative file paths
|
|
107
|
-
* @returns {Array<{ file: string, ok?: boolean, error?: string }>}
|
|
108
|
-
*/
|
|
109
84
|
export function deleteLogFiles(logDir, files) {
|
|
110
85
|
const results = [];
|
|
111
86
|
for (const file of files) {
|
|
@@ -134,21 +109,12 @@ export function deleteLogFiles(logDir, files) {
|
|
|
134
109
|
return results;
|
|
135
110
|
}
|
|
136
111
|
|
|
137
|
-
|
|
138
|
-
* Merge multiple log files into the first one, deleting the rest.
|
|
139
|
-
* @param {string} logDir - base log directory
|
|
140
|
-
* @param {string[]} files - array of relative file paths (at least 2, same project, chronological order)
|
|
141
|
-
* @returns {string} the merged target file path (relative)
|
|
142
|
-
*/
|
|
143
|
-
export function mergeLogFiles(logDir, files) {
|
|
112
|
+
export async function mergeLogFiles(logDir, files) {
|
|
144
113
|
if (!Array.isArray(files) || files.length < 2) {
|
|
145
114
|
const err = new Error('At least 2 files required');
|
|
146
115
|
err.code = 'INVALID_INPUT';
|
|
147
116
|
throw err;
|
|
148
117
|
}
|
|
149
|
-
// 拒绝归档文件参与合并:mergeLogFiles 会以 files[0] 路径写入 plain jsonl 内容,若该路径
|
|
150
|
-
// 是 .jsonl.zip 会把 zip 文件覆写成裸文本破坏归档;且合并产物语义上应该是可继续追加的
|
|
151
|
-
// 活动文件,与"归档=只读快照"语义冲突。前端 UI 已 disabled,此处后端兜底。
|
|
152
118
|
for (const f of files) {
|
|
153
119
|
if (typeof f === 'string' && f.endsWith('.jsonl.zip')) {
|
|
154
120
|
const err = new Error('Cannot merge archived (.jsonl.zip) files');
|
|
@@ -156,15 +122,12 @@ export function mergeLogFiles(logDir, files) {
|
|
|
156
122
|
throw err;
|
|
157
123
|
}
|
|
158
124
|
}
|
|
159
|
-
// 校验所有文件属于同一 project
|
|
160
|
-
// 兼容 Win backslash:files 内部可能是 `project\log.json`,按两种 sep 都切才能拿 project 段。
|
|
161
125
|
const projects = new Set(files.map(f => f.split(/[\\/]/)[0]));
|
|
162
126
|
if (projects.size !== 1) {
|
|
163
127
|
const err = new Error('All files must belong to the same project');
|
|
164
128
|
err.code = 'INVALID_INPUT';
|
|
165
129
|
throw err;
|
|
166
130
|
}
|
|
167
|
-
// 校验文件存在且无路径穿越
|
|
168
131
|
for (const f of files) {
|
|
169
132
|
if (f.includes('..')) {
|
|
170
133
|
const err = new Error('Invalid file path');
|
|
@@ -177,26 +140,23 @@ export function mergeLogFiles(logDir, files) {
|
|
|
177
140
|
throw err;
|
|
178
141
|
}
|
|
179
142
|
}
|
|
180
|
-
// 校验合并后总大小不超过 400MB
|
|
181
143
|
const MAX_MERGE_SIZE = 400 * 1024 * 1024;
|
|
182
144
|
let totalSize = 0;
|
|
183
145
|
for (const f of files) {
|
|
184
|
-
totalSize +=
|
|
146
|
+
totalSize += (await stat(join(logDir, f))).size;
|
|
185
147
|
}
|
|
186
148
|
if (totalSize > MAX_MERGE_SIZE) {
|
|
187
149
|
const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds ${MAX_MERGE_SIZE / 1024 / 1024}MB limit`);
|
|
188
150
|
err.code = 'INVALID_INPUT';
|
|
189
151
|
throw err;
|
|
190
152
|
}
|
|
191
|
-
// Delta storage: 流式合并 — 逐文件分段重建并直接写入目标文件,避免全量加载 OOM
|
|
192
153
|
const targetFile = files[0];
|
|
193
154
|
const targetPath = join(logDir, targetFile);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
writeFileSync(tmpPath, ''); // 创建空临时文件
|
|
155
|
+
const tmpPath = `${targetPath}.merge-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
156
|
+
await writeFile(tmpPath, '');
|
|
197
157
|
for (const f of files) {
|
|
198
158
|
const filePath = join(logDir, f);
|
|
199
|
-
|
|
159
|
+
await streamReconstructedEntriesAsync(filePath, async (segment) => {
|
|
200
160
|
let chunk = '';
|
|
201
161
|
for (const entry of segment) {
|
|
202
162
|
delete entry._deltaFormat;
|
|
@@ -205,12 +165,10 @@ export function mergeLogFiles(logDir, files) {
|
|
|
205
165
|
delete entry._isCheckpoint;
|
|
206
166
|
chunk += JSON.stringify(entry) + '\n---\n';
|
|
207
167
|
}
|
|
208
|
-
|
|
168
|
+
await appendFile(tmpPath, chunk);
|
|
209
169
|
});
|
|
210
170
|
}
|
|
211
|
-
// 临时文件写入成功后原子覆盖目标(POSIX renameSync 自动替换;Windows reader 持锁时 retry)
|
|
212
171
|
renameSyncWithRetry(tmpPath, targetPath);
|
|
213
|
-
// 删除其余文件
|
|
214
172
|
for (let i = 1; i < files.length; i++) {
|
|
215
173
|
unlinkSync(join(logDir, files[i]));
|
|
216
174
|
}
|
|
@@ -224,33 +182,23 @@ function migrateStatsCacheKey(projectDir, projectName, oldFileName, newFileName)
|
|
|
224
182
|
const stats = JSON.parse(readFileSync(statsFile, 'utf-8'));
|
|
225
183
|
if (stats?.files?.[oldFileName]) {
|
|
226
184
|
const entry = stats.files[oldFileName];
|
|
227
|
-
// 同步用归档后 .zip 的 size / mtime 覆写 entry,避免 stats-worker 下次扫描时
|
|
228
|
-
// 因 size/mtime 不匹配判定 cache stale 触发整文件重解析(大 jsonl 数秒 CPU)。
|
|
229
185
|
try {
|
|
230
186
|
const zipStat = statSync(join(projectDir, newFileName));
|
|
231
187
|
entry.size = zipStat.size;
|
|
232
188
|
entry.lastModified = zipStat.mtime.toISOString();
|
|
233
|
-
} catch {
|
|
189
|
+
} catch {}
|
|
234
190
|
stats.files[newFileName] = entry;
|
|
235
191
|
delete stats.files[oldFileName];
|
|
236
192
|
writeFileSync(statsFile, JSON.stringify(stats, null, 2));
|
|
237
193
|
}
|
|
238
|
-
} catch {
|
|
194
|
+
} catch {}
|
|
239
195
|
}
|
|
240
196
|
|
|
241
|
-
/**
|
|
242
|
-
* 压缩归档多个 .jsonl 文件。每个 project 的最新文件(按文件名 desc 排序后的 logs[0])
|
|
243
|
-
* 被拒绝,复用 mergeLogFiles 的"最新不允许"语义。
|
|
244
|
-
* @param {string} logDir
|
|
245
|
-
* @param {string[]} files - 形如 "project/<name>.jsonl"
|
|
246
|
-
* @returns {{ archived: string[], skipped: Array<{file:string,reason:string}>, failed: Array<{file:string,reason:string}> }}
|
|
247
|
-
*/
|
|
248
197
|
export function archiveLogFiles(logDir, files) {
|
|
249
198
|
const archived = [];
|
|
250
199
|
const skipped = [];
|
|
251
200
|
const failed = [];
|
|
252
201
|
|
|
253
|
-
// 按 project 分组以判定最新文件
|
|
254
202
|
const byProject = new Map();
|
|
255
203
|
for (const f of files) {
|
|
256
204
|
if (!f || typeof f !== 'string' || f.includes('..') || !f.endsWith('.jsonl')) {
|
|
@@ -280,7 +228,7 @@ export function archiveLogFiles(logDir, files) {
|
|
|
280
228
|
.sort()
|
|
281
229
|
.reverse();
|
|
282
230
|
latest = projectEntries[0] || null;
|
|
283
|
-
} catch {
|
|
231
|
+
} catch {}
|
|
284
232
|
|
|
285
233
|
for (const f of projectFiles) {
|
|
286
234
|
const fileName = f.split(/[\\/]/).slice(1).join('/');
|
|
@@ -309,7 +257,6 @@ export function archiveLogFiles(logDir, files) {
|
|
|
309
257
|
} else if (result.skipped) {
|
|
310
258
|
skipped.push({ file: f, reason: result.skipped });
|
|
311
259
|
} else {
|
|
312
|
-
// archiveJsonl 内部已在 unlink 失败时回滚 zip,此处 fail 即原状态完整保留,用户可重试
|
|
313
260
|
failed.push({ file: f, reason: result.error || 'archive failed' });
|
|
314
261
|
}
|
|
315
262
|
}
|
package/server/lib/log-stream.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
14
|
+
import { open as fsOpen, stat as fsStat } from 'node:fs/promises';
|
|
14
15
|
import { isCheckpointEntry, isDeltaEntry, reconstructSegment } from './delta-reconstructor.js';
|
|
15
16
|
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
16
17
|
|
|
@@ -56,15 +57,55 @@ function* iterateRawEntries(filePath) {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 异步 Generator:分块读取 JSONL 文件,逐条 yield 原始 JSON 字符串。
|
|
62
|
+
* 使用 fs.promises.open + fileHandle.read,不阻塞事件循环。
|
|
63
|
+
*/
|
|
64
|
+
async function* iterateRawEntriesAsync(filePath) {
|
|
65
|
+
filePath = resolveJsonlPath(filePath);
|
|
66
|
+
let fh;
|
|
67
|
+
try {
|
|
68
|
+
const st = await fsStat(filePath);
|
|
69
|
+
if (st.size === 0) return;
|
|
70
|
+
fh = await fsOpen(filePath, 'r');
|
|
71
|
+
const fileSize = st.size;
|
|
72
|
+
const buf = Buffer.alloc(Math.min(READ_CHUNK_SIZE, fileSize));
|
|
73
|
+
let offset = 0;
|
|
74
|
+
let pending = '';
|
|
75
|
+
|
|
76
|
+
while (offset < fileSize) {
|
|
77
|
+
const toRead = Math.min(buf.length, fileSize - offset);
|
|
78
|
+
const { bytesRead } = await fh.read(buf, 0, toRead, offset);
|
|
79
|
+
if (bytesRead === 0) break;
|
|
80
|
+
offset += bytesRead;
|
|
81
|
+
|
|
82
|
+
const raw = pending + buf.toString('utf-8', 0, bytesRead);
|
|
83
|
+
const parts = raw.split(SEPARATOR);
|
|
84
|
+
pending = parts.pop() || '';
|
|
85
|
+
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
const trimmed = part.trim();
|
|
88
|
+
if (trimmed) yield trimmed;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (pending.trim()) {
|
|
93
|
+
yield pending.trim();
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
if (fh) await fh.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
59
100
|
/**
|
|
60
101
|
* 轻量预扫描:统计条目总数(原始条目数,不去重)。
|
|
61
102
|
* 用于 SSE load_start 的 total 字段(进度显示)。
|
|
62
103
|
*/
|
|
63
|
-
export function countLogEntries(filePath) {
|
|
104
|
+
export async function countLogEntries(filePath) {
|
|
64
105
|
filePath = resolveJsonlPath(filePath);
|
|
65
106
|
if (!existsSync(filePath)) return 0;
|
|
66
107
|
let count = 0;
|
|
67
|
-
for (const _ of
|
|
108
|
+
for await (const _ of iterateRawEntriesAsync(filePath)) { count++; }
|
|
68
109
|
return count;
|
|
69
110
|
}
|
|
70
111
|
|
|
@@ -157,6 +198,61 @@ export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
|
|
|
157
198
|
return sentCount;
|
|
158
199
|
}
|
|
159
200
|
|
|
201
|
+
export async function streamReconstructedEntriesAsync(filePath, onSegment, opts = {}) {
|
|
202
|
+
filePath = resolveJsonlPath(filePath);
|
|
203
|
+
if (!existsSync(filePath)) return 0;
|
|
204
|
+
try {
|
|
205
|
+
const st = await fsStat(filePath);
|
|
206
|
+
if (st.size === 0) return 0;
|
|
207
|
+
} catch { return 0; }
|
|
208
|
+
|
|
209
|
+
const sinceMs = opts.since ? new Date(opts.since).getTime() : 0;
|
|
210
|
+
let currentSegment = [];
|
|
211
|
+
let dedup = new Map();
|
|
212
|
+
let sentCount = 0;
|
|
213
|
+
|
|
214
|
+
async function flushSegment(nextCp) {
|
|
215
|
+
if (currentSegment.length === 0) return;
|
|
216
|
+
const dedupedSegment = Array.from(dedup.values());
|
|
217
|
+
reconstructSegment(dedupedSegment, nextCp);
|
|
218
|
+
|
|
219
|
+
let toSend = dedupedSegment;
|
|
220
|
+
if (sinceMs) {
|
|
221
|
+
toSend = dedupedSegment.filter(e => {
|
|
222
|
+
const ts = e.timestamp ? new Date(e.timestamp).getTime() : 0;
|
|
223
|
+
return ts > sinceMs;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (toSend.length > 0) {
|
|
227
|
+
await onSegment(toSend);
|
|
228
|
+
sentCount += toSend.length;
|
|
229
|
+
}
|
|
230
|
+
currentSegment = [];
|
|
231
|
+
dedup = new Map();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for await (const rawEntry of iterateRawEntriesAsync(filePath)) {
|
|
235
|
+
let entry;
|
|
236
|
+
try { entry = JSON.parse(rawEntry); } catch { continue; }
|
|
237
|
+
|
|
238
|
+
if (isSegmentBoundary(entry) && currentSegment.length > 0) {
|
|
239
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
240
|
+
const last = currentSegment[currentSegment.length - 1];
|
|
241
|
+
const lastKey = `${last.timestamp}|${last.url}`;
|
|
242
|
+
if (key !== lastKey) {
|
|
243
|
+
await flushSegment(entry);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
248
|
+
dedup.set(key, entry);
|
|
249
|
+
currentSegment.push(entry);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await flushSegment(null);
|
|
253
|
+
return sentCount;
|
|
254
|
+
}
|
|
255
|
+
|
|
160
256
|
// ============================================================================
|
|
161
257
|
// 异步 API — 用于 SSE/HTTP:不做重建,直接发原始 JSON 字符串
|
|
162
258
|
// ============================================================================
|
|
@@ -192,16 +288,15 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
|
192
288
|
const onScan = opts.onScan || null;
|
|
193
289
|
const onReady = opts.onReady || null;
|
|
194
290
|
|
|
195
|
-
//
|
|
291
|
+
// 第一遍:异步 generator 逐条读取 → dedup Map 存原始字符串(不 parse)
|
|
196
292
|
// 内存 = 去重后的原始字符串总量 ≈ 文件大小的一半(inProgress 被 completed 覆盖)
|
|
197
293
|
const dedup = new Map();
|
|
198
|
-
for (const raw of
|
|
294
|
+
for await (const raw of iterateRawEntriesAsync(filePath)) {
|
|
199
295
|
if (onScan) onScan(raw);
|
|
200
296
|
const key = extractDedupKey(raw);
|
|
201
297
|
if (key) {
|
|
202
298
|
dedup.set(key, raw);
|
|
203
299
|
} else {
|
|
204
|
-
// 无法提取 key 的条目直接保留(用自增 id 避免被覆盖)
|
|
205
300
|
dedup.set(`__nokey_${dedup.size}`, raw);
|
|
206
301
|
}
|
|
207
302
|
}
|
|
@@ -270,15 +365,14 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
|
270
365
|
* @param {{ before: string, limit: number }} opts
|
|
271
366
|
* @returns {{ entries: string[], hasMore: boolean, oldestTimestamp: string, count: number }}
|
|
272
367
|
*/
|
|
273
|
-
export function readPagedEntries(filePath, { before, limit }) {
|
|
368
|
+
export async function readPagedEntries(filePath, { before, limit }) {
|
|
274
369
|
filePath = resolveJsonlPath(filePath);
|
|
275
370
|
if (!existsSync(filePath)) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
|
|
276
371
|
const stat = statSync(filePath);
|
|
277
372
|
if (stat.size === 0) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
|
|
278
373
|
|
|
279
|
-
// 去重
|
|
280
374
|
const dedup = new Map();
|
|
281
|
-
for (const raw of
|
|
375
|
+
for await (const raw of iterateRawEntriesAsync(filePath)) {
|
|
282
376
|
const key = extractDedupKey(raw);
|
|
283
377
|
if (key) {
|
|
284
378
|
dedup.set(key, raw);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readFileSync, existsSync, watch, watchFile, unwatchFile,
|
|
1
|
+
import { readFileSync, existsSync, watch, watchFile, unwatchFile, statSync } from 'node:fs';
|
|
2
|
+
import { open as fsOpen, stat as fsStat } from 'node:fs/promises';
|
|
2
3
|
import { dirname, basename } from 'node:path';
|
|
3
4
|
import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
|
|
4
5
|
import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
|
|
5
6
|
import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
|
|
6
|
-
import { countLogEntries,
|
|
7
|
+
import { countLogEntries, streamReconstructedEntriesAsync } from './log-stream.js';
|
|
7
8
|
import { enrichEntry } from './enrich-plan-input.js';
|
|
8
9
|
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
9
10
|
|
|
@@ -109,11 +110,11 @@ export function sendChunkToClients(clients, dataJson) {
|
|
|
109
110
|
|
|
110
111
|
// --- 轮转切换(抽取公共逻辑) ---
|
|
111
112
|
|
|
112
|
-
function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
|
|
113
|
+
async function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
|
|
113
114
|
_unwatchSingleFile(logFile);
|
|
114
|
-
const total = countLogEntries(currentLogFile);
|
|
115
|
+
const total = await countLogEntries(currentLogFile);
|
|
115
116
|
sendEventToClients(clients, 'load_start', { total, incremental: false });
|
|
116
|
-
|
|
117
|
+
await streamReconstructedEntriesAsync(currentLogFile, (segment) => {
|
|
117
118
|
sendChunkToClients(clients, JSON.stringify(segment));
|
|
118
119
|
});
|
|
119
120
|
sendEventToClients(clients, 'load_end', {});
|
|
@@ -122,11 +123,14 @@ function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
|
|
|
122
123
|
|
|
123
124
|
// --- 增量读 + 解析 + 广播(独立于触发机制) ---
|
|
124
125
|
|
|
125
|
-
function _readDelta(state) {
|
|
126
|
+
async function _readDelta(state) {
|
|
127
|
+
if (state._reading) return; // 防止并发调用(debounce + safetyTimer 可能重叠)
|
|
128
|
+
state._reading = true;
|
|
126
129
|
const { logFile, opts, reconstructor } = state;
|
|
127
130
|
const { clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
|
|
128
131
|
try {
|
|
129
|
-
const
|
|
132
|
+
const st = await fsStat(logFile);
|
|
133
|
+
const currentSize = st.size;
|
|
130
134
|
|
|
131
135
|
if (currentSize < state.lastByteOffset) {
|
|
132
136
|
state.lastByteOffset = 0;
|
|
@@ -135,7 +139,7 @@ function _readDelta(state) {
|
|
|
135
139
|
|
|
136
140
|
const currentLogFile = getLogFile();
|
|
137
141
|
if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
|
|
138
|
-
_switchToRotatedFile(logFile, currentLogFile, clients, opts);
|
|
142
|
+
await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
|
|
139
143
|
return;
|
|
140
144
|
}
|
|
141
145
|
}
|
|
@@ -144,11 +148,11 @@ function _readDelta(state) {
|
|
|
144
148
|
|
|
145
149
|
const bytesToRead = currentSize - state.lastByteOffset;
|
|
146
150
|
const buf = Buffer.alloc(bytesToRead);
|
|
147
|
-
const
|
|
151
|
+
const fh = await fsOpen(logFile, 'r');
|
|
148
152
|
try {
|
|
149
|
-
|
|
153
|
+
await fh.read(buf, 0, bytesToRead, state.lastByteOffset);
|
|
150
154
|
} finally {
|
|
151
|
-
|
|
155
|
+
await fh.close();
|
|
152
156
|
}
|
|
153
157
|
state.lastByteOffset = currentSize;
|
|
154
158
|
|
|
@@ -191,10 +195,12 @@ function _readDelta(state) {
|
|
|
191
195
|
|
|
192
196
|
const currentLogFile = getLogFile();
|
|
193
197
|
if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
|
|
194
|
-
_switchToRotatedFile(logFile, currentLogFile, clients, opts);
|
|
198
|
+
await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
|
|
195
199
|
}
|
|
196
200
|
} catch {
|
|
197
201
|
// File not yet created or transient read error
|
|
202
|
+
} finally {
|
|
203
|
+
state._reading = false;
|
|
198
204
|
}
|
|
199
205
|
}
|
|
200
206
|
|
package/server/routes/events.js
CHANGED
|
@@ -96,7 +96,7 @@ function resumeChoice(req, res, parsedUrl, isLocal, deps) {
|
|
|
96
96
|
} catch { }
|
|
97
97
|
});
|
|
98
98
|
// 流式分段广播 full_reload,避免全量加载 OOM
|
|
99
|
-
const reloadTotal = countLogEntries(LOG_FILE);
|
|
99
|
+
const reloadTotal = await countLogEntries(LOG_FILE);
|
|
100
100
|
deps.clients.forEach(client => {
|
|
101
101
|
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: reloadTotal, incremental: false })}\n\n`); } catch { }
|
|
102
102
|
});
|
|
@@ -313,7 +313,7 @@ async function requests(req, res) {
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
// 分页历史条目端点:移动端"加载更多"按需拉取
|
|
316
|
-
function entriesPage(req, res, parsedUrl) {
|
|
316
|
+
async function entriesPage(req, res, parsedUrl) {
|
|
317
317
|
const before = parsedUrl.searchParams.get('before');
|
|
318
318
|
const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 100, 500);
|
|
319
319
|
if (!before || isNaN(new Date(before).getTime())) {
|
|
@@ -322,7 +322,7 @@ function entriesPage(req, res, parsedUrl) {
|
|
|
322
322
|
return;
|
|
323
323
|
}
|
|
324
324
|
try {
|
|
325
|
-
const result = readPagedEntries(LOG_FILE, { before, limit: limitVal });
|
|
325
|
+
const result = await readPagedEntries(LOG_FILE, { before, limit: limitVal });
|
|
326
326
|
// entries 是原始 JSON 字符串数组,parse 后返回给客户端
|
|
327
327
|
// ExitPlanMode V2 空 input 的条目用 enrichRawIfNeeded 在 raw 阶段补全
|
|
328
328
|
const entries = result.entries.map(raw => {
|
package/server/routes/logs.js
CHANGED
|
@@ -6,9 +6,9 @@ import { _projectName } from '../interceptor.js';
|
|
|
6
6
|
import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles, validateLogPath } from '../lib/log-management.js';
|
|
7
7
|
import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
|
|
8
8
|
|
|
9
|
-
function localLogs(req, res) {
|
|
9
|
+
async function localLogs(req, res) {
|
|
10
10
|
try {
|
|
11
|
-
const result = listLocalLogs(LOG_DIR, _projectName);
|
|
11
|
+
const result = await listLocalLogs(LOG_DIR, _projectName);
|
|
12
12
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13
13
|
res.end(JSON.stringify(result));
|
|
14
14
|
} catch (err) {
|
|
@@ -96,7 +96,7 @@ async function localLog(req, res, parsedUrl) {
|
|
|
96
96
|
// 独立 SSE 流:直接向请求方返回 event-stream,不走 /events 广播
|
|
97
97
|
validateLogPath(LOG_DIR, file);
|
|
98
98
|
const filePath = join(LOG_DIR, file);
|
|
99
|
-
const total = countLogEntries(filePath);
|
|
99
|
+
const total = await countLogEntries(filePath);
|
|
100
100
|
|
|
101
101
|
res.writeHead(200, {
|
|
102
102
|
'Content-Type': 'text/event-stream',
|
|
@@ -151,10 +151,10 @@ function deleteLogs(req, res, parsedUrl, isLocal, deps) {
|
|
|
151
151
|
function mergeLogs(req, res, parsedUrl, isLocal, deps) {
|
|
152
152
|
let body = '';
|
|
153
153
|
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
154
|
-
req.on('end', () => {
|
|
154
|
+
req.on('end', async () => {
|
|
155
155
|
try {
|
|
156
156
|
const { files } = JSON.parse(body);
|
|
157
|
-
const merged = mergeLogFiles(LOG_DIR, files);
|
|
157
|
+
const merged = await mergeLogFiles(LOG_DIR, files);
|
|
158
158
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
159
159
|
res.end(JSON.stringify({ ok: true, merged }));
|
|
160
160
|
} catch (err) {
|
|
@@ -7,8 +7,8 @@ import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
|
7
7
|
import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
|
|
8
8
|
|
|
9
9
|
function workspacesList(req, res, parsedUrl, isLocal, deps) {
|
|
10
|
-
import('../workspace-registry.js').then(({ getWorkspaces }) => {
|
|
11
|
-
const workspaces = getWorkspaces();
|
|
10
|
+
import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
|
|
11
|
+
const workspaces = await getWorkspaces();
|
|
12
12
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13
13
|
res.end(JSON.stringify({ workspaces, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
|
|
14
14
|
}).catch(err => {
|
|
@@ -73,7 +73,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
// 流式分段广播以刷新会话区域,避免全量加载 OOM
|
|
76
|
-
const wsReloadTotal = countLogEntries(LOG_FILE);
|
|
76
|
+
const wsReloadTotal = await countLogEntries(LOG_FILE);
|
|
77
77
|
deps.clients.forEach(client => {
|
|
78
78
|
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
|
|
79
79
|
});
|
package/server/server.js
CHANGED
|
@@ -86,6 +86,7 @@ function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
|
|
|
86
86
|
let claudeSettings = {};
|
|
87
87
|
// SSR theme 注入自检状态:模板缺 data-theme 时仅首次 warn(避免高 QPS 刷屏)
|
|
88
88
|
let _ssrThemeAttrWarned = false;
|
|
89
|
+
let _indexHtmlCache = null; // { html: string, mtime: number }
|
|
89
90
|
try {
|
|
90
91
|
const settingsPath = join(getClaudeConfigDir(), 'settings.json');
|
|
91
92
|
if (existsSync(settingsPath)) {
|
|
@@ -685,16 +686,20 @@ async function handleRequest(req, res) {
|
|
|
685
686
|
const serveIndexHtml = () => {
|
|
686
687
|
try {
|
|
687
688
|
const indexPath = join(DIST_DIR, 'index.html');
|
|
688
|
-
|
|
689
|
-
let
|
|
689
|
+
// mtime 缓存:避免每次请求都 readFileSync(Windows Defender 下每次读 5-50ms)
|
|
690
|
+
let st;
|
|
691
|
+
try { st = statSync(indexPath); } catch { return false; }
|
|
692
|
+
if (!_indexHtmlCache || _indexHtmlCache.mtime !== st.mtimeMs) {
|
|
693
|
+
_indexHtmlCache = { html: readFileSync(indexPath, 'utf-8'), mtime: st.mtimeMs };
|
|
694
|
+
}
|
|
695
|
+
let html = _indexHtmlCache.html;
|
|
696
|
+
let themeColor = process.platform === 'win32' ? 'dark' : 'light';
|
|
690
697
|
try {
|
|
691
698
|
if (existsSync(getPrefsFile())) {
|
|
692
699
|
const prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8'));
|
|
693
700
|
if (prefs.themeColor === 'dark' || prefs.themeColor === 'light') themeColor = prefs.themeColor;
|
|
694
701
|
}
|
|
695
|
-
} catch {
|
|
696
|
-
// 自检:模板里没有 <html ... data-theme="..."> 时 replace 静默 no-op,SSR 优化失效但不报错。
|
|
697
|
-
// 仅首次 warn 避免高 QPS 刷屏(_ssrThemeAttrWarned 单进程一次性)。
|
|
702
|
+
} catch {}
|
|
698
703
|
if (!_ssrThemeAttrWarned && !/<html[^>]*data-theme="[^"]*"/.test(html)) {
|
|
699
704
|
_ssrThemeAttrWarned = true;
|
|
700
705
|
console.warn('[serveIndexHtml] dist/index.html 没有 <html data-theme="..."> 属性,SSR theme 注入将不生效。检查 index.html 模板。');
|