cc-viewer 1.6.32 → 1.6.34
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-BlNBvL5K.css → index-BdC0zG2U.css} +2 -2
- package/dist/assets/{index-AD0x3eII.js → index-DE4AHbP5.js} +159 -159
- package/dist/index.html +2 -2
- package/i18n.js +0 -40
- package/interceptor.js +2 -2
- package/lib/delta-reconstructor.js +75 -3
- package/lib/log-management.js +20 -24
- package/lib/log-stream.js +192 -0
- package/lib/log-watcher.js +25 -8
- package/package.json +1 -1
- package/server.js +110 -140
- package/lib/translator.js +0 -84
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DE4AHbP5.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BdC0zG2U.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/i18n.js
CHANGED
|
@@ -1599,46 +1599,6 @@ const i18nData = {
|
|
|
1599
1599
|
"tr": "Düşünce süreci",
|
|
1600
1600
|
"uk": "Процес мислення"
|
|
1601
1601
|
},
|
|
1602
|
-
"ui.translate": {
|
|
1603
|
-
"zh": "翻译",
|
|
1604
|
-
"en": "Translate",
|
|
1605
|
-
"zh-TW": "翻譯",
|
|
1606
|
-
"ko": "번역",
|
|
1607
|
-
"ja": "翻訳",
|
|
1608
|
-
"de": "Übersetzen",
|
|
1609
|
-
"es": "Traducir",
|
|
1610
|
-
"fr": "Traduire",
|
|
1611
|
-
"it": "Traduci",
|
|
1612
|
-
"da": "Oversæt",
|
|
1613
|
-
"pl": "Tłumacz",
|
|
1614
|
-
"ru": "Перевести",
|
|
1615
|
-
"ar": "ترجمة",
|
|
1616
|
-
"no": "Oversett",
|
|
1617
|
-
"pt-BR": "Traduzir",
|
|
1618
|
-
"th": "แปล",
|
|
1619
|
-
"tr": "Çevir",
|
|
1620
|
-
"uk": "Перекласти"
|
|
1621
|
-
},
|
|
1622
|
-
"ui.translating": {
|
|
1623
|
-
"zh": "翻译中…",
|
|
1624
|
-
"en": "Translating…",
|
|
1625
|
-
"zh-TW": "翻譯中…",
|
|
1626
|
-
"ko": "번역 중…",
|
|
1627
|
-
"ja": "翻訳中…",
|
|
1628
|
-
"de": "Übersetze…",
|
|
1629
|
-
"es": "Traduciendo…",
|
|
1630
|
-
"fr": "Traduction…",
|
|
1631
|
-
"it": "Traduzione…",
|
|
1632
|
-
"da": "Oversætter…",
|
|
1633
|
-
"pl": "Tłumaczenie…",
|
|
1634
|
-
"ru": "Перевод…",
|
|
1635
|
-
"ar": "جارٍ الترجمة…",
|
|
1636
|
-
"no": "Oversetter…",
|
|
1637
|
-
"pt-BR": "Traduzindo…",
|
|
1638
|
-
"th": "กำลังแปล…",
|
|
1639
|
-
"tr": "Çevriliyor…",
|
|
1640
|
-
"uk": "Переклад…"
|
|
1641
|
-
},
|
|
1642
1602
|
"ui.skillLoaded": {
|
|
1643
1603
|
"zh": "Skill 已加载",
|
|
1644
1604
|
"en": "Skill Loaded",
|
package/interceptor.js
CHANGED
|
@@ -198,7 +198,7 @@ export function resetWorkspace() {
|
|
|
198
198
|
LOG_FILE = '';
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
const MAX_LOG_SIZE =
|
|
201
|
+
const MAX_LOG_SIZE = 250 * 1024 * 1024; // 250MB
|
|
202
202
|
|
|
203
203
|
function checkAndRotateLogFile() {
|
|
204
204
|
// Teammate 不做日志轮转,由 leader 负责
|
|
@@ -373,7 +373,7 @@ export function setupInterceptor() {
|
|
|
373
373
|
}
|
|
374
374
|
} catch { }
|
|
375
375
|
|
|
376
|
-
// 用户新指令边界:检查日志文件大小,超过
|
|
376
|
+
// 用户新指令边界:检查日志文件大小,超过 250MB 则切换新文件
|
|
377
377
|
if (requestEntry?.mainAgent) {
|
|
378
378
|
checkAndRotateLogFile();
|
|
379
379
|
// 仅 mainAgent 请求时缓存模型名,避免 SubAgent 覆盖
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* 将 delta 格式的日志条目重建为完整的 messages 数组。
|
|
5
5
|
* 仅处理 mainAgent 条目,teammate/旧格式条目直接跳过。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 提供三种 API:
|
|
8
8
|
* - reconstructEntries(entries): 批量重建,用于 readLogFile() 和 readLocalLog()
|
|
9
|
+
* - reconstructSegment(segment, nextCheckpoint): 段级重建,用于流式分段处理
|
|
9
10
|
* - createIncrementalReconstructor(): 有状态的增量重建器,用于 watcher 逐条重建
|
|
10
11
|
*/
|
|
11
12
|
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
* 2. _isCheckpoint === true → 显式 checkpoint
|
|
17
18
|
* 3. _totalMessageCount === body.messages.length → 隐式 checkpoint(delta 长度 === 总长度)
|
|
18
19
|
*/
|
|
19
|
-
function isCheckpointEntry(entry) {
|
|
20
|
+
export function isCheckpointEntry(entry) {
|
|
20
21
|
// 无 _deltaFormat:旧格式全量条目
|
|
21
22
|
if (!entry._deltaFormat) return true;
|
|
22
23
|
// 显式 checkpoint
|
|
@@ -30,7 +31,7 @@ function isCheckpointEntry(entry) {
|
|
|
30
31
|
/**
|
|
31
32
|
* 判断一个条目是否为需要重建的 delta 条目(mainAgent + _deltaFormat)。
|
|
32
33
|
*/
|
|
33
|
-
function isDeltaEntry(entry) {
|
|
34
|
+
export function isDeltaEntry(entry) {
|
|
34
35
|
return entry._deltaFormat && entry.mainAgent;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -112,6 +113,77 @@ function _compensateBrokenEntries(entries, brokenIndices) {
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* 段级重建 — 用于流式分段处理。
|
|
118
|
+
* 对一个 checkpoint 边界内的段进行正向重建,如有 broken 条目则用 nextCheckpoint 反向修复。
|
|
119
|
+
* 段内条目数通常 ≤ CHECKPOINT_INTERVAL(10),内存开销可控。
|
|
120
|
+
*
|
|
121
|
+
* @param {Array} segment - 段内条目数组(段首应为 checkpoint/旧格式条目)
|
|
122
|
+
* @param {object|null} nextCheckpoint - 下一个 checkpoint 条目(用于反向修复),最后一段可为 null
|
|
123
|
+
* @returns {Array} 重建后的段条目数组(原地修改)
|
|
124
|
+
*/
|
|
125
|
+
export function reconstructSegment(segment, nextCheckpoint) {
|
|
126
|
+
let accumulated = [];
|
|
127
|
+
const broken = [];
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < segment.length; i++) {
|
|
130
|
+
const entry = segment[i];
|
|
131
|
+
if (!isDeltaEntry(entry)) {
|
|
132
|
+
if (entry.mainAgent && Array.isArray(entry.body?.messages)) {
|
|
133
|
+
accumulated = [...entry.body.messages];
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const msgs = entry.body?.messages;
|
|
139
|
+
if (!Array.isArray(msgs)) continue;
|
|
140
|
+
|
|
141
|
+
if (isCheckpointEntry(entry)) {
|
|
142
|
+
accumulated = [...msgs];
|
|
143
|
+
} else {
|
|
144
|
+
accumulated = [...accumulated, ...msgs];
|
|
145
|
+
entry.body.messages = accumulated;
|
|
146
|
+
if (entry._totalMessageCount && accumulated.length !== entry._totalMessageCount) {
|
|
147
|
+
broken.push(i);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 补偿修复:先在段内向后查找,再用 nextCheckpoint
|
|
153
|
+
if (broken.length > 0) {
|
|
154
|
+
for (const brokenIdx of broken) {
|
|
155
|
+
const brokenEntry = segment[brokenIdx];
|
|
156
|
+
const expectedCount = brokenEntry._totalMessageCount;
|
|
157
|
+
if (!expectedCount) continue;
|
|
158
|
+
|
|
159
|
+
let repaired = false;
|
|
160
|
+
// 段内向后查找
|
|
161
|
+
for (let j = brokenIdx + 1; j < segment.length; j++) {
|
|
162
|
+
const candidate = segment[j];
|
|
163
|
+
if (!candidate.mainAgent || !Array.isArray(candidate.body?.messages)) continue;
|
|
164
|
+
const candidateMsgs = candidate.body.messages;
|
|
165
|
+
const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
|
|
166
|
+
const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
|
|
167
|
+
if (isFullEntry && candidateTotal >= expectedCount) {
|
|
168
|
+
brokenEntry.body.messages = candidateMsgs.slice(0, expectedCount);
|
|
169
|
+
repaired = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// 段内未找到,用 nextCheckpoint 修复
|
|
174
|
+
if (!repaired && nextCheckpoint) {
|
|
175
|
+
const cpMsgs = nextCheckpoint.body?.messages;
|
|
176
|
+
const cpTotal = nextCheckpoint._totalMessageCount || cpMsgs?.length || 0;
|
|
177
|
+
if (Array.isArray(cpMsgs) && cpTotal >= expectedCount) {
|
|
178
|
+
brokenEntry.body.messages = cpMsgs.slice(0, expectedCount);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return segment;
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
/**
|
|
116
188
|
* 创建有状态的增量重建器 — 用于 watcher 逐条重建。
|
|
117
189
|
* 每次调用 reconstruct(entry) 处理一条新条目。
|
package/lib/log-management.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync, renameSync, appendFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
4
|
+
import { streamReconstructedEntries } from './log-stream.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Validate that a resolved file path is contained within logDir.
|
|
@@ -167,33 +168,28 @@ export function mergeLogFiles(logDir, files) {
|
|
|
167
168
|
err.code = 'INVALID_INPUT';
|
|
168
169
|
throw err;
|
|
169
170
|
}
|
|
170
|
-
// Delta storage:
|
|
171
|
+
// Delta storage: 流式合并 — 逐文件分段重建并直接写入目标文件,避免全量加载 OOM
|
|
171
172
|
const targetFile = files[0];
|
|
172
173
|
const targetPath = join(logDir, targetFile);
|
|
173
|
-
|
|
174
|
+
// 先写到临时文件,成功后再覆盖目标
|
|
175
|
+
const tmpPath = targetPath + '.merge-tmp';
|
|
176
|
+
writeFileSync(tmpPath, ''); // 创建空临时文件
|
|
174
177
|
for (const f of files) {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// 清除 delta 元字段,输出为旧格式全量条目
|
|
188
|
-
for (const entry of reconstructed) {
|
|
189
|
-
delete entry._deltaFormat;
|
|
190
|
-
delete entry._totalMessageCount;
|
|
191
|
-
delete entry._conversationId;
|
|
192
|
-
delete entry._isCheckpoint;
|
|
193
|
-
}
|
|
194
|
-
allEntries.push(...reconstructed);
|
|
178
|
+
const filePath = join(logDir, f);
|
|
179
|
+
streamReconstructedEntries(filePath, (segment) => {
|
|
180
|
+
let chunk = '';
|
|
181
|
+
for (const entry of segment) {
|
|
182
|
+
delete entry._deltaFormat;
|
|
183
|
+
delete entry._totalMessageCount;
|
|
184
|
+
delete entry._conversationId;
|
|
185
|
+
delete entry._isCheckpoint;
|
|
186
|
+
chunk += JSON.stringify(entry) + '\n---\n';
|
|
187
|
+
}
|
|
188
|
+
appendFileSync(tmpPath, chunk);
|
|
189
|
+
});
|
|
195
190
|
}
|
|
196
|
-
|
|
191
|
+
// 临时文件写入成功后原子覆盖目标(POSIX renameSync 自动替换)
|
|
192
|
+
renameSync(tmpPath, targetPath);
|
|
197
193
|
// 删除其余文件
|
|
198
194
|
for (let i = 1; i < files.length; i++) {
|
|
199
195
|
unlinkSync(join(logDir, files[i]));
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log Stream — 流式分段读取模块
|
|
3
|
+
*
|
|
4
|
+
* 关键设计:server 不做 delta 重建,只做去重和流式发送。
|
|
5
|
+
* 重建交给客户端(浏览器内存更充裕)。
|
|
6
|
+
*
|
|
7
|
+
* 内存控制:
|
|
8
|
+
* - 文件读取:openSync + readSync 1MB 分块,generator 逐条 yield
|
|
9
|
+
* - 去重:用 regex 提取 key,不做 JSON.parse(存原始字符串)
|
|
10
|
+
* - 异步发送:逐条 write + 定期 setImmediate yield(GC + buffer drain)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
14
|
+
import { isCheckpointEntry, isDeltaEntry, reconstructSegment } from './delta-reconstructor.js';
|
|
15
|
+
|
|
16
|
+
const READ_CHUNK_SIZE = 1024 * 1024; // 1MB
|
|
17
|
+
const SEPARATOR = '\n---\n';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generator:分块读取 JSONL 文件,逐条 yield 原始 JSON 字符串。
|
|
21
|
+
* 内存 = 1MB buffer + pending。
|
|
22
|
+
*/
|
|
23
|
+
function* iterateRawEntries(filePath) {
|
|
24
|
+
const fileSize = statSync(filePath).size;
|
|
25
|
+
if (fileSize === 0) return;
|
|
26
|
+
|
|
27
|
+
const fd = openSync(filePath, 'r');
|
|
28
|
+
const buf = Buffer.alloc(Math.min(READ_CHUNK_SIZE, fileSize));
|
|
29
|
+
let offset = 0;
|
|
30
|
+
let pending = '';
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
while (offset < fileSize) {
|
|
34
|
+
const toRead = Math.min(buf.length, fileSize - offset);
|
|
35
|
+
const bytesRead = readSync(fd, buf, 0, toRead, offset);
|
|
36
|
+
if (bytesRead === 0) break;
|
|
37
|
+
offset += bytesRead;
|
|
38
|
+
|
|
39
|
+
const raw = pending + buf.toString('utf-8', 0, bytesRead);
|
|
40
|
+
const parts = raw.split(SEPARATOR);
|
|
41
|
+
pending = parts.pop() || '';
|
|
42
|
+
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
const trimmed = part.trim();
|
|
45
|
+
if (trimmed) yield trimmed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (pending.trim()) {
|
|
50
|
+
yield pending.trim();
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 轻量预扫描:统计条目总数(原始条目数,不去重)。
|
|
59
|
+
* 用于 SSE load_start 的 total 字段(进度显示)。
|
|
60
|
+
*/
|
|
61
|
+
export function countLogEntries(filePath) {
|
|
62
|
+
if (!existsSync(filePath)) return 0;
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const _ of iterateRawEntries(filePath)) { count++; }
|
|
65
|
+
return count;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 用 regex 从原始 JSON 字符串中提取 timestamp|url 去重 key(不做 JSON.parse) */
|
|
69
|
+
function extractDedupKey(raw) {
|
|
70
|
+
const tsMatch = raw.match(/"timestamp"\s*:\s*"([^"]+)"/);
|
|
71
|
+
const urlMatch = raw.match(/"url"\s*:\s*"([^"]+)"/);
|
|
72
|
+
if (tsMatch && urlMatch) return `${tsMatch[1]}|${urlMatch[1]}`;
|
|
73
|
+
// fallback: 无法提取 key 则用内容哈希
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isSegmentBoundary(entry) {
|
|
78
|
+
if (!entry.mainAgent) return false;
|
|
79
|
+
if (!entry._deltaFormat) return true;
|
|
80
|
+
return isCheckpointEntry(entry);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// 同步 API — 用于 mergeLogFiles(合并需要重建为全量格式写入磁盘)
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
|
|
88
|
+
if (!existsSync(filePath)) return 0;
|
|
89
|
+
const stat = statSync(filePath);
|
|
90
|
+
if (stat.size === 0) return 0;
|
|
91
|
+
|
|
92
|
+
const sinceMs = opts.since ? new Date(opts.since).getTime() : 0;
|
|
93
|
+
let currentSegment = [];
|
|
94
|
+
let dedup = new Map();
|
|
95
|
+
let sentCount = 0;
|
|
96
|
+
|
|
97
|
+
function flushSegment(nextCp) {
|
|
98
|
+
if (currentSegment.length === 0) return;
|
|
99
|
+
const dedupedSegment = Array.from(dedup.values());
|
|
100
|
+
reconstructSegment(dedupedSegment, nextCp);
|
|
101
|
+
|
|
102
|
+
let toSend = dedupedSegment;
|
|
103
|
+
if (sinceMs) {
|
|
104
|
+
toSend = dedupedSegment.filter(e => {
|
|
105
|
+
const ts = e.timestamp ? new Date(e.timestamp).getTime() : 0;
|
|
106
|
+
return ts > sinceMs;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (toSend.length > 0) {
|
|
110
|
+
onSegment(toSend);
|
|
111
|
+
sentCount += toSend.length;
|
|
112
|
+
}
|
|
113
|
+
currentSegment = [];
|
|
114
|
+
dedup = new Map();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const rawEntry of iterateRawEntries(filePath)) {
|
|
118
|
+
let entry;
|
|
119
|
+
try { entry = JSON.parse(rawEntry); } catch { continue; }
|
|
120
|
+
|
|
121
|
+
if (isSegmentBoundary(entry) && currentSegment.length > 0) {
|
|
122
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
123
|
+
const last = currentSegment[currentSegment.length - 1];
|
|
124
|
+
const lastKey = `${last.timestamp}|${last.url}`;
|
|
125
|
+
if (key !== lastKey) {
|
|
126
|
+
flushSegment(entry);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
131
|
+
dedup.set(key, entry);
|
|
132
|
+
currentSegment.push(entry);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
flushSegment(null);
|
|
136
|
+
return sentCount;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// 异步 API — 用于 SSE/HTTP:不做重建,直接发原始 JSON 字符串
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 异步流式发送原始条目(不重建 delta)。
|
|
145
|
+
*
|
|
146
|
+
* - 用 generator 逐条读取原始 JSON 字符串
|
|
147
|
+
* - regex 提取 key 去重(后出现的覆盖先出现的)
|
|
148
|
+
* - 逐条调用 onRawEntry(rawJsonString)
|
|
149
|
+
* - 每 N 条 setImmediate yield 让 GC + write buffer drain
|
|
150
|
+
*
|
|
151
|
+
* server 不做 JSON.parse / JSON.stringify / reconstruct = 内存峰值极低。
|
|
152
|
+
* 客户端收到后自行 reconstructEntries()。
|
|
153
|
+
*
|
|
154
|
+
* @param {string} filePath
|
|
155
|
+
* @param {(rawJson: string) => void} onRawEntry - 原始 JSON 字符串回调
|
|
156
|
+
* @returns {Promise<number>} 发送条目数
|
|
157
|
+
*/
|
|
158
|
+
export async function streamRawEntriesAsync(filePath, onRawEntry) {
|
|
159
|
+
if (!existsSync(filePath)) return 0;
|
|
160
|
+
const stat = statSync(filePath);
|
|
161
|
+
if (stat.size === 0) return 0;
|
|
162
|
+
|
|
163
|
+
// 第一遍:generator 逐条读取 → dedup Map 存原始字符串(不 parse)
|
|
164
|
+
// 内存 = 去重后的原始字符串总量 ≈ 文件大小的一半(inProgress 被 completed 覆盖)
|
|
165
|
+
const dedup = new Map();
|
|
166
|
+
for (const raw of iterateRawEntries(filePath)) {
|
|
167
|
+
const key = extractDedupKey(raw);
|
|
168
|
+
if (key) {
|
|
169
|
+
dedup.set(key, raw);
|
|
170
|
+
} else {
|
|
171
|
+
// 无法提取 key 的条目直接保留(用自增 id 避免被覆盖)
|
|
172
|
+
dedup.set(`__nokey_${dedup.size}`, raw);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 第二遍:逐条发送 + 定期 yield
|
|
177
|
+
let sentCount = 0;
|
|
178
|
+
const YIELD_INTERVAL = 20; // 每 20 条 yield 一次
|
|
179
|
+
|
|
180
|
+
for (const raw of dedup.values()) {
|
|
181
|
+
onRawEntry(raw);
|
|
182
|
+
sentCount++;
|
|
183
|
+
if (sentCount % YIELD_INTERVAL === 0) {
|
|
184
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 最终 yield 确保最后一批 buffer drain
|
|
189
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
190
|
+
|
|
191
|
+
return sentCount;
|
|
192
|
+
}
|
package/lib/log-watcher.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, watchFile, unwatchFile, openSync, readSync, c
|
|
|
2
2
|
import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
|
|
3
3
|
import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
|
|
4
4
|
import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
|
|
5
|
+
import { countLogEntries, streamReconstructedEntries } from './log-stream.js';
|
|
5
6
|
|
|
6
7
|
// 跟踪所有被 watch 的日志文件
|
|
7
8
|
const watchedFiles = new Map();
|
|
@@ -110,11 +111,19 @@ export function watchLogFile(opts) {
|
|
|
110
111
|
unwatchFile(logFile);
|
|
111
112
|
watchedFiles.delete(logFile);
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
// 流式分段广播,避免全量加载 OOM
|
|
115
|
+
const rotTotal = countLogEntries(currentLogFile);
|
|
114
116
|
clients.forEach(client => {
|
|
115
|
-
try {
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: rotTotal, incremental: false })}\n\n`); } catch { }
|
|
118
|
+
});
|
|
119
|
+
streamReconstructedEntries(currentLogFile, (segment) => {
|
|
120
|
+
const data = JSON.stringify(segment);
|
|
121
|
+
clients.forEach(client => {
|
|
122
|
+
try { client.write(`event: load_chunk\ndata: ${data}\n\n`); } catch { }
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
clients.forEach(client => {
|
|
126
|
+
try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
|
|
118
127
|
});
|
|
119
128
|
watchLogFile({ ...opts, logFile: currentLogFile });
|
|
120
129
|
return;
|
|
@@ -193,11 +202,19 @@ export function watchLogFile(opts) {
|
|
|
193
202
|
unwatchFile(logFile);
|
|
194
203
|
watchedFiles.delete(logFile);
|
|
195
204
|
|
|
196
|
-
|
|
205
|
+
// 流式分段广播,避免全量加载 OOM
|
|
206
|
+
const endRotTotal = countLogEntries(currentLogFile);
|
|
197
207
|
clients.forEach(client => {
|
|
198
|
-
try {
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: endRotTotal, incremental: false })}\n\n`); } catch { }
|
|
209
|
+
});
|
|
210
|
+
streamReconstructedEntries(currentLogFile, (segment) => {
|
|
211
|
+
const data = JSON.stringify(segment);
|
|
212
|
+
clients.forEach(client => {
|
|
213
|
+
try { client.write(`event: load_chunk\ndata: ${data}\n\n`); } catch { }
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
clients.forEach(client => {
|
|
217
|
+
try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
|
|
201
218
|
});
|
|
202
219
|
watchLogFile({ ...opts, logFile: currentLogFile });
|
|
203
220
|
}
|