chore: rebuild CentOS7 release package
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"updatedAt": "2026-06-30T15:42:26.421Z",
|
"updatedAt": "2026-07-02T06:02:53.126Z",
|
||||||
"replies": []
|
"replies": []
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -23,6 +23,32 @@ function createAgentRuntime(deps) {
|
|||||||
getRuntimeSessionId,
|
getRuntimeSessionId,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
function readRuntimePositiveIntEnv(name, fallback, options = {}) {
|
||||||
|
const raw = Number.parseInt(String(processEnv?.[name] || ''), 10);
|
||||||
|
const min = Number.isFinite(options.min) ? options.min : 1;
|
||||||
|
const max = Number.isFinite(options.max) ? options.max : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNTIME_FULL_TEXT_MAX_CHARS = readRuntimePositiveIntEnv(
|
||||||
|
'CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS',
|
||||||
|
256 * 1024,
|
||||||
|
{ min: 4096 },
|
||||||
|
);
|
||||||
|
const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n';
|
||||||
|
|
||||||
|
function keepTail(value, maxLen) {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text;
|
||||||
|
const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length);
|
||||||
|
return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCappedText(current, addition, maxLen = RUNTIME_FULL_TEXT_MAX_CHARS) {
|
||||||
|
return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
function tomlString(value) {
|
function tomlString(value) {
|
||||||
return JSON.stringify(String(value || ''));
|
return JSON.stringify(String(value || ''));
|
||||||
}
|
}
|
||||||
@@ -330,7 +356,7 @@ function createAgentRuntime(deps) {
|
|||||||
? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`)
|
? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`)
|
||||||
: '';
|
: '';
|
||||||
const chunk = separator + nextText;
|
const chunk = separator + nextText;
|
||||||
entry.fullText += chunk;
|
entry.fullText = appendCappedText(entry.fullText || '', chunk);
|
||||||
return chunk;
|
return chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +409,7 @@ function createAgentRuntime(deps) {
|
|||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
entry.fullText += block.text;
|
entry.fullText = appendCappedText(entry.fullText || '', block.text);
|
||||||
sendRuntime(entry, sessionId, { type: 'text_delta', text: block.text });
|
sendRuntime(entry, sessionId, { type: 'text_delta', text: block.text });
|
||||||
} else if (block.type === 'tool_use') {
|
} else if (block.type === 'tool_use') {
|
||||||
const toolInput = sanitizeToolInput(block.name, block.input);
|
const toolInput = sanitizeToolInput(block.name, block.input);
|
||||||
|
|||||||
208
public/app.js
208
public/app.js
@@ -2,7 +2,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const ASSET_VERSION = '20260629-ccweb-prompt-dark-theme';
|
const ASSET_VERSION = '20260702-visible-no-rerender';
|
||||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||||
const RENDER_DEBOUNCE = 100;
|
const RENDER_DEBOUNCE = 100;
|
||||||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||||||
@@ -76,6 +76,8 @@
|
|||||||
const OLD_SESSION_COLLAPSE_DAYS = 7;
|
const OLD_SESSION_COLLAPSE_DAYS = 7;
|
||||||
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
|
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
|
||||||
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
|
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
|
||||||
|
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
|
||||||
|
const SESSION_RESUME_FALLBACK_MS = 1_500;
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
const MODEL_OPTIONS = [
|
||||||
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
||||||
@@ -183,6 +185,8 @@
|
|||||||
let loadedHistorySessionId = null;
|
let loadedHistorySessionId = null;
|
||||||
let activeSessionLoad = null;
|
let activeSessionLoad = null;
|
||||||
let sessionLoadOverlayTimer = null;
|
let sessionLoadOverlayTimer = null;
|
||||||
|
let sessionLoadRequestTimer = null;
|
||||||
|
let sessionResumeFallbackTimer = null;
|
||||||
let sidebarSwipe = null;
|
let sidebarSwipe = null;
|
||||||
let activeComposerToken = null;
|
let activeComposerToken = null;
|
||||||
let composerSuggestionTimer = null;
|
let composerSuggestionTimer = null;
|
||||||
@@ -202,6 +206,7 @@
|
|||||||
let codexAppApprovalModal = null;
|
let codexAppApprovalModal = null;
|
||||||
let pendingNewSessionRequest = null;
|
let pendingNewSessionRequest = null;
|
||||||
let pendingSessionSwitchRequest = null;
|
let pendingSessionSwitchRequest = null;
|
||||||
|
let pendingSessionResumeRequest = null;
|
||||||
let sessionSwitchRequestSeq = 0;
|
let sessionSwitchRequestSeq = 0;
|
||||||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||||
let pendingInitialSessionLoad = false;
|
let pendingInitialSessionLoad = false;
|
||||||
@@ -4126,7 +4131,11 @@
|
|||||||
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
|
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
|
||||||
closeFileBrowser();
|
closeFileBrowser();
|
||||||
}
|
}
|
||||||
const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning);
|
const hasStreamingElement = !!document.getElementById('streaming-msg');
|
||||||
|
const preserveStreaming = !!(options.preserveStreaming &&
|
||||||
|
snapshot.sessionId === currentSessionId &&
|
||||||
|
snapshot.isRunning &&
|
||||||
|
(isGenerating || currentSessionRunning || hasStreamingElement));
|
||||||
if (isGenerating && !preserveStreaming) {
|
if (isGenerating && !preserveStreaming) {
|
||||||
isGenerating = false;
|
isGenerating = false;
|
||||||
generatingSessionId = null;
|
generatingSessionId = null;
|
||||||
@@ -4220,6 +4229,59 @@
|
|||||||
sessionLoadOverlayTimer = null;
|
sessionLoadOverlayTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSessionLoadRequestTimer() {
|
||||||
|
if (!sessionLoadRequestTimer) return;
|
||||||
|
clearTimeout(sessionLoadRequestTimer);
|
||||||
|
sessionLoadRequestTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionResumeFallbackTimer() {
|
||||||
|
if (!sessionResumeFallbackTimer) return;
|
||||||
|
clearTimeout(sessionResumeFallbackTimer);
|
||||||
|
sessionResumeFallbackTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingSessionSwitchRequest(sessionId, requestId) {
|
||||||
|
if (!pendingSessionSwitchRequest) return;
|
||||||
|
if (sessionId && pendingSessionSwitchRequest.sessionId !== sessionId) return;
|
||||||
|
if (requestId && pendingSessionSwitchRequest.requestId !== requestId) return;
|
||||||
|
pendingSessionSwitchRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingSessionResumeRequest(sessionId, requestId) {
|
||||||
|
if (!pendingSessionResumeRequest) return;
|
||||||
|
if (sessionId && pendingSessionResumeRequest.sessionId !== sessionId) return;
|
||||||
|
if (requestId && pendingSessionResumeRequest.requestId !== requestId) return;
|
||||||
|
pendingSessionResumeRequest = null;
|
||||||
|
clearSessionResumeFallbackTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifySessionLoadTimeout(sessionId) {
|
||||||
|
const meta = sessionId ? getSessionMeta(sessionId) : null;
|
||||||
|
const title = meta?.title ? `“${meta.title}”` : '所选会话';
|
||||||
|
appendError(`${title} 加载超时,已取消本次切换。若服务刚重启或网络恢复后,可重新点击该会话。`, {
|
||||||
|
transient: true,
|
||||||
|
autoDismissMs: 9000,
|
||||||
|
preserveScroll: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSessionLoadRequestTimeout(sessionId, requestId) {
|
||||||
|
clearSessionLoadRequestTimer();
|
||||||
|
if (!sessionId || !requestId) return;
|
||||||
|
sessionLoadRequestTimer = setTimeout(() => {
|
||||||
|
sessionLoadRequestTimer = null;
|
||||||
|
if (!activeSessionLoad ||
|
||||||
|
activeSessionLoad.sessionId !== sessionId ||
|
||||||
|
activeSessionLoad.requestId !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingSessionSwitchRequest(sessionId, requestId);
|
||||||
|
clearSessionLoading(sessionId);
|
||||||
|
notifySessionLoadTimeout(sessionId);
|
||||||
|
}, SESSION_LOAD_REQUEST_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) {
|
function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) {
|
||||||
clearSessionLoadOverlayTimer();
|
clearSessionLoadOverlayTimer();
|
||||||
document.body.classList.remove('session-loading-active');
|
document.body.classList.remove('session-loading-active');
|
||||||
@@ -4236,10 +4298,19 @@
|
|||||||
|
|
||||||
function setSessionLoading(sessionId, options = {}) {
|
function setSessionLoading(sessionId, options = {}) {
|
||||||
clearSessionLoadOverlayTimer();
|
clearSessionLoadOverlayTimer();
|
||||||
|
clearSessionLoadRequestTimer();
|
||||||
const loading = !!sessionId;
|
const loading = !!sessionId;
|
||||||
const blocking = options.blocking !== false;
|
const blocking = options.blocking !== false;
|
||||||
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
|
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
|
||||||
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId, overlayReleased: false } : null;
|
activeSessionLoad = loading ? {
|
||||||
|
sessionId,
|
||||||
|
blocking,
|
||||||
|
snapshot: null,
|
||||||
|
requestId,
|
||||||
|
overlayReleased: false,
|
||||||
|
recoverCurrent: options.recoverCurrent === true,
|
||||||
|
} : null;
|
||||||
|
if (loading) scheduleSessionLoadRequestTimeout(sessionId, requestId);
|
||||||
const showOverlay = !!(loading && blocking);
|
const showOverlay = !!(loading && blocking);
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
document.body.classList.add('session-loading-active');
|
document.body.classList.add('session-loading-active');
|
||||||
@@ -4315,6 +4386,7 @@
|
|||||||
blocking: options.blocking !== false,
|
blocking: options.blocking !== false,
|
||||||
label: options.label || '',
|
label: options.label || '',
|
||||||
requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
|
requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
|
||||||
|
recoverCurrent: options.recoverCurrent === true,
|
||||||
};
|
};
|
||||||
if (ws && ws.readyState === 1 && wsAuthenticated) {
|
if (ws && ws.readyState === 1 && wsAuthenticated) {
|
||||||
flushPendingSessionSwitch();
|
flushPendingSessionSwitch();
|
||||||
@@ -4323,9 +4395,42 @@
|
|||||||
if (!ws || ws.readyState > 1) connect();
|
if (!ws || ws.readyState > 1) connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestSessionResume(sessionId, options = {}) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
pendingSessionResumeRequest = {
|
||||||
|
sessionId,
|
||||||
|
requestId: options.requestId || createSessionSwitchRequestId(sessionId),
|
||||||
|
};
|
||||||
|
if (ws && ws.readyState === 1 && wsAuthenticated) {
|
||||||
|
flushPendingSessionResume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ws || ws.readyState > 1) connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSessionResumeFallback(sessionId, requestId) {
|
||||||
|
clearSessionResumeFallbackTimer();
|
||||||
|
if (!sessionId || !requestId) return;
|
||||||
|
sessionResumeFallbackTimer = setTimeout(() => {
|
||||||
|
sessionResumeFallbackTimer = null;
|
||||||
|
if (!pendingSessionResumeRequest ||
|
||||||
|
pendingSessionResumeRequest.sessionId !== sessionId ||
|
||||||
|
pendingSessionResumeRequest.requestId !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingSessionResumeRequest = null;
|
||||||
|
requestSessionLoad(sessionId, {
|
||||||
|
blocking: false,
|
||||||
|
label: '正在恢复运行输出…',
|
||||||
|
requestId: createSessionSwitchRequestId(sessionId),
|
||||||
|
recoverCurrent: true,
|
||||||
|
});
|
||||||
|
}, SESSION_RESUME_FALLBACK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function flushPendingSessionSwitch() {
|
function flushPendingSessionSwitch() {
|
||||||
if (!pendingSessionSwitchRequest) return;
|
if (!pendingSessionSwitchRequest) return false;
|
||||||
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return;
|
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
|
||||||
const request = pendingSessionSwitchRequest;
|
const request = pendingSessionSwitchRequest;
|
||||||
pendingSessionSwitchRequest = null;
|
pendingSessionSwitchRequest = null;
|
||||||
if (!activeSessionLoad) {
|
if (!activeSessionLoad) {
|
||||||
@@ -4333,9 +4438,20 @@
|
|||||||
blocking: request.blocking,
|
blocking: request.blocking,
|
||||||
label: request.label || undefined,
|
label: request.label || undefined,
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
|
recoverCurrent: request.recoverCurrent === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId }));
|
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingSessionResume() {
|
||||||
|
if (!pendingSessionResumeRequest) return false;
|
||||||
|
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
|
||||||
|
const request = pendingSessionResumeRequest;
|
||||||
|
ws.send(JSON.stringify({ type: 'resume_session', sessionId: request.sessionId, requestId: request.requestId }));
|
||||||
|
scheduleSessionResumeFallback(request.sessionId, request.requestId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCachedSession(sessionId) {
|
function showCachedSession(sessionId) {
|
||||||
@@ -4883,9 +4999,15 @@
|
|||||||
if (activeSessionLoad?.sessionId && !isPageUnloading) {
|
if (activeSessionLoad?.sessionId && !isPageUnloading) {
|
||||||
pendingSessionSwitchRequest = {
|
pendingSessionSwitchRequest = {
|
||||||
sessionId: activeSessionLoad.sessionId,
|
sessionId: activeSessionLoad.sessionId,
|
||||||
blocking: activeSessionLoad.blocking,
|
blocking: false,
|
||||||
label: sessionLoadingLabel?.textContent || '',
|
label: sessionLoadingLabel?.textContent || '',
|
||||||
requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
|
requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
|
||||||
|
recoverCurrent: activeSessionLoad.recoverCurrent === true,
|
||||||
|
};
|
||||||
|
} else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) {
|
||||||
|
pendingSessionResumeRequest = {
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
requestId: createSessionSwitchRequestId(currentSessionId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
clearSessionLoading();
|
clearSessionLoading();
|
||||||
@@ -4934,7 +5056,18 @@
|
|||||||
document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
|
document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
|
||||||
loginOverlay.hidden = true;
|
loginOverlay.hidden = true;
|
||||||
app.hidden = false;
|
app.hidden = false;
|
||||||
flushPendingSessionSwitch();
|
const flushedSessionSwitch = flushPendingSessionSwitch();
|
||||||
|
const flushedSessionResume = flushedSessionSwitch ? false : flushPendingSessionResume();
|
||||||
|
if (!flushedSessionSwitch &&
|
||||||
|
!flushedSessionResume &&
|
||||||
|
!pendingSessionSwitchRequest &&
|
||||||
|
!pendingSessionResumeRequest &&
|
||||||
|
currentSessionId &&
|
||||||
|
(isGenerating || currentSessionRunning)) {
|
||||||
|
requestSessionResume(currentSessionId, {
|
||||||
|
requestId: createSessionSwitchRequestId(currentSessionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
send({ type: 'get_codex_config' });
|
send({ type: 'get_codex_config' });
|
||||||
// Check if must change password
|
// Check if must change password
|
||||||
if (msg.mustChangePassword) {
|
if (msg.mustChangePassword) {
|
||||||
@@ -4944,6 +5077,8 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pendingSessionSwitchRequest = null;
|
pendingSessionSwitchRequest = null;
|
||||||
|
pendingSessionResumeRequest = null;
|
||||||
|
clearSessionResumeFallbackTimer();
|
||||||
clearSessionLoading();
|
clearSessionLoading();
|
||||||
authToken = null;
|
authToken = null;
|
||||||
wsAuthenticated = false;
|
wsAuthenticated = false;
|
||||||
@@ -5007,7 +5142,7 @@
|
|||||||
const canSwitchToSessionInfo = matchesActiveLoad
|
const canSwitchToSessionInfo = matchesActiveLoad
|
||||||
|| matchesPendingNewSession
|
|| matchesPendingNewSession
|
||||||
|| msg.sessionId === currentSessionId
|
|| msg.sessionId === currentSessionId
|
||||||
|| (!currentSessionId && !activeLoad && !pendingNewSession)
|
|| (!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession)
|
||||||
|| (!messageRequestId && !activeLoad && !pendingNewSession);
|
|| (!messageRequestId && !activeLoad && !pendingNewSession);
|
||||||
mergeSessionListSnapshot(snapshot);
|
mergeSessionListSnapshot(snapshot);
|
||||||
if (matchesActiveLoad) {
|
if (matchesActiveLoad) {
|
||||||
@@ -5041,6 +5176,10 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_history_chunk':
|
case 'session_history_chunk':
|
||||||
|
if (activeSessionLoad?.recoverCurrent && activeSessionLoad.sessionId === msg.sessionId) {
|
||||||
|
if (!msg.remaining) finalizeLoadedSession(msg.sessionId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
|
if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
|
||||||
const blocking = isBlockingSessionLoad(msg.sessionId);
|
const blocking = isBlockingSessionLoad(msg.sessionId);
|
||||||
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
|
if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
|
||||||
@@ -5274,10 +5413,11 @@
|
|||||||
|
|
||||||
case 'resume_generating':
|
case 'resume_generating':
|
||||||
if (!isCurrentSessionEvent(msg)) break;
|
if (!isCurrentSessionEvent(msg)) break;
|
||||||
|
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
|
||||||
// Server has an active process for this session — resume streaming
|
// Server has an active process for this session — resume streaming
|
||||||
setCurrentSessionRunningState(true);
|
setCurrentSessionRunningState(true);
|
||||||
if (!isGenerating || !document.getElementById('streaming-msg')) {
|
if (!isGenerating || !document.getElementById('streaming-msg')) {
|
||||||
startGenerating(msg.sessionId || currentSessionId);
|
startGenerating(msg.sessionId || currentSessionId, { follow: isNearBottom() });
|
||||||
} else {
|
} else {
|
||||||
updateGenerationControls();
|
updateGenerationControls();
|
||||||
toolGroupCount = 0;
|
toolGroupCount = 0;
|
||||||
@@ -5312,8 +5452,24 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'resume_session_result':
|
||||||
if (!isCurrentSessionEvent(msg)) break;
|
if (!isCurrentSessionEvent(msg)) break;
|
||||||
|
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
|
||||||
|
setCurrentSessionRunningState(!!msg.isRunning);
|
||||||
|
if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) {
|
||||||
|
updateGenerationControls();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
const errorRequestId = String(msg.requestId || '');
|
||||||
|
const matchesActiveLoadError = !!(activeSessionLoad &&
|
||||||
|
(!msg.sessionId || msg.sessionId === activeSessionLoad.sessionId) &&
|
||||||
|
(!errorRequestId || errorRequestId === activeSessionLoad.requestId));
|
||||||
|
if (!matchesActiveLoadError && !isCurrentSessionEvent(msg)) break;
|
||||||
|
if (matchesActiveLoadError) {
|
||||||
|
clearPendingSessionSwitchRequest(activeSessionLoad.sessionId, activeSessionLoad.requestId);
|
||||||
|
}
|
||||||
if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
|
if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
|
||||||
const request = pendingNewSessionRequest;
|
const request = pendingNewSessionRequest;
|
||||||
pendingNewSessionRequest = null;
|
pendingNewSessionRequest = null;
|
||||||
@@ -5401,8 +5557,12 @@
|
|||||||
showToast(`「${msg.title}」任务完成`, msg.sessionId);
|
showToast(`「${msg.title}」任务完成`, msg.sessionId);
|
||||||
showBrowserNotification(msg.title);
|
showBrowserNotification(msg.title);
|
||||||
if (msg.sessionId === currentSessionId) {
|
if (msg.sessionId === currentSessionId) {
|
||||||
// Reload current session to show completed response
|
// 当前用户如果正在向上翻历史,不要自动整屏重绘并把滚动条拽回底部。
|
||||||
openSession(msg.sessionId, { forceSync: true, blocking: false });
|
if (isNearBottom()) {
|
||||||
|
openSession(msg.sessionId, { forceSync: true, blocking: false });
|
||||||
|
} else {
|
||||||
|
send({ type: 'list_sessions' });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
send({ type: 'list_sessions' });
|
send({ type: 'list_sessions' });
|
||||||
}
|
}
|
||||||
@@ -5435,9 +5595,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Generating State ---
|
// --- Generating State ---
|
||||||
function startGenerating(sessionId = currentSessionId) {
|
function startGenerating(sessionId = currentSessionId, options = {}) {
|
||||||
const targetSessionId = sessionId || currentSessionId || null;
|
const targetSessionId = sessionId || currentSessionId || null;
|
||||||
if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
|
if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
|
||||||
|
const shouldFollow = options.follow !== false;
|
||||||
isGenerating = true;
|
isGenerating = true;
|
||||||
generatingSessionId = targetSessionId;
|
generatingSessionId = targetSessionId;
|
||||||
setCurrentSessionRunningState(true);
|
setCurrentSessionRunningState(true);
|
||||||
@@ -5469,7 +5630,11 @@
|
|||||||
bubble.appendChild(toolsDiv);
|
bubble.appendChild(toolsDiv);
|
||||||
syncAssistantLastSectionButton(msgEl);
|
syncAssistantLastSectionButton(msgEl);
|
||||||
messagesDiv.appendChild(msgEl);
|
messagesDiv.appendChild(msgEl);
|
||||||
scrollToBottom();
|
if (shouldFollow) {
|
||||||
|
scrollToBottom();
|
||||||
|
} else {
|
||||||
|
updateScrollbar();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10210,20 +10375,21 @@
|
|||||||
rememberPw.checked = true;
|
rememberPw.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visibility change: re-sync state when user returns to tab (critical for mobile)
|
// 页签切回只做轻量状态同步:不要因为 visible 事件整屏重载当前会话,
|
||||||
|
// 否则 renderMessages() 会重建气泡并把滚动条拉回底部。
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState !== 'visible') return;
|
if (document.visibilityState !== 'visible') return;
|
||||||
if (!ws || ws.readyState > 1) {
|
if (!ws || ws.readyState > 1) {
|
||||||
// WS is dead, force reconnect
|
// WS is dead, force reconnect
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
connect();
|
connect();
|
||||||
} else if (ws.readyState === 1 && currentSessionId) {
|
} else if (ws.readyState === 1 && wsAuthenticated) {
|
||||||
// Preserve active streaming UI when returning to foreground.
|
if (currentSessionId && !activeSessionLoad && (isGenerating || currentSessionRunning)) {
|
||||||
if (isGenerating || currentSessionRunning) {
|
requestSessionResume(currentSessionId, {
|
||||||
send({ type: 'load_session', sessionId: currentSessionId });
|
requestId: createSessionSwitchRequestId(currentSessionId),
|
||||||
} else {
|
});
|
||||||
beginSessionSwitch(currentSessionId, { blocking: false, force: true });
|
|
||||||
}
|
}
|
||||||
|
send({ type: 'list_sessions' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,6 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
|
||||||
<script src="app.js?v=20260629-ccweb-prompt-dark-theme"></script>
|
<script src="app.js?v=20260702-visible-no-rerender"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -614,6 +614,102 @@ function assertFrontendMcpReloadContract() {
|
|||||||
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
|
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertSessionSwitchResilienceContract() {
|
||||||
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||||
|
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
|
||||||
|
|
||||||
|
assert(frontendSource.includes('SESSION_LOAD_REQUEST_TIMEOUT_MS'), 'Frontend should define a hard timeout for session load requests');
|
||||||
|
assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer');
|
||||||
|
assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests');
|
||||||
|
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests');
|
||||||
|
assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests');
|
||||||
|
assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session');
|
||||||
|
assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
|
||||||
|
assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
|
||||||
|
assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
|
||||||
|
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
|
||||||
|
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
|
||||||
|
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
|
||||||
|
const visibilitySource = visibilityStart >= 0 && visibilityEnd > visibilityStart
|
||||||
|
? frontendSource.slice(visibilityStart, visibilityEnd)
|
||||||
|
: '';
|
||||||
|
assert(visibilitySource.includes('requestSessionResume(currentSessionId'), 'Visibility restore should use lightweight resume for running conversations');
|
||||||
|
assert(visibilitySource.includes("send({ type: 'list_sessions' });"), 'Visibility restore should only refresh session list for idle conversations');
|
||||||
|
assert(!visibilitySource.includes("type: 'load_session'"), 'Visibility restore must not force load_session and rerender the current conversation');
|
||||||
|
assert(!visibilitySource.includes('beginSessionSwitch('), 'Visibility restore must not force a session switch and scroll to bottom');
|
||||||
|
assert(
|
||||||
|
/else if \(currentSessionId && \(isGenerating \|\| currentSessionRunning\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
|
||||||
|
'Frontend should queue a lightweight resume request for running conversations when WS closes'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
|
||||||
|
'Frontend should resume the current running session after auth without forcing load_session'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/case 'background_done':[\s\S]*?if \(isNearBottom\(\)\)[\s\S]*?openSession\(msg\.sessionId,\s*\{ forceSync: true, blocking: false \}\)[\s\S]*?send\(\{ type: 'list_sessions' \}\)/.test(frontendSource),
|
||||||
|
'Frontend should not auto-rerender the current session on background_done while the user is reading history'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/function startGenerating\(sessionId = currentSessionId, options = \{\}\)[\s\S]*?const shouldFollow = options\.follow !== false[\s\S]*?if \(shouldFollow\)/.test(frontendSource),
|
||||||
|
'Frontend resume_generating should be able to create a streaming bubble without forcing scroll-to-bottom'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/const preserveStreaming = !!\(options\.preserveStreaming[\s\S]*?\(isGenerating \|\| currentSessionRunning \|\| hasStreamingElement\)\)/.test(frontendSource),
|
||||||
|
'Frontend should preserve the current running conversation DOM even when isGenerating was stale'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/case 'session_history_chunk':[\s\S]*?activeSessionLoad\?\.recoverCurrent[\s\S]*?break;/.test(frontendSource),
|
||||||
|
'Frontend should ignore history chunks from recovery fallback to avoid duplicating/redrawing bubbles'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/function flushPendingSessionSwitch\(\)[\s\S]*?return false[\s\S]*?return true/.test(frontendSource),
|
||||||
|
'Frontend flushPendingSessionSwitch should report whether it sent a load_session request'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/pendingSessionSwitchRequest\s*=\s*\{[\s\S]*?blocking:\s*false[\s\S]*?requestId:\s*activeSessionLoad\.requestId/.test(frontendSource),
|
||||||
|
'Frontend should retry load_session after WS close without re-blocking the UI'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
frontendSource.includes('!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession'),
|
||||||
|
'Frontend should not let late requestId-bearing session_info switch an idle/welcome view'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
frontendSource.includes('matchesActiveLoadError') && frontendSource.includes('errorRequestId === activeSessionLoad.requestId'),
|
||||||
|
'Frontend should clear active session load errors by requestId'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(serverSource.includes('SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS'), 'Server should cap session message size for WebSocket transport');
|
||||||
|
assert(serverSource.includes("case 'resume_session':"), 'Server should accept lightweight resume_session requests');
|
||||||
|
assert(serverSource.includes('function handleResumeSession'), 'Server should implement lightweight running-session resume');
|
||||||
|
assert(serverSource.includes('function attachActiveRuntimeToWs'), 'Server should share runtime re-attach logic without sending session_info first');
|
||||||
|
assert(serverSource.includes('WS_HEARTBEAT_MAX_MISSES'), 'Server should tolerate missed WebSocket pongs before terminating');
|
||||||
|
assert(serverSource.includes('function markWsActivity'), 'Server should mark WebSocket activity on send/message/pong');
|
||||||
|
assert(
|
||||||
|
serverSource.includes('markWsActivity(ws);') && serverSource.includes('markWsActivity(ws);'),
|
||||||
|
'Server should call markWsActivity from WebSocket send and message paths'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/hasRecentActivity[\s\S]*?_ccWebMissedPongs[\s\S]*?ws_heartbeat_terminate/.test(serverSource),
|
||||||
|
'Server heartbeat should consider recent activity and log before terminating stale sockets'
|
||||||
|
);
|
||||||
|
assert(serverSource.includes('function sanitizeMessagesForTransport'), 'Server should sanitize session messages before WebSocket transport');
|
||||||
|
assert(
|
||||||
|
/function splitHistoryMessages\(messages[\s\S]*?const list = sanitizeMessagesForTransport\(messages\)/.test(serverSource),
|
||||||
|
'Server history split should operate on transport-sanitized messages'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/function wsSend\(ws, data\)[\s\S]*?try\s*\{[\s\S]*?JSON\.stringify\(data\)[\s\S]*?catch/.test(serverSource),
|
||||||
|
'Server wsSend should guard JSON serialization failures'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
runtimeSource.includes('CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS') &&
|
||||||
|
runtimeSource.includes('function appendCappedText') &&
|
||||||
|
runtimeSource.includes('entry.fullText = appendCappedText'),
|
||||||
|
'Classic runtime should cap accumulated fullText in memory'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
assertFrontendGenerationControlsContract();
|
assertFrontendGenerationControlsContract();
|
||||||
assertFrontendComposerMcpContract();
|
assertFrontendComposerMcpContract();
|
||||||
@@ -621,6 +717,7 @@ async function main() {
|
|||||||
assertFrontendMarkdownLinkContract();
|
assertFrontendMarkdownLinkContract();
|
||||||
assertMockCodexAppPromptUserNotTextTriggered();
|
assertMockCodexAppPromptUserNotTextTriggered();
|
||||||
assertFrontendMcpReloadContract();
|
assertFrontendMcpReloadContract();
|
||||||
|
assertSessionSwitchResilienceContract();
|
||||||
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||||
const configDir = path.join(tempRoot, 'config');
|
const configDir = path.join(tempRoot, 'config');
|
||||||
@@ -756,6 +853,13 @@ async function main() {
|
|||||||
const { ws, messages, token } = await connectWs(port, password);
|
const { ws, messages, token } = await connectWs(port, password);
|
||||||
|
|
||||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||||
|
ws.send(JSON.stringify({ type: 'load_session', sessionId: 'missing-session', requestId: 'reg-missing-session' }));
|
||||||
|
const missingSessionLoad = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'error' &&
|
||||||
|
msg.code === 'session_not_found' &&
|
||||||
|
msg.sessionId === 'missing-session'
|
||||||
|
));
|
||||||
|
assert(missingSessionLoad.requestId === 'reg-missing-session', 'Missing load_session error should echo requestId for frontend cleanup');
|
||||||
|
|
||||||
const pickerRoot = path.join(homeDir, 'picker-root');
|
const pickerRoot = path.join(homeDir, 'picker-root');
|
||||||
mkdirp(path.join(pickerRoot, 'alpha'));
|
mkdirp(path.join(pickerRoot, 'alpha'));
|
||||||
@@ -1977,7 +2081,11 @@ async function main() {
|
|||||||
assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');
|
assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported');
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId }));
|
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId }));
|
||||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedCodexApp.sessionId));
|
await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_list' &&
|
||||||
|
!msg.sessions.some((s) => s.id === importedCodexApp.sessionId) &&
|
||||||
|
!fs.existsSync(importedCodexAppPath)
|
||||||
|
));
|
||||||
assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON');
|
assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON');
|
||||||
assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery');
|
assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery');
|
||||||
|
|
||||||
|
|||||||
229
server.js
229
server.js
@@ -105,6 +105,10 @@ const SESSION_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_MES
|
|||||||
const SESSION_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 });
|
const SESSION_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 });
|
||||||
const SESSION_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 });
|
const SESSION_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 });
|
||||||
const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE', 80, { min: 1, max: 1000 });
|
const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE', 80, { min: 1, max: 1000 });
|
||||||
|
const SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS', 64 * 1024, { min: 4096 });
|
||||||
|
const SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS', 8 * 1024, { min: 1024 });
|
||||||
|
const SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS', 16 * 1024, { min: 1024 });
|
||||||
|
const SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE', 40, { min: 1, max: 1000 });
|
||||||
const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 });
|
const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 });
|
||||||
const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 });
|
const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 });
|
||||||
const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 });
|
const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 });
|
||||||
@@ -1276,7 +1280,25 @@ const MIME_TYPES = {
|
|||||||
// === Utility Functions ===
|
// === Utility Functions ===
|
||||||
|
|
||||||
function wsSend(ws, data) {
|
function wsSend(ws, data) {
|
||||||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
|
if (!ws || ws.readyState !== 1) return;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
markWsActivity(ws);
|
||||||
|
} catch (err) {
|
||||||
|
plog('WARN', 'ws_send_failed', {
|
||||||
|
wsId: ws._ccWebId || null,
|
||||||
|
type: data?.type || null,
|
||||||
|
sessionId: data?.sessionId ? String(data.sessionId).slice(0, 8) : null,
|
||||||
|
error: err?.message || String(err || ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markWsActivity(ws) {
|
||||||
|
if (!ws) return;
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws._ccWebLastActivityAt = Date.now();
|
||||||
|
ws._ccWebMissedPongs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
@@ -3508,6 +3530,21 @@ function sanitizeMessagesForPersist(messages, limits = {}) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeMessageForTransport(message) {
|
||||||
|
return sanitizeMessageForPersist(message, {
|
||||||
|
contentMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS,
|
||||||
|
toolInputMaxChars: SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS,
|
||||||
|
toolResultMaxChars: SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS,
|
||||||
|
maxToolCalls: SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE,
|
||||||
|
metaMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMessagesForTransport(messages) {
|
||||||
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
|
return list.map((message) => sanitizeMessageForTransport(message));
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeSessionForPersist(session, limits = {}) {
|
function sanitizeSessionForPersist(session, limits = {}) {
|
||||||
const output = {};
|
const output = {};
|
||||||
const skipKeys = new Set([
|
const skipKeys = new Set([
|
||||||
@@ -3755,7 +3792,7 @@ function sessionModelLabel(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitHistoryMessages(messages, options = {}) {
|
function splitHistoryMessages(messages, options = {}) {
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = sanitizeMessagesForTransport(messages);
|
||||||
if (list.length <= INITIAL_HISTORY_COUNT) {
|
if (list.length <= INITIAL_HISTORY_COUNT) {
|
||||||
return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length };
|
return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length };
|
||||||
}
|
}
|
||||||
@@ -6147,6 +6184,7 @@ const server = http.createServer((req, res) => {
|
|||||||
// === WebSocket Server ===
|
// === WebSocket Server ===
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
||||||
|
const WS_HEARTBEAT_MAX_MISSES = readPositiveIntEnv('CC_WEB_WS_HEARTBEAT_MAX_MISSES', 4, { min: 2, max: 20 });
|
||||||
|
|
||||||
server.on('upgrade', (req, socket, head) => {
|
server.on('upgrade', (req, socket, head) => {
|
||||||
let pathname = '';
|
let pathname = '';
|
||||||
@@ -6184,14 +6222,17 @@ wss.on('connection', (ws, req) => {
|
|||||||
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
||||||
const wsConnectTime = new Date().toISOString();
|
const wsConnectTime = new Date().toISOString();
|
||||||
ws.isAlive = true;
|
ws.isAlive = true;
|
||||||
|
ws._ccWebMissedPongs = 0;
|
||||||
ws._ccWebId = wsId;
|
ws._ccWebId = wsId;
|
||||||
|
markWsActivity(ws);
|
||||||
plog('INFO', 'ws_connect', { wsId });
|
plog('INFO', 'ws_connect', { wsId });
|
||||||
|
|
||||||
ws.on('pong', () => {
|
ws.on('pong', () => {
|
||||||
ws.isAlive = true;
|
markWsActivity(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
|
markWsActivity(ws);
|
||||||
let msg;
|
let msg;
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(raw);
|
msg = JSON.parse(raw);
|
||||||
@@ -6244,6 +6285,9 @@ wss.on('connection', (ws, req) => {
|
|||||||
case 'load_session':
|
case 'load_session':
|
||||||
handleLoadSession(ws, msg);
|
handleLoadSession(ws, msg);
|
||||||
break;
|
break;
|
||||||
|
case 'resume_session':
|
||||||
|
handleResumeSession(ws, msg);
|
||||||
|
break;
|
||||||
case 'load_history_page':
|
case 'load_history_page':
|
||||||
handleLoadHistoryPage(ws, msg);
|
handleLoadHistoryPage(ws, msg);
|
||||||
break;
|
break;
|
||||||
@@ -6339,19 +6383,36 @@ wss.on('connection', (ws, req) => {
|
|||||||
|
|
||||||
// WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。
|
// WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。
|
||||||
const wsHeartbeatTimer = setInterval(() => {
|
const wsHeartbeatTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
for (const client of wss.clients) {
|
for (const client of wss.clients) {
|
||||||
|
if (client.readyState !== 1) continue;
|
||||||
if (client.isAlive === false) {
|
if (client.isAlive === false) {
|
||||||
client.terminate();
|
const lastActivityAt = Number(client._ccWebLastActivityAt || 0);
|
||||||
continue;
|
const hasRecentActivity = lastActivityAt > 0 && now - lastActivityAt < WS_HEARTBEAT_INTERVAL_MS * 2;
|
||||||
|
if (hasRecentActivity) {
|
||||||
|
client._ccWebMissedPongs = 0;
|
||||||
|
} else {
|
||||||
|
client._ccWebMissedPongs = Number(client._ccWebMissedPongs || 0) + 1;
|
||||||
|
}
|
||||||
|
if (client._ccWebMissedPongs >= WS_HEARTBEAT_MAX_MISSES) {
|
||||||
|
plog('WARN', 'ws_heartbeat_terminate', {
|
||||||
|
wsId: client._ccWebId || null,
|
||||||
|
missedPongs: client._ccWebMissedPongs,
|
||||||
|
lastActivityAgeMs: lastActivityAt ? now - lastActivityAt : null,
|
||||||
|
});
|
||||||
|
client.terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
client._ccWebMissedPongs = 0;
|
||||||
}
|
}
|
||||||
client.isAlive = false;
|
client.isAlive = false;
|
||||||
if (client.readyState === 1) {
|
try {
|
||||||
try {
|
client.ping();
|
||||||
client.ping();
|
} catch (err) {
|
||||||
} catch (err) {
|
plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message });
|
||||||
plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message });
|
client.terminate();
|
||||||
client.terminate();
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, WS_HEARTBEAT_INTERVAL_MS);
|
}, WS_HEARTBEAT_INTERVAL_MS);
|
||||||
@@ -7191,7 +7252,7 @@ function createPersistentConversationSession(args = {}, options = {}) {
|
|||||||
|
|
||||||
function buildSessionInfoPayload(session) {
|
function buildSessionInfoPayload(session) {
|
||||||
const waitState = crossConversationWaitState(session.id);
|
const waitState = crossConversationWaitState(session.id);
|
||||||
const messages = session.messages || [];
|
const messages = sanitizeMessagesForTransport(session.messages || []);
|
||||||
return {
|
return {
|
||||||
type: 'session_info',
|
type: 'session_info',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
@@ -7268,9 +7329,14 @@ function handleLoadHistoryPage(ws, msg = {}) {
|
|||||||
const sessionId = sanitizeId(msg.sessionId || '');
|
const sessionId = sanitizeId(msg.sessionId || '');
|
||||||
const session = loadSession(sessionId);
|
const session = loadSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return wsSend(ws, { type: 'error', message: 'Session not found' });
|
return wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'error',
|
||||||
|
code: 'session_not_found',
|
||||||
|
sessionId,
|
||||||
|
message: 'Session not found',
|
||||||
|
}, msg));
|
||||||
}
|
}
|
||||||
const list = Array.isArray(session.messages) ? session.messages : [];
|
const list = sanitizeMessagesForTransport(session.messages);
|
||||||
const requestedBefore = Number.parseInt(String(msg.before || ''), 10);
|
const requestedBefore = Number.parseInt(String(msg.before || ''), 10);
|
||||||
const before = Number.isFinite(requestedBefore)
|
const before = Number.isFinite(requestedBefore)
|
||||||
? Math.max(0, Math.min(list.length, requestedBefore))
|
? Math.max(0, Math.min(list.length, requestedBefore))
|
||||||
@@ -7288,12 +7354,93 @@ function handleLoadHistoryPage(ws, msg = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachActiveRuntimeToWs(ws, sessionId, source = {}) {
|
||||||
|
if (activeProcesses.has(sessionId)) {
|
||||||
|
const entry = activeProcesses.get(sessionId);
|
||||||
|
entry.ws = ws;
|
||||||
|
entry.wsDisconnectTime = null; // clear disconnect marker
|
||||||
|
plog('INFO', 'ws_resume_attach', {
|
||||||
|
sessionId: sessionId.slice(0, 8),
|
||||||
|
pid: entry.pid,
|
||||||
|
responseLen: (entry.fullText || '').length,
|
||||||
|
});
|
||||||
|
wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'resume_generating',
|
||||||
|
sessionId,
|
||||||
|
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
|
||||||
|
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
|
||||||
|
}, source));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeCodexAppTurns.has(sessionId)) {
|
||||||
|
const entry = activeCodexAppTurns.get(sessionId);
|
||||||
|
entry.ws = ws;
|
||||||
|
entry.wsDisconnectTime = null;
|
||||||
|
plog('INFO', 'codex_app_ws_resume_attach', {
|
||||||
|
sessionId: sessionId.slice(0, 8),
|
||||||
|
threadId: entry.threadId || null,
|
||||||
|
turnId: entry.turnId || null,
|
||||||
|
responseLen: (entry.fullText || '').length,
|
||||||
|
});
|
||||||
|
wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'resume_generating',
|
||||||
|
sessionId,
|
||||||
|
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
|
||||||
|
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
|
||||||
|
}, source));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeCodexAppGoalCommands.has(sessionId)) {
|
||||||
|
const entry = activeCodexAppGoalCommands.get(sessionId);
|
||||||
|
entry.ws = ws;
|
||||||
|
entry.wsDisconnectTime = null;
|
||||||
|
wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'system_message',
|
||||||
|
sessionId,
|
||||||
|
message: '正在同步 Goal...',
|
||||||
|
}, source));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResumeSession(ws, msg = {}) {
|
||||||
|
const sessionId = sanitizeId(msg.sessionId || '');
|
||||||
|
const session = loadSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'error',
|
||||||
|
code: 'session_not_found',
|
||||||
|
sessionId,
|
||||||
|
message: 'Session not found',
|
||||||
|
}, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
detachWsFromActiveRuntimes(ws);
|
||||||
|
wsSessionMap.set(ws, sessionId);
|
||||||
|
const attached = attachActiveRuntimeToWs(ws, sessionId, msg);
|
||||||
|
wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'resume_session_result',
|
||||||
|
sessionId,
|
||||||
|
isRunning: isSessionRunning(sessionId),
|
||||||
|
attached,
|
||||||
|
}, msg));
|
||||||
|
}
|
||||||
|
|
||||||
function handleLoadSession(ws, msg) {
|
function handleLoadSession(ws, msg) {
|
||||||
const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId);
|
const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId);
|
||||||
reconcilePendingCrossConversationReplies();
|
reconcilePendingCrossConversationReplies();
|
||||||
const session = loadSession(sessionId);
|
const session = loadSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return wsSend(ws, { type: 'error', message: 'Session not found' });
|
return wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'error',
|
||||||
|
code: 'session_not_found',
|
||||||
|
sessionId,
|
||||||
|
message: 'Session not found',
|
||||||
|
}, msg));
|
||||||
}
|
}
|
||||||
flushPendingCrossConversationReplies(sessionId);
|
flushPendingCrossConversationReplies(sessionId);
|
||||||
const refreshedSession = loadSession(sessionId) || session;
|
const refreshedSession = loadSession(sessionId) || session;
|
||||||
@@ -7367,44 +7514,8 @@ function handleLoadSession(ws, msg) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume streaming if process is still active
|
// Resume streaming if process is still active.
|
||||||
if (activeProcesses.has(sessionId)) {
|
attachActiveRuntimeToWs(ws, sessionId);
|
||||||
const entry = activeProcesses.get(sessionId);
|
|
||||||
entry.ws = ws;
|
|
||||||
entry.wsDisconnectTime = null; // clear disconnect marker
|
|
||||||
plog('INFO', 'ws_resume_attach', {
|
|
||||||
sessionId: sessionId.slice(0, 8),
|
|
||||||
pid: entry.pid,
|
|
||||||
responseLen: (entry.fullText || '').length,
|
|
||||||
});
|
|
||||||
wsSend(ws, {
|
|
||||||
type: 'resume_generating',
|
|
||||||
sessionId,
|
|
||||||
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
|
|
||||||
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
|
|
||||||
});
|
|
||||||
} else if (activeCodexAppTurns.has(sessionId)) {
|
|
||||||
const entry = activeCodexAppTurns.get(sessionId);
|
|
||||||
entry.ws = ws;
|
|
||||||
entry.wsDisconnectTime = null;
|
|
||||||
plog('INFO', 'codex_app_ws_resume_attach', {
|
|
||||||
sessionId: sessionId.slice(0, 8),
|
|
||||||
threadId: entry.threadId || null,
|
|
||||||
turnId: entry.turnId || null,
|
|
||||||
responseLen: (entry.fullText || '').length,
|
|
||||||
});
|
|
||||||
wsSend(ws, {
|
|
||||||
type: 'resume_generating',
|
|
||||||
sessionId,
|
|
||||||
text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
|
|
||||||
toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
|
|
||||||
});
|
|
||||||
} else if (activeCodexAppGoalCommands.has(sessionId)) {
|
|
||||||
const entry = activeCodexAppGoalCommands.get(sessionId);
|
|
||||||
entry.ws = ws;
|
|
||||||
entry.wsDisconnectTime = null;
|
|
||||||
wsSend(ws, { type: 'system_message', sessionId, message: '正在同步 Goal...' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sqlQuote(value) {
|
function sqlQuote(value) {
|
||||||
@@ -9940,10 +10051,11 @@ function handleImportNativeSession(ws, msg) {
|
|||||||
};
|
};
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
wsSessionMap.set(ws, id);
|
wsSessionMap.set(ws, id);
|
||||||
|
const transportMessages = sanitizeMessagesForTransport(session.messages);
|
||||||
wsSend(ws, attachClientRequestId({
|
wsSend(ws, attachClientRequestId({
|
||||||
type: 'session_info',
|
type: 'session_info',
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
messages: session.messages,
|
messages: transportMessages,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
mode: session.permissionMode,
|
mode: session.permissionMode,
|
||||||
@@ -9953,6 +10065,8 @@ function handleImportNativeSession(ws, msg) {
|
|||||||
totalCost: session.totalCost || 0,
|
totalCost: session.totalCost || 0,
|
||||||
totalUsage: session.totalUsage || null,
|
totalUsage: session.totalUsage || null,
|
||||||
updated: session.updated,
|
updated: session.updated,
|
||||||
|
historyTotal: transportMessages.length,
|
||||||
|
historyBaseIndex: 0,
|
||||||
hasUnread: false,
|
hasUnread: false,
|
||||||
historyPending: false,
|
historyPending: false,
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
@@ -10109,10 +10223,11 @@ function handleImportCodexSession(ws, msg) {
|
|||||||
|
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
wsSessionMap.set(ws, id);
|
wsSessionMap.set(ws, id);
|
||||||
|
const transportMessages = sanitizeMessagesForTransport(session.messages);
|
||||||
wsSend(ws, attachClientRequestId({
|
wsSend(ws, attachClientRequestId({
|
||||||
type: 'session_info',
|
type: 'session_info',
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
messages: session.messages,
|
messages: transportMessages,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
mode: session.permissionMode,
|
mode: session.permissionMode,
|
||||||
@@ -10122,6 +10237,8 @@ function handleImportCodexSession(ws, msg) {
|
|||||||
totalCost: session.totalCost || 0,
|
totalCost: session.totalCost || 0,
|
||||||
totalUsage: session.totalUsage || null,
|
totalUsage: session.totalUsage || null,
|
||||||
updated: session.updated,
|
updated: session.updated,
|
||||||
|
historyTotal: transportMessages.length,
|
||||||
|
historyBaseIndex: 0,
|
||||||
hasUnread: false,
|
hasUnread: false,
|
||||||
historyPending: false,
|
historyPending: false,
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user