cc-viewer 1.7.0 → 1.7.1
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-gUSmU6Ny.js +2 -0
- package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-DAR39A3S.js} +1 -1
- package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-qfRsRth0.js} +1 -1
- package/dist/assets/index-B51Ci0GL.js +2 -0
- package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
- package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/server/lib/log-watcher.js +29 -3
- package/server/lib/v2/adapter.js +71 -6
- package/server/lib/v2/live-feed.js +110 -3
- package/server/lib/v2/meta-rows.js +360 -0
- package/server/lib/v2/v2-writer.js +15 -0
- package/server/lib/wire-compress.js +167 -0
- package/server/routes/events.js +96 -26
- package/server/routes/logs.js +15 -12
- package/server/routes/v2.js +60 -0
- package/server/routes/workspaces.js +7 -6
- package/server/server.js +23 -1
- package/src/utils/apiUrl.js +26 -0
- package/src/utils/askFallback.js +21 -0
- package/src/utils/askOptionDesc.js +55 -0
- package/src/utils/askPortalMatcher.js +54 -0
- package/src/utils/autoApproveOptions.js +27 -0
- package/src/utils/avatarAnimationPostPass.js +49 -0
- package/src/utils/builtinPresets.js +18 -0
- package/src/utils/clearCheckpoint.js +7 -0
- package/src/utils/commandValidator.js +45 -0
- package/src/utils/contentFilter.js +620 -0
- package/src/utils/contextRaw.js +18 -0
- package/src/utils/contextSidebarNavigation.js +16 -0
- package/src/utils/contextTurns.js +117 -0
- package/src/utils/displayScaleHelper.js +47 -0
- package/src/utils/effectiveModel.js +13 -0
- package/src/utils/entry-slim.js +563 -0
- package/src/utils/entryCache.js +144 -0
- package/src/utils/errorReport.js +6 -0
- package/src/utils/fileExpandedPathsStorage.js +70 -0
- package/src/utils/fileIcons.jsx +36 -0
- package/src/utils/fileOpen.js +37 -0
- package/src/utils/formatters.js +48 -0
- package/src/utils/gitApi.js +50 -0
- package/src/utils/gitTreeBuilder.js +19 -0
- package/src/utils/helpers.js +846 -0
- package/src/utils/identityHeal.js +99 -0
- package/src/utils/imConnState.js +59 -0
- package/src/utils/imOrigin.js +24 -0
- package/src/utils/imTr.js +7 -0
- package/src/utils/imageCompress.js +83 -0
- package/src/utils/imageDownscale.js +28 -0
- package/src/utils/imageResize.js +112 -0
- package/src/utils/ingestPipeline.js +47 -0
- package/src/utils/lazyWithReload.js +53 -0
- package/src/utils/markdown.js +30 -0
- package/src/utils/markdownIncremental.js +39 -0
- package/src/utils/markdownProfiler.js +113 -0
- package/src/utils/mdExtensionDetect.js +64 -0
- package/src/utils/memoryLinkParser.js +44 -0
- package/src/utils/modalMask.js +15 -0
- package/src/utils/monotime.js +6 -0
- package/src/utils/pinnedMenu.js +55 -0
- package/src/utils/presetShortcuts.js +14 -0
- package/src/utils/projectAlias.js +135 -0
- package/src/utils/promptClassifier.js +112 -0
- package/src/utils/promptDetect.js +196 -0
- package/src/utils/promptNav.js +60 -0
- package/src/utils/ptyChunkBuilder.js +251 -0
- package/src/utils/quickMenuHoverIntent.js +35 -0
- package/src/utils/rateLimitParser.js +119 -0
- package/src/utils/readResultPool.js +106 -0
- package/src/utils/refreshCachedItemProp.js +30 -0
- package/src/utils/requestType.js +208 -0
- package/src/utils/resizeCalc.js +21 -0
- package/src/utils/resolveLocalized.js +17 -0
- package/src/utils/searchApi.js +47 -0
- package/src/utils/searchReplace.js +51 -0
- package/src/utils/seqResourceLoaders.js +79 -0
- package/src/utils/sessionManager.js +471 -0
- package/src/utils/sessionMerge.js +180 -0
- package/src/utils/skillModalController.js +111 -0
- package/src/utils/skillsParser.js +116 -0
- package/src/utils/slashCommandLabels.js +80 -0
- package/src/utils/splitDragCalc.js +73 -0
- package/src/utils/stickyBottomController.js +643 -0
- package/src/utils/svgSanitize.js +48 -0
- package/src/utils/systemTags.js +47 -0
- package/src/utils/tClaude.js +20 -0
- package/src/utils/teamModalBuilder.js +351 -0
- package/src/utils/teamSessionParser.js +190 -0
- package/src/utils/teammateAvatars.js +194 -0
- package/src/utils/teammateDetector.js +121 -0
- package/src/utils/termDiag.js +114 -0
- package/src/utils/terminalClipboard.js +117 -0
- package/src/utils/terminalWriteQueue.js +398 -0
- package/src/utils/thinkingMerge.js +49 -0
- package/src/utils/toolCatalog.js +29 -0
- package/src/utils/toolResultBuilder.js +393 -0
- package/src/utils/toolResultClassifier.js +16 -0
- package/src/utils/toolResultCore.js +167 -0
- package/src/utils/toolsDiff.js +44 -0
- package/src/utils/toolsXmlFormatter.js +3 -0
- package/src/utils/ultraplanController.js +109 -0
- package/src/utils/ultraplanExperts.js +86 -0
- package/src/utils/ultraplanTemplates.js +154 -0
- package/src/utils/userImageRefs.js +49 -0
- package/src/utils/v3Assembler.js +112 -0
- package/src/utils/v3Rows.js +48 -0
- package/src/utils/voicePackPlayer.js +233 -0
- package/src/utils/webSearchGrouping.js +111 -0
- package/src/utils/workflowFormat.js +52 -0
- package/src/utils/workflowRuns.js +118 -0
- package/src/utils/workflowStore.js +94 -0
- package/dist/assets/App-DTkacfRW.js +0 -2
- package/dist/assets/index-Ac8vnV3v.js +0 -2
- package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
package/server/routes/events.js
CHANGED
|
@@ -6,7 +6,9 @@ import { LOG_DIR } from '../../findcc.js';
|
|
|
6
6
|
import { streamRawEntriesAsync } from '../lib/log-stream.js';
|
|
7
7
|
import { migrationStatus } from '../lib/v2/migrate-prompt.js';
|
|
8
8
|
import { reportSwallowed } from '../lib/error-report.js';
|
|
9
|
-
import {
|
|
9
|
+
import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain } from '../lib/wire-compress.js';
|
|
10
|
+
import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
|
|
11
|
+
import { readV2SingleEntry } from '../lib/v2/adapter.js';
|
|
10
12
|
import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
|
|
11
13
|
import { validateLogPath } from '../lib/log-management.js';
|
|
12
14
|
import { isMainAgentEntry, extractCachedContent } from '../lib/kv-cache-analyzer.js';
|
|
@@ -60,7 +62,10 @@ export function sseUpdateBadgeFrame(pending) {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
async function events(req, res, parsedUrl, isLocal, deps) {
|
|
63
|
-
|
|
65
|
+
// Negotiated Content-Encoding (br|identity). From here on, every byte of
|
|
66
|
+
// this response MUST go through sseWrite — a bare res.write would corrupt
|
|
67
|
+
// the compressed stream (see server/lib/wire-compress.js).
|
|
68
|
+
sseHead(req, res, 200, {
|
|
64
69
|
'Content-Type': 'text/event-stream',
|
|
65
70
|
'Cache-Control': 'no-cache',
|
|
66
71
|
'Connection': 'keep-alive',
|
|
@@ -74,7 +79,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
74
79
|
|
|
75
80
|
// SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
|
|
76
81
|
const pingTimer = setInterval(() => {
|
|
77
|
-
try { res
|
|
82
|
+
try { sseWrite(res, 'event: ping\ndata: {}\n\n'); } catch {}
|
|
78
83
|
}, 30000);
|
|
79
84
|
|
|
80
85
|
// server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
|
|
@@ -82,7 +87,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
82
87
|
// 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
|
|
83
88
|
// write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
|
|
84
89
|
try {
|
|
85
|
-
res
|
|
90
|
+
sseWrite(res, `event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: deps.turnEndDebounceMs, wireV3: !!deps.wireV3, build: deps.serverBuild || '' })}\n\n`);
|
|
86
91
|
} catch (err) {
|
|
87
92
|
console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
|
|
88
93
|
}
|
|
@@ -90,7 +95,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
90
95
|
// 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
|
|
91
96
|
// 置于 deps.clients.push(res) 之前 → 新客户端仅得一份,不与一次性广播竞态。
|
|
92
97
|
const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
|
|
93
|
-
if (updFrame) res
|
|
98
|
+
if (updFrame) sseWrite(res, updFrame);
|
|
94
99
|
|
|
95
100
|
// 1.7.0 迁移引导(P2):当前项目仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
|
|
96
101
|
// 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
|
|
@@ -99,7 +104,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
99
104
|
try {
|
|
100
105
|
const mig = migrationStatus(LOG_DIR, _projectName || '');
|
|
101
106
|
if (mig.pending) {
|
|
102
|
-
res
|
|
107
|
+
sseWrite(res, `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
|
|
103
108
|
}
|
|
104
109
|
} catch (e) { reportSwallowed('sse.migrate_prompt', e); }
|
|
105
110
|
|
|
@@ -144,9 +149,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
144
149
|
const MAINAGENT_SCAN_RING = 3;
|
|
145
150
|
const mainAgentRawRing = [];
|
|
146
151
|
|
|
152
|
+
// Wire v3 (V3.S5): flagged + v2 source ⇒ the legacy full-entry cold stream
|
|
153
|
+
// is REPLACED by rows + native lines (the byte win); the client assembler
|
|
154
|
+
// rebuilds entries locally. v1 legacy files keep the entry pipeline.
|
|
155
|
+
const _v3Src = deps.wireV3 ? getLiveLogSource() : null;
|
|
156
|
+
const v3Cold = !!(_v3Src && existsSync(join(_v3Src, 'journal.jsonl')));
|
|
157
|
+
|
|
147
158
|
// S6b: the cold-load source is the current v2 session dir when the v2
|
|
148
159
|
// writer is active (adapter stream), else the v1 file.
|
|
149
|
-
const coldLoadResult = await streamRawEntriesAsync(getLiveLogSource(), async (raw) => {
|
|
160
|
+
const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(getLiveLogSource(), async (raw) => {
|
|
150
161
|
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
151
162
|
// ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
|
|
152
163
|
if (res.destroyed || !res.writable) return;
|
|
@@ -156,9 +167,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
156
167
|
// 把 EPIPE 抛穿 async callback;res.on('close'|'error') 已会做 clients 数组清理。
|
|
157
168
|
let drained = true;
|
|
158
169
|
try {
|
|
159
|
-
res
|
|
160
|
-
|
|
161
|
-
res
|
|
170
|
+
sseWrite(res, 'event: load_chunk\ndata: [');
|
|
171
|
+
const ok = sseWrite(res, out.includes('\n') ? out.replace(/\n/g, '') : out);
|
|
172
|
+
sseWrite(res, ']\n\n');
|
|
173
|
+
// Two pressure signals: `ok` is the write target's input buffer (the
|
|
174
|
+
// ENCODER on compressed paths — its backlog would otherwise be invisible
|
|
175
|
+
// because compressed output is 20-80x smaller and rarely fills the
|
|
176
|
+
// socket), needsDrain(res) is the socket itself. awaitWireDrain below
|
|
177
|
+
// waits on whichever stream actually applies the pressure.
|
|
178
|
+
drained = ok && !needsDrain(res);
|
|
162
179
|
} catch {
|
|
163
180
|
return;
|
|
164
181
|
}
|
|
@@ -166,7 +183,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
166
183
|
// helper 内部会在 fulfill 时把另外两个监听器从 res 上摘掉,避免 N 次 backpressure
|
|
167
184
|
// 累积出 N 个 stale close/error listener 触发 MaxListenersExceededWarning。
|
|
168
185
|
if (!drained) {
|
|
169
|
-
await
|
|
186
|
+
await awaitWireDrain(res, deps.SSE_BACKPRESSURE_TIMEOUT_MS);
|
|
170
187
|
}
|
|
171
188
|
}, {
|
|
172
189
|
since: useIncremental ? sinceParam : undefined,
|
|
@@ -190,11 +207,54 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
190
207
|
loadStartData.hasMore = !!hasMore;
|
|
191
208
|
loadStartData.oldestTs = oldestTs || '';
|
|
192
209
|
}
|
|
193
|
-
res
|
|
210
|
+
sseWrite(res, `event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
|
|
194
211
|
},
|
|
195
212
|
});
|
|
196
213
|
|
|
197
|
-
|
|
214
|
+
// Wire v3 (V3.S2/S4/S5): cold rows + native lines land BEFORE load_end so
|
|
215
|
+
// the flagged client can assemble its window in the same load cycle. The
|
|
216
|
+
// legacy chunk stream was skipped above (v3Cold) — load_start is emitted
|
|
217
|
+
// here from the rows metadata instead.
|
|
218
|
+
if (v3Cold) {
|
|
219
|
+
try {
|
|
220
|
+
const src = _v3Src;
|
|
221
|
+
// Single-flighted bundle (review P2-b): a reconnect storm coalesces to
|
|
222
|
+
// one journal fold + one native read per (dir, window). `since` scopes
|
|
223
|
+
// an incremental reconnect to the delta window (review P1-2) — the
|
|
224
|
+
// client upserts those rows instead of resetting the list.
|
|
225
|
+
const { meta, native } = await readV2ColdBundle(src, {
|
|
226
|
+
limit: useLimit ? effectiveLimit : 0,
|
|
227
|
+
since: useIncremental ? sinceParam : null,
|
|
228
|
+
});
|
|
229
|
+
// Build every payload BEFORE load_start so it can carry the exact byte
|
|
230
|
+
// total — the client renders a real received/total progress (the legacy
|
|
231
|
+
// wire's per-entry count-up has no per-frame granularity here).
|
|
232
|
+
const rowsPayload = JSON.stringify({ rows: meta.rows, totalCount: meta.totalCount, hasMore: meta.hasMore, oldestTs: meta.oldestTimestamp, incremental: !!useIncremental });
|
|
233
|
+
let v3Bytes = rowsPayload.length;
|
|
234
|
+
for (const p of native.convPayloads) v3Bytes += p.length;
|
|
235
|
+
for (const p of native.respPayloads) v3Bytes += p.length;
|
|
236
|
+
const loadStartData = { total: meta.totalCount, incremental: !!useIncremental, v3Bytes };
|
|
237
|
+
if (useLimit) {
|
|
238
|
+
loadStartData.hasMore = !!meta.hasMore;
|
|
239
|
+
loadStartData.oldestTs = meta.oldestTimestamp || '';
|
|
240
|
+
}
|
|
241
|
+
sseWrite(res, `event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
|
|
242
|
+
sseWrite(res, `event: v2_requests\ndata: ${rowsPayload}\n\n`);
|
|
243
|
+
for (const payload of native.convPayloads) sseWrite(res, `event: v3_conv\ndata: ${payload}\n\n`);
|
|
244
|
+
for (const payload of native.respPayloads) sseWrite(res, `event: v3_resp\ndata: ${payload}\n\n`);
|
|
245
|
+
// kv-cache / context_window sources: rebuild the newest completed
|
|
246
|
+
// mainAgent rows. Depth 3 mirrors the legacy scan-ring fallback (review
|
|
247
|
+
// P2-e): the newest main may lack usage/cached-content or be a
|
|
248
|
+
// synthesis-gated orphan — earlier candidates then provide the values.
|
|
249
|
+
const mains = meta.rows.filter((r) => r.mainAgent && !r.inProgress).slice(-3);
|
|
250
|
+
for (const m of mains) {
|
|
251
|
+
const detail = await readV2SingleEntry(src, { seq: m.seq, sessionId: m.sessionId });
|
|
252
|
+
if (detail && detail.entry) mainAgentRawRing.push(detail.entry);
|
|
253
|
+
}
|
|
254
|
+
} catch (err) { reportSwallowed('sse.v2_requests', err); }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
sseWrite(res, `event: load_end\ndata: {}\n\n`);
|
|
198
258
|
|
|
199
259
|
// S10a: v2 sources no longer invoke onScan (the two-pass window never
|
|
200
260
|
// stringifies out-of-window entries) — the adapter returns the newest-main
|
|
@@ -227,10 +287,10 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
227
287
|
|
|
228
288
|
// 发送最新 MainAgent 的 KV-Cache 和 context_window
|
|
229
289
|
if (latestKvCache) {
|
|
230
|
-
res
|
|
290
|
+
sseWrite(res, `event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
|
|
231
291
|
}
|
|
232
292
|
if (latestContextWindow) {
|
|
233
|
-
res
|
|
293
|
+
sseWrite(res, `event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
|
|
234
294
|
pushedContextWindow = true;
|
|
235
295
|
}
|
|
236
296
|
// Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
|
|
@@ -250,7 +310,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
250
310
|
const effectiveSize = adaptContextWindow(contextSize, inputTokens);
|
|
251
311
|
const usedPct = effectiveSize > 0 ? Math.round((totalTokens / effectiveSize) * 100) : 0;
|
|
252
312
|
const data = { ...cw, context_window_size: effectiveSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
|
|
253
|
-
res
|
|
313
|
+
sseWrite(res, `event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
|
|
254
314
|
}
|
|
255
315
|
} catch { }
|
|
256
316
|
}
|
|
@@ -274,16 +334,26 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
274
334
|
// API endpoint
|
|
275
335
|
async function requests(req, res) {
|
|
276
336
|
// 异步流式 JSON 数组输出,不做 reconstruct,发原始条目
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
337
|
+
// flush:false —— whole-stream response: the client parses the array only
|
|
338
|
+
// when complete, so per-macrotask flush boundaries would just cost ratio.
|
|
339
|
+
sseHead(req, res, 200, { 'Content-Type': 'application/json' }, { flush: false });
|
|
340
|
+
try {
|
|
341
|
+
sseWrite(res, '[');
|
|
342
|
+
let first = true;
|
|
343
|
+
await streamRawEntriesAsync(getLiveLogSource(), (raw) => {
|
|
344
|
+
if (!first) sseWrite(res, ',');
|
|
345
|
+
sseWrite(res, enrichRawIfNeeded(raw));
|
|
346
|
+
first = false;
|
|
347
|
+
});
|
|
348
|
+
sseWrite(res, ']');
|
|
349
|
+
wireEnd(res);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// Mid-stream failure (e.g. session dir converted/removed while streaming):
|
|
352
|
+
// without this catch the rejection propagates through the dispatcher and
|
|
353
|
+
// kills the process. Headers are already sent — close the stream cleanly.
|
|
354
|
+
console.error('[api-requests]', err && err.stack || err);
|
|
355
|
+
wireEnd(res);
|
|
356
|
+
}
|
|
287
357
|
}
|
|
288
358
|
|
|
289
359
|
export const eventsRoutes = [
|
package/server/routes/logs.js
CHANGED
|
@@ -7,6 +7,7 @@ import { LOG_DIR } from '../../findcc.js';
|
|
|
7
7
|
import { _projectName, _v2Writer } from '../interceptor.js';
|
|
8
8
|
import { listV2Logs, listLocalLogs, countListedV1Files, deleteLogFiles, validateLogPath } from '../lib/log-management.js';
|
|
9
9
|
import { countLogEntries, streamRawEntriesAsync, readTailEntries } from '../lib/log-stream.js';
|
|
10
|
+
import { sseHead, sseWrite, wireEnd } from '../lib/wire-compress.js';
|
|
10
11
|
import { dirSizeSync } from '../lib/v2/layout.js';
|
|
11
12
|
import { extractV2Zip } from '../lib/log-zip.js';
|
|
12
13
|
import { startConvert, stopConvert, convertStatus } from '../lib/v2/convert-manager.js';
|
|
@@ -191,7 +192,9 @@ async function localLog(req, res, parsedUrl) {
|
|
|
191
192
|
const filePath = validateLogPath(LOG_DIR, file);
|
|
192
193
|
const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 0, 500);
|
|
193
194
|
|
|
194
|
-
|
|
195
|
+
// Negotiated Content-Encoding (br|identity) — every subsequent byte MUST
|
|
196
|
+
// go through sseWrite/wireEnd (see server/lib/wire-compress.js).
|
|
197
|
+
sseHead(req, res, 200, {
|
|
195
198
|
'Content-Type': 'text/event-stream',
|
|
196
199
|
'Cache-Control': 'no-cache',
|
|
197
200
|
'Connection': 'keep-alive',
|
|
@@ -200,24 +203,24 @@ async function localLog(req, res, parsedUrl) {
|
|
|
200
203
|
if (limitVal > 0) {
|
|
201
204
|
// 尾部加载模式:跳过 countLogEntries,只读文件末尾
|
|
202
205
|
const { entries, hasMore, oldestTimestamp, estimatedTotal } = await readTailEntries(filePath, { limit: limitVal });
|
|
203
|
-
res
|
|
206
|
+
sseWrite(res, `event: load_start\ndata: ${JSON.stringify({ total: estimatedTotal, incremental: false, hasMore, oldestTs: oldestTimestamp })}\n\n`);
|
|
204
207
|
for (const raw of entries) {
|
|
205
|
-
res
|
|
206
|
-
res
|
|
207
|
-
res
|
|
208
|
+
sseWrite(res, 'event: load_chunk\ndata: [');
|
|
209
|
+
sseWrite(res, raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
|
|
210
|
+
sseWrite(res, ']\n\n');
|
|
208
211
|
}
|
|
209
212
|
} else {
|
|
210
213
|
// 全量加载模式(向后兼容)
|
|
211
214
|
const total = await countLogEntries(filePath);
|
|
212
|
-
res
|
|
215
|
+
sseWrite(res, `event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
|
|
213
216
|
await streamRawEntriesAsync(filePath, (raw) => {
|
|
214
|
-
res
|
|
215
|
-
res
|
|
216
|
-
res
|
|
217
|
+
sseWrite(res, 'event: load_chunk\ndata: [');
|
|
218
|
+
sseWrite(res, raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
|
|
219
|
+
sseWrite(res, ']\n\n');
|
|
217
220
|
});
|
|
218
221
|
}
|
|
219
|
-
res
|
|
220
|
-
res
|
|
222
|
+
sseWrite(res, `event: load_end\ndata: {}\n\n`);
|
|
223
|
+
wireEnd(res);
|
|
221
224
|
} catch (err) {
|
|
222
225
|
// 如果 headers 未发送,返回 JSON 错误;否则关闭连接
|
|
223
226
|
// 落 stderr 让用户在 ccv 终端能看到具体原因(SSE onerror 在客户端
|
|
@@ -228,7 +231,7 @@ async function localLog(req, res, parsedUrl) {
|
|
|
228
231
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
229
232
|
res.end(JSON.stringify({ error: err.message }));
|
|
230
233
|
} else {
|
|
231
|
-
res
|
|
234
|
+
wireEnd(res); // flush the encoder trailer if the stream was compressed
|
|
232
235
|
}
|
|
233
236
|
}
|
|
234
237
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Wire v3 routes (V3.S1+): on-demand single-entry detail for the metadata-row
|
|
2
|
+
// request list. Registered unconditionally — harmless while CCV_WIRE_V3 is off
|
|
3
|
+
// (nothing calls it), so the route itself needs no flag gate.
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { LOG_DIR } from '../../findcc.js';
|
|
6
|
+
import { validateLogPath } from '../lib/log-management.js';
|
|
7
|
+
import { resolveSessionDirName } from '../lib/v2/session-select.js';
|
|
8
|
+
import { readV2SingleEntry } from '../lib/v2/adapter.js';
|
|
9
|
+
import { sseHead, sseWrite, wireEnd } from '../lib/wire-compress.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/v2-entry?file=v2:<project>/<dirToken>&seq=N[&sid=<uuid>]
|
|
13
|
+
* dirToken is normally the session dir basename (`<ts>_<uuid>`); a bare UUID
|
|
14
|
+
* (the client only holds `_seqEpoch="v2:<uuid>"`) is resolved to the basename
|
|
15
|
+
* BEFORE validateLogPath — validation expects an existing directory path.
|
|
16
|
+
* `sid` disambiguates teammate rows: the fold runs over the leader dir, the
|
|
17
|
+
* target is keyed (sid, seq); omitted = match seq in any folded session.
|
|
18
|
+
*/
|
|
19
|
+
async function v2Entry(req, res, parsedUrl) {
|
|
20
|
+
const file = parsedUrl.searchParams.get('file') || '';
|
|
21
|
+
const seq = parseInt(parsedUrl.searchParams.get('seq'), 10);
|
|
22
|
+
const sid = parsedUrl.searchParams.get('sid') || null;
|
|
23
|
+
const m = /^v2:([^/]+)\/([^/]+)$/.exec(file);
|
|
24
|
+
if (!m || !Number.isInteger(seq) || seq < 0) {
|
|
25
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
26
|
+
res.end(JSON.stringify({ error: 'expected file=v2:<project>/<session>&seq=<n>' }));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
// UUID → dir-basename resolution first (rename/-c adoption safe), then the
|
|
31
|
+
// same path validation every other v2 ref goes through.
|
|
32
|
+
const [, project, dirToken] = m;
|
|
33
|
+
const resolved = resolveSessionDirName(join(LOG_DIR, project), dirToken) || dirToken;
|
|
34
|
+
const sessionDir = validateLogPath(LOG_DIR, `v2:${project}/${resolved}`);
|
|
35
|
+
const result = await readV2SingleEntry(sessionDir, { seq, sessionId: sid });
|
|
36
|
+
if (!result) {
|
|
37
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
38
|
+
res.end(JSON.stringify({ error: 'entry not found' }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Whole-stream JSON (like /api/requests): compress without per-event flush.
|
|
42
|
+
sseHead(req, res, 200, { 'Content-Type': 'application/json' }, { flush: false });
|
|
43
|
+
// slots are raw JSON strings — compose without re-parse/re-stringify.
|
|
44
|
+
sseWrite(res, `{"entry":${result.entry},"prevMain":${result.prevMain ?? 'null'}}`);
|
|
45
|
+
wireEnd(res);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('[v2-entry]', file, err && err.stack || err);
|
|
48
|
+
if (!res.headersSent) {
|
|
49
|
+
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
|
|
50
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
52
|
+
} else {
|
|
53
|
+
wireEnd(res);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const v2Routes = [
|
|
59
|
+
{ method: 'GET', match: 'exact', path: '/api/v2-entry', handler: v2Entry },
|
|
60
|
+
];
|
|
@@ -17,6 +17,7 @@ const RESUME_FLAGS = new Set(['-r', '--resume']);
|
|
|
17
17
|
import { unwatchAllWorkflows } from '../lib/workflow-watcher.js';
|
|
18
18
|
import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
19
19
|
import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
|
|
20
|
+
import { sseWrite } from '../lib/wire-compress.js';
|
|
20
21
|
|
|
21
22
|
function workspacesList(req, res, parsedUrl, isLocal, deps) {
|
|
22
23
|
import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
|
|
@@ -91,7 +92,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
91
92
|
const startedPayload = `event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath, claudeProjectModel: readClaudeProjectModel(wsPath) })}\n\n`;
|
|
92
93
|
deps.clients.forEach(client => {
|
|
93
94
|
try {
|
|
94
|
-
client
|
|
95
|
+
sseWrite(client, startedPayload);
|
|
95
96
|
} catch {}
|
|
96
97
|
});
|
|
97
98
|
|
|
@@ -102,15 +103,15 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
102
103
|
const wsReloadSource = getLiveLogSource();
|
|
103
104
|
const wsReloadTotal = await countLogEntries(wsReloadSource);
|
|
104
105
|
deps.clients.forEach(client => {
|
|
105
|
-
try { client
|
|
106
|
+
try { sseWrite(client, `event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
|
|
106
107
|
});
|
|
107
108
|
await streamRawEntriesAsync(wsReloadSource, (raw) => {
|
|
108
109
|
deps.clients.forEach(client => {
|
|
109
|
-
try { client
|
|
110
|
+
try { sseWrite(client, 'event: load_chunk\ndata: ['); sseWrite(client, raw.replace(/\n/g, '')); sseWrite(client, ']\n\n'); } catch {}
|
|
110
111
|
});
|
|
111
112
|
});
|
|
112
113
|
deps.clients.forEach(client => {
|
|
113
|
-
try { client
|
|
114
|
+
try { sseWrite(client, `event: load_end\ndata: {}\n\n`); } catch {}
|
|
114
115
|
});
|
|
115
116
|
|
|
116
117
|
// 1.7.0 迁移引导(P2):切进的项目仍有未转换 v1 日志 → 对存量连接广播
|
|
@@ -119,7 +120,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
119
120
|
const mig = migrationStatus(LOG_DIR, result.projectName || '');
|
|
120
121
|
if (mig.pending) {
|
|
121
122
|
const frame = `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`;
|
|
122
|
-
deps.clients.forEach(client => { try { client
|
|
123
|
+
deps.clients.forEach(client => { try { sseWrite(client, frame); } catch {} });
|
|
123
124
|
}
|
|
124
125
|
} catch (e) { reportSwallowed('sse.migrate_prompt', e); }
|
|
125
126
|
|
|
@@ -185,7 +186,7 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
|
185
186
|
// 通知所有 SSE 客户端
|
|
186
187
|
deps.clients.forEach(client => {
|
|
187
188
|
try {
|
|
188
|
-
client
|
|
189
|
+
sseWrite(client, `event: workspace_stopped\ndata: {}\n\n`);
|
|
189
190
|
} catch {}
|
|
190
191
|
});
|
|
191
192
|
|
package/server/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { execFile, exec, spawn } from 'node:child_process';
|
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
10
|
import { Worker } from 'node:worker_threads';
|
|
11
11
|
import { isPathContained } from './lib/file-api.js';
|
|
12
|
+
import { sseWrite, isWireV3Enabled } from './lib/wire-compress.js';
|
|
12
13
|
import { setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, loadAskStore as askStoreLoad } from './lib/ask-store.js';
|
|
13
14
|
import { ASK_TIMEOUT_MS, ASK_WAITER_REAP_INTERVAL_MS, ASK_WAITER_LIVENESS_MS } from './lib/ask-constants.js';
|
|
14
15
|
import { reapDeadAskWaiters, sweepOrphanedDiskAsks } from './lib/ask-reaper.js';
|
|
@@ -33,6 +34,7 @@ import { searchRoutes } from './routes/search.js';
|
|
|
33
34
|
import { workspacesRoutes } from './routes/workspaces.js';
|
|
34
35
|
import { expertRoutes } from './routes/expert.js';
|
|
35
36
|
import { eventsRoutes } from './routes/events.js';
|
|
37
|
+
import { v2Routes } from './routes/v2.js';
|
|
36
38
|
import { askPermRoutes } from './routes/ask-perm.js';
|
|
37
39
|
import { teamRoutes } from './routes/team.js';
|
|
38
40
|
import { authRoutes } from './routes/auth.js';
|
|
@@ -315,6 +317,22 @@ const MAX_POST_BODY = 10 * 1024 * 1024;
|
|
|
315
317
|
// 防止长会话把数十 MB 历史一次性灌进浏览器导致 renderer OOM。
|
|
316
318
|
// 用户显式 ?limit=0 可恢复全量加载(power-user 逃生口)。
|
|
317
319
|
const DEFAULT_EVENTS_LIMIT = 1000;
|
|
320
|
+
// Wire v3 flag (V3.S6: DEFAULT ON) — read ONCE at startup; a read/UI-shape
|
|
321
|
+
// toggle the server and client must agree on per process, not per request
|
|
322
|
+
// (unlike wire-compress's per-connection content negotiation). Broadcast to
|
|
323
|
+
// clients via the server_config SSE frame; tests inject deps.wireV3 directly.
|
|
324
|
+
// CCV_WIRE_V3=0 (or =off) is the escape hatch back to the legacy full-entry
|
|
325
|
+
// wire — kept (not deleted) for one release cycle before the legacy live UI
|
|
326
|
+
// path is removed; v1 legacy FILES always use the legacy pipeline regardless.
|
|
327
|
+
const WIRE_V3 = isWireV3Enabled(process.env.CCV_WIRE_V3);
|
|
328
|
+
// Build stamp broadcast in server_config: a tab that survives a server
|
|
329
|
+
// upgrade reconnects with a STALE JS bundle that lacks the new wire's
|
|
330
|
+
// listeners (silent empty session). The fresh bundle compares this stamp on
|
|
331
|
+
// reconnect and reloads itself on mismatch (review P2-a; heals upgrades from
|
|
332
|
+
// this version onward — older bundles predate the guard).
|
|
333
|
+
const SERVER_BUILD = (() => {
|
|
334
|
+
try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version || ''; } catch { return ''; }
|
|
335
|
+
})();
|
|
318
336
|
// SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
|
|
319
337
|
// 调高至 30s:大会话首屏/重连重放时,渲染器(尤其 Windows 浏览器,大 DOM layout 更重)
|
|
320
338
|
// 可能短暂忙到来不及排空 socket。过早剔除会触发「断开→EventSource 自动重连→再次重放」
|
|
@@ -427,6 +445,7 @@ function _ensureV2LiveFeed() {
|
|
|
427
445
|
runParallelHook,
|
|
428
446
|
// The stats worker's v2 units are session dirs (stats-worker.js v9).
|
|
429
447
|
notifyStatsWorker,
|
|
448
|
+
wireV3: WIRE_V3,
|
|
430
449
|
});
|
|
431
450
|
}
|
|
432
451
|
return _v2LiveFeed;
|
|
@@ -591,6 +610,8 @@ const deps = {
|
|
|
591
610
|
WINDOWS_RESERVED_NAMES,
|
|
592
611
|
DEFAULT_EVENTS_LIMIT,
|
|
593
612
|
SSE_BACKPRESSURE_TIMEOUT_MS,
|
|
613
|
+
wireV3: WIRE_V3,
|
|
614
|
+
serverBuild: SERVER_BUILD,
|
|
594
615
|
IGNORED_PATTERNS,
|
|
595
616
|
isCliMode,
|
|
596
617
|
isSdkMode,
|
|
@@ -623,6 +644,7 @@ const _routes = [
|
|
|
623
644
|
...workspacesRoutes,
|
|
624
645
|
...expertRoutes,
|
|
625
646
|
...eventsRoutes,
|
|
647
|
+
...v2Routes,
|
|
626
648
|
...askPermRoutes,
|
|
627
649
|
...teamRoutes,
|
|
628
650
|
...dingtalkRoutes,
|
|
@@ -2375,7 +2397,7 @@ if (!isWorkspaceMode) {
|
|
|
2375
2397
|
pendingMajorUpdate = { version: result.remoteVersion, source: result.status };
|
|
2376
2398
|
const payload = JSON.stringify(pendingMajorUpdate);
|
|
2377
2399
|
clients.forEach(client => {
|
|
2378
|
-
try { client
|
|
2400
|
+
try { sseWrite(client, `event: update_major_available\ndata: ${payload}\n\n`); } catch { }
|
|
2379
2401
|
});
|
|
2380
2402
|
} else if (result.status === 'upgrading_in_background') {
|
|
2381
2403
|
console.error(`[CC Viewer] background upgrade to ${result.remoteVersion} started (active after next launch)`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 从 URL 中提取 LAN 访问 token,附加到所有 API 请求 / WebSocket 握手
|
|
2
|
+
const _urlToken = new URLSearchParams(window.location.search).get('token');
|
|
3
|
+
|
|
4
|
+
// 反向代理子路径部署: 从 <base> 标签或 SSR 注入的全局变量读取 base path,
|
|
5
|
+
// 使 API/WebSocket 等动态请求也能正确路由到代理后端。
|
|
6
|
+
export function getBasePath() {
|
|
7
|
+
if (typeof window !== 'undefined' && window.__CCV_BASE_PATH__) return window.__CCV_BASE_PATH__;
|
|
8
|
+
if (typeof document === 'undefined' || !document.querySelector) return '';
|
|
9
|
+
const base = document.querySelector('base');
|
|
10
|
+
if (base && base.href) return base.getAttribute('href');
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 把 token 追加到任意 URL(HTTP path 或 ws:// 完整 URL 皆可)。无 token 时原样返回。
|
|
15
|
+
// WS 握手必须也带 token —— 否则启用鉴权后远程「?token=」终端会被 socket.destroy()。
|
|
16
|
+
export function appendToken(url) {
|
|
17
|
+
if (!_urlToken) return url;
|
|
18
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
19
|
+
return `${url}${sep}token=${_urlToken}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function apiUrl(path) {
|
|
23
|
+
const base = getBasePath();
|
|
24
|
+
const fullPath = base ? base.replace(/\/$/, '') + path : path;
|
|
25
|
+
return appendToken(fullPath);
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Decision helper for ApprovalModal's fallback AskQuestionForm.
|
|
2
|
+
//
|
|
3
|
+
// The Question modal body is normally filled by the transcript tool_use block
|
|
4
|
+
// portaling an AskQuestionForm into the modal's ask slot (see askPortalMatcher.js).
|
|
5
|
+
// When no block portals in — a stale replayed ask whose block is old history, or a
|
|
6
|
+
// fresh ask arriving before transcript ingest — the modal would show an empty body.
|
|
7
|
+
// The fallback renders a form directly from the authoritative pending-ask broadcast.
|
|
8
|
+
//
|
|
9
|
+
// Contract: `slotOccupied` is DOM occupancy of the ask slot (MutationObserver on
|
|
10
|
+
// childList + sync initial read); `graceElapsed` flips true ASK_FALLBACK_GRACE_MS
|
|
11
|
+
// after the ask becomes visible, giving the real portal one commit cycle so fresh
|
|
12
|
+
// asks never flash the fallback before the portaled form mounts.
|
|
13
|
+
|
|
14
|
+
export const ASK_FALLBACK_GRACE_MS = 120;
|
|
15
|
+
|
|
16
|
+
export function shouldRenderAskFallback({ isAskActive, slotOccupied, graceElapsed, questions, submitHandler }) {
|
|
17
|
+
if (!isAskActive || slotOccupied || !graceElapsed) return false;
|
|
18
|
+
if (!Array.isArray(questions) || questions.length === 0) return false;
|
|
19
|
+
if (typeof submitHandler !== 'function') return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isPlaceholderAskId } from './askPortalMatcher.js';
|
|
2
|
+
|
|
3
|
+
// AskUserQuestion options[].description is schema-optional; centralize fallback
|
|
4
|
+
// so AskQuestionForm and ChatMessage recap stay aligned.
|
|
5
|
+
|
|
6
|
+
export function optionAriaLabel(opt) {
|
|
7
|
+
if (!opt || opt.label == null) return '';
|
|
8
|
+
return opt.description
|
|
9
|
+
? `${opt.label}: ${opt.description}`
|
|
10
|
+
: String(opt.label);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasOptionDescription(opt) {
|
|
14
|
+
return Boolean(opt && opt.description);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Pick the questions the interactive AskUserQuestion card actually renders.
|
|
18
|
+
//
|
|
19
|
+
// The modal body is filled by the inline tool_use block (reconstructed from the log
|
|
20
|
+
// stream) portaling into the modal's askSlot — it does not read pendingAsk.questions
|
|
21
|
+
// (the authoritative copy broadcast over WS ask-hook-pending / sdk-ask-pending, fully
|
|
22
|
+
// parsed server-side before the hook fires) directly. During streaming of a large
|
|
23
|
+
// payload there is a window where the modal is already open but the reconstructed
|
|
24
|
+
// block's input is still partial JSON. Crucially, partial-JSON parsing materializes
|
|
25
|
+
// the outer questions[] array shape before the element content arrives, so the
|
|
26
|
+
// streamed copy can be HOLLOW AT EQUAL LENGTH (e.g. two question objects with empty
|
|
27
|
+
// text/options while big options[].preview strings are still streaming — the
|
|
28
|
+
// 2026-07-04 blank-popup regression; a strictly-longer length heuristic missed it).
|
|
29
|
+
//
|
|
30
|
+
// So: for the currently pending ask (toolId === lastPendingAskId, id-matched), always
|
|
31
|
+
// prefer the authoritative copy. It is complete by construction, and the submit path
|
|
32
|
+
// (handleAskQuestionSubmit in askFlowController) maps answer indices through the same
|
|
33
|
+
// authoritative _askHookQuestions copy — rendering it keeps rendered indices aligned
|
|
34
|
+
// with the mapping array. Historical blocks never match lastPendingAskId and always
|
|
35
|
+
// keep their own streamed questions (history view unchanged).
|
|
36
|
+
//
|
|
37
|
+
// Legacy/no-id servers use placeholder pendingAsk ids ('__ask__', 'ask_<ts>_<rnd>')
|
|
38
|
+
// that can never equal the real tool_use id. There the authoritative copy is only
|
|
39
|
+
// borrowed when the streamed render would otherwise be completely empty — it can fill
|
|
40
|
+
// a blank popup but never override visible content (a stale legacy pendingAsk must
|
|
41
|
+
// not inject a previous ask's questions over a rendered one).
|
|
42
|
+
export function resolveAskQuestions(streamedQuestions, toolId, lastPendingAskId, pendingAsk) {
|
|
43
|
+
const streamed = Array.isArray(streamedQuestions) ? streamedQuestions : [];
|
|
44
|
+
if (!pendingAsk || toolId == null || toolId !== lastPendingAskId) {
|
|
45
|
+
return streamed;
|
|
46
|
+
}
|
|
47
|
+
const idMatches = pendingAsk.id === toolId;
|
|
48
|
+
if (!idMatches && !(isPlaceholderAskId(pendingAsk.id) && streamed.length === 0)) {
|
|
49
|
+
return streamed;
|
|
50
|
+
}
|
|
51
|
+
const authoritative = Array.isArray(pendingAsk.questions) ? pendingAsk.questions : [];
|
|
52
|
+
if (authoritative.length === 0) return streamed; // defensive: WS handlers guarantee non-empty
|
|
53
|
+
if (streamed.length > authoritative.length) return streamed; // never shrink (belt-and-braces)
|
|
54
|
+
return authoritative;
|
|
55
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal 决策:判断 inline AskQuestionForm(在 ChatMessage 里渲染的某条 tool_use)
|
|
3
|
+
* 是否应该 createPortal 到 ApprovalModal 的 askSlot。
|
|
4
|
+
*
|
|
5
|
+
* activeAskId 三种合法形态(与 server 端协议绑定,改一边必须改另一边):
|
|
6
|
+
*
|
|
7
|
+
* 1. `toolu_xxx` — SDK 模式 / 新版 Claude Code 透传的真实 tool_use_id
|
|
8
|
+
* → 走 strict 匹配:activeAskId === toolId
|
|
9
|
+
*
|
|
10
|
+
* 2. `__ask__` — LEGACY 老 server(pre-Map 协议)的单槽占位
|
|
11
|
+
* → 走 owner 通配:仅 owner-idx 锁定的那条 ChatMessage(lastPendingAskId === toolId)命中
|
|
12
|
+
*
|
|
13
|
+
* 3. `ask_${ts}_${rnd}` — 老 Claude Code PreToolUse hook payload 不带 tool_use_id 时
|
|
14
|
+
* server.js 自生成的 fallback id(修复 modal 标题在但内容空白的根因)
|
|
15
|
+
* → 走 owner 通配:与 __ask__ 同语义
|
|
16
|
+
*
|
|
17
|
+
* 协议锚点:
|
|
18
|
+
* - server 端 fallback id 生成:server.js(搜索 `ask_${Date.now()}` 定位)
|
|
19
|
+
* - server 端 LEGACY 占位推送:server.js 推送 ask-hook-pending 事件时 id 为 '__ask__'
|
|
20
|
+
* - 前端 setState pendingAsk:src/components/chat/ChatView.jsx
|
|
21
|
+
*
|
|
22
|
+
* 改 server 端 fallback id 格式时,必须同步更新此文件的 isFallbackId 检测。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const FALLBACK_ID_PREFIX = 'ask_';
|
|
26
|
+
const LEGACY_PLACEHOLDER_ID = '__ask__';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when the given pending-ask id is a placeholder that can never equal a real
|
|
30
|
+
* tool_use id: the LEGACY single-slot '__ask__' or a server-generated 'ask_<ts>_<rnd>'
|
|
31
|
+
* fallback id. Consumers (e.g. resolveAskQuestions) use this to apply the same
|
|
32
|
+
* owner-wildcard semantics as shouldPortalAskForm on the legacy paths.
|
|
33
|
+
*/
|
|
34
|
+
export function isPlaceholderAskId(id) {
|
|
35
|
+
return id === LEGACY_PLACEHOLDER_ID
|
|
36
|
+
|| (typeof id === 'string' && id.startsWith(FALLBACK_ID_PREFIX));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {*} activeAskId ApprovalPortalContext.Consumer 提供的 activeAskId(modal 当前 active ask 的 id)
|
|
41
|
+
* @param {*} toolId 当前 ChatMessage 里 tool_use 块的 id(toolu_xxx)
|
|
42
|
+
* @param {*} lastPendingAskId ChatMessage 收到的 owner-idx 派生信号;非 owner 永远为 null
|
|
43
|
+
* @returns {boolean} true 表示当前 ChatMessage 应该 portal 到 askSlot
|
|
44
|
+
*/
|
|
45
|
+
export function shouldPortalAskForm(activeAskId, toolId, lastPendingAskId) {
|
|
46
|
+
if (activeAskId == null) return false;
|
|
47
|
+
// strict:SDK / 新 Claude Code 真实 tool_use_id
|
|
48
|
+
if (String(activeAskId) === String(toolId)) return true;
|
|
49
|
+
// owner 通配:__ask__(LEGACY 老 server)∪ ask_*(server fallback)
|
|
50
|
+
// 必须同时满足 owner-idx 锁定(lastPendingAskId === toolId),否则会让历史所有真实 toolId
|
|
51
|
+
// 都被通配命中,重现旧的双份 portal bug
|
|
52
|
+
if (isPlaceholderAskId(activeAskId) && lastPendingAskId === toolId) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|