mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-21 10:27:31 +02:00
修复多会话任务栏抖动、会话跳回与停止失效 (#264)
* fix(web): stabilize active task ordering * fix(chat): preserve navigation during conversation startup * fix(tasks): guarantee hard cancellation * fix(chat): bind navigation and hard stop targets * fix(chat): prevent replay from reclaiming navigation
This commit is contained in:
@@ -6,6 +6,7 @@ const vm = require('node:vm');
|
||||
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
|
||||
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
|
||||
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
|
||||
@@ -383,6 +384,76 @@ test('任务计划进度事件在活跃任务列表变化和新任务开始时
|
||||
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/);
|
||||
});
|
||||
|
||||
test('活跃任务按启动时间稳定排列且无变化刷新不重建停止按钮', () => {
|
||||
const sortSource = functionSource(monitor, 'stableActiveTasksForDisplay', 'activeTasksRenderSignature');
|
||||
const sortTasks = vm.runInNewContext(`(${sortSource.trim()})`);
|
||||
const tasks = [
|
||||
{ conversationId: 'conversation-z', startedAt: '2026-08-19T10:00:00Z' },
|
||||
{ conversationId: 'conversation-late', startedAt: '2026-08-19T10:01:00Z' },
|
||||
{ conversationId: 'conversation-a', startedAt: '2026-08-19T10:00:00Z' }
|
||||
];
|
||||
assert.deepEqual(
|
||||
Array.from(sortTasks(tasks), task => task.conversationId),
|
||||
['conversation-a', 'conversation-z', 'conversation-late']
|
||||
);
|
||||
|
||||
const renderSource = functionSource(monitor, 'renderActiveTasks', 'reconcileHitlApprovalStateWithActiveTasks');
|
||||
assert.match(renderSource, /nextVisualSignature === activeTasksVisualSignature/);
|
||||
assert.match(renderSource, /bar\.querySelectorAll\('\.active-task-item'\)\.length === normalizedTasks\.length/);
|
||||
assert.match(renderSource, /const previousScrollLeft = bar\.scrollLeft/);
|
||||
assert.match(renderSource, /bar\.scrollLeft = previousScrollLeft/);
|
||||
});
|
||||
|
||||
test('新对话初始化期间切换会话后旧流事件不能把页面拉回', () => {
|
||||
const guardSource = functionSource(chat, 'shouldIgnoreLiveChatStreamEvent', 'clearLiveChatStreamIfOwned');
|
||||
const shouldIgnore = vm.runInNewContext(`(${guardSource.trim()})`);
|
||||
const activeStream = { active: true, detached: false, navigationSeq: 7 };
|
||||
|
||||
assert.equal(shouldIgnore(activeStream, activeStream, 7), false);
|
||||
activeStream.detached = true;
|
||||
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
|
||||
activeStream.detached = false;
|
||||
activeStream.active = false;
|
||||
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
|
||||
activeStream.active = true;
|
||||
assert.equal(shouldIgnore(activeStream, activeStream, 8), true);
|
||||
assert.equal(shouldIgnore({ active: true, detached: false, navigationSeq: 7 }, activeStream, 7), true);
|
||||
|
||||
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
|
||||
const guardIndex = sendSource.indexOf('shouldIgnoreLiveChatStreamEvent(liveStreamState)');
|
||||
const handlerIndex = sendSource.indexOf('handleStreamEvent(eventData');
|
||||
assert.notEqual(guardIndex, -1);
|
||||
assert.notEqual(handlerIndex, -1);
|
||||
assert.ok(guardIndex < handlerIndex);
|
||||
assert.match(sendSource, /const requestNavigationSeq = chatConversationNavigationSeq;[\s\S]*?await loadActiveTasks\(\)/);
|
||||
assert.match(sendSource, /if \(requestNavigationSeq !== chatConversationNavigationSeq\) \{[\s\S]{0,80}return;/);
|
||||
assert.match(sendSource, /navigationSeq: requestNavigationSeq/);
|
||||
assert.match(sendSource, /if \(!streamConversationId\) \{[\s\S]{0,180}liveStreamState\.conversationId = eventConvId/);
|
||||
assert.match(sendSource, /if \(eventConvId\) updateProgressConversation\(progressId, eventConvId\);[\s\S]{0,80}return;/);
|
||||
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const newConversationSource = functionSource(chat, 'startNewConversation', 'loadConversations');
|
||||
assert.match(loadSource, /markChatConversationNavigation\(conversationId\)/);
|
||||
assert.match(loadSource, /window\.cancelScheduledChatConversationFromHash\(\)/);
|
||||
assert.match(newConversationSource, /markChatConversationNavigation\('', true\)/);
|
||||
assert.match(newConversationSource, /clearChatConversationHash\(\)/);
|
||||
assert.match(router, /function cancelScheduledChatConversationFromHash\(\)[\s\S]{0,160}chatConversationFromHashSeq\+\+/);
|
||||
assert.match(chat, /function abandonChatConversationForPageNavigation\(\)[\s\S]{0,260}markChatConversationNavigation\('', true\)/);
|
||||
assert.match(chat, /abandonChatConversationForPageNavigation\(\)[\s\S]{0,420}detachLiveChatStreamForNavigation\('', true\)/);
|
||||
assert.match(router, /currentPage === 'chat'[\s\S]{0,140}window\.abandonChatConversationForPageNavigation\(\)/);
|
||||
assert.match(chat, /const targetConversationId = String\(item\.dataset\.conversationId \|\| ''\)\.trim\(\);[\s\S]{0,80}loadConversation\(targetConversationId\)/);
|
||||
assert.match(projects, /const targetConversationId = String\(event\.currentTarget && event\.currentTarget\.dataset\.conversationId \|\| ''\)\.trim\(\)/);
|
||||
assert.match(projects, /window\.loadConversation\(targetConversationId\)/);
|
||||
assert.match(chat, /let loadConversationPendingId = ''/);
|
||||
assert.match(chat, /window\.isChatConversationLoadPending = isChatConversationLoadPending/);
|
||||
const immediateSelectionIndex = loadSource.indexOf('currentConversationId = conversationId;');
|
||||
const conversationFetchIndex = loadSource.indexOf('await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`');
|
||||
assert.notEqual(immediateSelectionIndex, -1);
|
||||
assert.notEqual(conversationFetchIndex, -1);
|
||||
assert.ok(immediateSelectionIndex < conversationFetchIndex);
|
||||
assert.match(monitor, /String\(window\.currentConversationId \|\| ''\) !== conversationId[\s\S]{0,300}window\.isChatConversationLoadPending\(conversationId\)/);
|
||||
});
|
||||
|
||||
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
|
||||
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
|
||||
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
|
||||
@@ -397,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
|
||||
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
|
||||
assert.match(html, /router\.js\?v=20260813-2/);
|
||||
assert.match(html, /chat\.js\?v=20260819-1/);
|
||||
assert.match(html, /router\.js\?v=20260819-3/);
|
||||
assert.match(html, /chat\.js\?v=20260819-5/);
|
||||
});
|
||||
|
||||
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
|
||||
|
||||
+112
-13
@@ -10,8 +10,46 @@ function syncChatConversationHash(conversationId) {
|
||||
}
|
||||
}
|
||||
window.syncChatConversationHash = syncChatConversationHash;
|
||||
|
||||
function clearChatConversationHash() {
|
||||
if (window.location.hash.split('?')[0] !== '#chat' || window.location.hash === '#chat') return;
|
||||
window.history.replaceState(null, '', '#chat');
|
||||
}
|
||||
window.clearChatConversationHash = clearChatConversationHash;
|
||||
let loadConversationRequestSeq = 0;
|
||||
let loadConversationAbortController = null;
|
||||
let loadConversationPendingId = '';
|
||||
let chatConversationNavigationSeq = 0;
|
||||
|
||||
function isChatConversationLoadPending(conversationId) {
|
||||
const id = String(conversationId || '').trim();
|
||||
return !!id && loadConversationPendingId === id;
|
||||
}
|
||||
window.isChatConversationLoadPending = isChatConversationLoadPending;
|
||||
|
||||
function markChatConversationNavigation(nextConversationId, force = false) {
|
||||
const nextId = String(nextConversationId || '').trim();
|
||||
const visibleId = String(currentConversationId || '').trim();
|
||||
if (force || nextId !== visibleId) {
|
||||
chatConversationNavigationSeq++;
|
||||
}
|
||||
return chatConversationNavigationSeq;
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开聊天页时立即让尚在初始化的发送请求失去页面所有权。
|
||||
* 后端任务仍会继续执行;这里只中止浏览器前台流,避免首个 conversation
|
||||
* 事件在用户已经切到其他页面后再次抢占当前会话。
|
||||
*/
|
||||
function abandonChatConversationForPageNavigation() {
|
||||
markChatConversationNavigation('', true);
|
||||
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
|
||||
window.cancelScheduledChatConversationFromHash();
|
||||
}
|
||||
cancelPendingConversationLoad();
|
||||
detachLiveChatStreamForNavigation('', true);
|
||||
}
|
||||
window.abandonChatConversationForPageNavigation = abandonChatConversationForPageNavigation;
|
||||
|
||||
/**
|
||||
* 轻量会话 LRU 缓存。
|
||||
@@ -1768,6 +1806,18 @@ function ownsLiveChatStream(liveStream) {
|
||||
return !!liveStream && window.__csAgentLiveStream === liveStream;
|
||||
}
|
||||
|
||||
function shouldIgnoreLiveChatStreamEvent(
|
||||
liveStream,
|
||||
activeLiveStream = window.__csAgentLiveStream,
|
||||
navigationSeq = chatConversationNavigationSeq
|
||||
) {
|
||||
return !liveStream ||
|
||||
activeLiveStream !== liveStream ||
|
||||
liveStream.active !== true ||
|
||||
liveStream.detached === true ||
|
||||
liveStream.navigationSeq !== navigationSeq;
|
||||
}
|
||||
|
||||
function clearLiveChatStreamIfOwned(liveStream) {
|
||||
if (!ownsLiveChatStream(liveStream)) return false;
|
||||
liveStream.active = false;
|
||||
@@ -2208,6 +2258,8 @@ async function sendMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
let message = input.value.trim();
|
||||
const hasAttachments = chatAttachments && chatAttachments.length > 0;
|
||||
const requestConversationId = currentConversationId;
|
||||
const requestNavigationSeq = chatConversationNavigationSeq;
|
||||
|
||||
if (!message && !hasAttachments) {
|
||||
return;
|
||||
@@ -2261,6 +2313,12 @@ async function sendMessage() {
|
||||
message = CHAT_FILE_DEFAULT_PROMPT;
|
||||
}
|
||||
|
||||
// 发送前的任务状态/附件检查可能包含异步等待。若用户已主动切换会话,
|
||||
// 保留当前页面,不再把这次尚未发出的请求写入新的可见对话。
|
||||
if (requestNavigationSeq !== chatConversationNavigationSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示用户消息(含附件名,便于用户确认)
|
||||
const displayMessage = hasAttachments
|
||||
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
|
||||
@@ -2296,7 +2354,7 @@ async function sendMessage() {
|
||||
// 构建请求体(含附件)
|
||||
const body = {
|
||||
message: message,
|
||||
conversationId: currentConversationId,
|
||||
conversationId: requestConversationId,
|
||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||
};
|
||||
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
|
||||
@@ -2357,7 +2415,8 @@ async function sendMessage() {
|
||||
conversationId: streamConversationId || null,
|
||||
progressId: progressId,
|
||||
abortController: requestAbortController,
|
||||
detached: false
|
||||
detached: false,
|
||||
navigationSeq: requestNavigationSeq
|
||||
};
|
||||
window.__csAgentLiveStream = liveStreamState;
|
||||
if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') {
|
||||
@@ -2408,18 +2467,18 @@ async function sendMessage() {
|
||||
if (streamConversationId && streamConversationId !== eventConvId) {
|
||||
return;
|
||||
}
|
||||
if (!streamConversationId && eventData.type === 'conversation') {
|
||||
if (!streamConversationId) {
|
||||
streamConversationId = eventConvId;
|
||||
liveStreamState.conversationId = eventConvId;
|
||||
justBoundConversation = true;
|
||||
// 旧请求可能在用户切换对话后才收到 conversation 事件。
|
||||
// 只完成本地任务绑定,不允许它重新抢占当前对话或新的主流状态。
|
||||
if (!ownsLiveChatStream(liveStreamState) || liveStreamState.detached) {
|
||||
updateProgressConversation(progressId, eventConvId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 切换对话后仍可能收到旧响应流中已缓冲的 conversation、response_start
|
||||
// 或 response 事件。它们只能补齐后台任务归属,不能重新抢占当前对话。
|
||||
if (shouldIgnoreLiveChatStreamEvent(liveStreamState)) {
|
||||
if (eventConvId) updateProgressConversation(progressId, eventConvId);
|
||||
return;
|
||||
}
|
||||
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
|
||||
return;
|
||||
}
|
||||
@@ -5652,6 +5711,11 @@ async function startNewConversation(options = {}) {
|
||||
const requestedProjectId = hasExplicitProjectId
|
||||
? String(options.projectId || '').trim()
|
||||
: String(inheritedProjectId || '').trim();
|
||||
markChatConversationNavigation('', true);
|
||||
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
|
||||
window.cancelScheduledChatConversationFromHash();
|
||||
}
|
||||
clearChatConversationHash();
|
||||
cancelPendingConversationLoad();
|
||||
detachLiveChatStreamForNavigation('', true);
|
||||
if (typeof window.cancelRunningTaskEventStream === 'function') {
|
||||
@@ -5776,7 +5840,8 @@ function createConversationListItem(conversation) {
|
||||
item.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
loadConversation(conversation.id);
|
||||
const targetConversationId = String(item.dataset.conversationId || '').trim();
|
||||
if (targetConversationId) loadConversation(targetConversationId);
|
||||
};
|
||||
return item;
|
||||
}
|
||||
@@ -6092,16 +6157,30 @@ async function prefetchLastAssistantProcessDetails() {
|
||||
}
|
||||
|
||||
async function loadConversation(conversationId) {
|
||||
conversationId = String(conversationId || '').trim();
|
||||
if (!conversationId) return;
|
||||
// Keep the visible conversation addressable across a full page refresh.
|
||||
// Sidebar/project entries call loadConversation directly (rather than the
|
||||
// router helper), so without this synchronization #chat loses the active
|
||||
// conversation and reload falls back to the welcome screen instead of
|
||||
// reconnecting the running task event stream.
|
||||
markChatConversationNavigation(conversationId);
|
||||
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
|
||||
window.cancelScheduledChatConversationFromHash();
|
||||
}
|
||||
syncChatConversationHash(conversationId);
|
||||
const seq = ++loadConversationRequestSeq;
|
||||
const previousConversationId = currentConversationId;
|
||||
cancelPendingConversationLoad();
|
||||
detachLiveChatStreamForNavigation(conversationId);
|
||||
// 用户单击即代表新的可见会话。必须在任何网络等待之前提交该选择,
|
||||
// 否则每 2 秒的活跃任务刷新仍会把旧会话识别为可见,并排队重载旧补流,
|
||||
// 反过来取消这次切换。
|
||||
currentConversationId = conversationId;
|
||||
try {
|
||||
window.currentConversationId = conversationId;
|
||||
} catch (e) { /* ignore */ }
|
||||
loadConversationPendingId = conversationId;
|
||||
const conversationLoadController = new AbortController();
|
||||
loadConversationAbortController = conversationLoadController;
|
||||
if (typeof window.selectChatProjectConversationItem === 'function') {
|
||||
@@ -6132,6 +6211,14 @@ async function loadConversation(conversationId) {
|
||||
return;
|
||||
}
|
||||
if (response && !response.ok) {
|
||||
if (seq === loadConversationRequestSeq) {
|
||||
currentConversationId = previousConversationId;
|
||||
try {
|
||||
window.currentConversationId = previousConversationId || '';
|
||||
} catch (e) { /* ignore */ }
|
||||
if (previousConversationId) syncChatConversationHash(previousConversationId);
|
||||
else clearChatConversationHash();
|
||||
}
|
||||
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
|
||||
return;
|
||||
}
|
||||
@@ -6418,8 +6505,16 @@ async function loadConversation(conversationId) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') {
|
||||
window.selectChatProjectConversationItem(previousConversationId);
|
||||
if (seq === loadConversationRequestSeq) {
|
||||
currentConversationId = previousConversationId;
|
||||
try {
|
||||
window.currentConversationId = previousConversationId || '';
|
||||
} catch (e) { /* ignore */ }
|
||||
if (previousConversationId) syncChatConversationHash(previousConversationId);
|
||||
else clearChatConversationHash();
|
||||
if (typeof window.selectChatProjectConversationItem === 'function') {
|
||||
window.selectChatProjectConversationItem(previousConversationId);
|
||||
}
|
||||
}
|
||||
console.error('加载对话失败:', error);
|
||||
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
|
||||
@@ -6430,6 +6525,9 @@ async function loadConversation(conversationId) {
|
||||
if (loadConversationAbortController === conversationLoadController) {
|
||||
loadConversationAbortController = null;
|
||||
}
|
||||
if (seq === loadConversationRequestSeq && loadConversationPendingId === conversationId) {
|
||||
loadConversationPendingId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10093,7 +10191,8 @@ function createConversationListItemWithMenu(conversation, isPinned) {
|
||||
if (currentGroupId) {
|
||||
exitGroupDetail();
|
||||
}
|
||||
loadConversation(conversation.id);
|
||||
const targetConversationId = String(item.dataset.conversationId || '').trim();
|
||||
if (targetConversationId) loadConversation(targetConversationId);
|
||||
};
|
||||
|
||||
return item;
|
||||
|
||||
@@ -280,7 +280,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
|
||||
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
|
||||
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
|
||||
assert.match(chat, /signal: requestAbortController\.signal/);
|
||||
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
|
||||
assert.match(chat, /shouldIgnoreLiveChatStreamEvent\(liveStreamState\)/);
|
||||
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
|
||||
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
|
||||
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
|
||||
@@ -289,14 +289,26 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
|
||||
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
|
||||
assert.match(monitor, /timeline\.scrollTop = targetTop/);
|
||||
assert.match(chat, /let loadConversationAbortController = null/);
|
||||
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
|
||||
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
|
||||
assert.match(chat, /signal: conversationLoadController\.signal/);
|
||||
assert.match(template, /monitor\.js\?v=20260819-3/);
|
||||
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
|
||||
assert.match(template, /chat\.js\?v=20260819-1/);
|
||||
assert.match(template, /chat\.js\?v=20260819-5/);
|
||||
assert.match(template, /style\.css\?v=20260819-4/);
|
||||
});
|
||||
|
||||
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
|
||||
const start = monitor.indexOf("async function performHardCancelProgressTask(progressId, conversationId = '')");
|
||||
const end = monitor.indexOf('function progressElapsedText(', start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
const hardCancelSource = monitor.slice(start, end);
|
||||
assert.match(monitor, /performHardCancelProgressTask\(progressId, conversationId\)/);
|
||||
assert.match(hardCancelSource, /const targetConversationId = String\(conversationId \|\| \(state && state\.conversationId\) \|\| ''\)\.trim\(\)/);
|
||||
assert.match(hardCancelSource, /await requestCancel\(targetConversationId\)/);
|
||||
assert.doesNotMatch(hardCancelSource, /if \(!state \|\| !state\.conversationId\)/);
|
||||
});
|
||||
|
||||
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
|
||||
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?display: block/);
|
||||
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?padding-block: 1px/);
|
||||
@@ -339,7 +351,7 @@ test('审批状态主动轮询并在服务不可用时立即关闭旧审批', ()
|
||||
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
|
||||
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
|
||||
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
|
||||
assert.match(template, /projects\.js\?v=20260812-6/);
|
||||
assert.match(template, /projects\.js\?v=20260819-1/);
|
||||
});
|
||||
|
||||
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ const ACTIVE_TASK_REFRESH_INTERVAL = 2000; // 运行态与审批态需要及时
|
||||
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
||||
const hitlInterruptToolItemMap = new Map();
|
||||
let activeTasksLoadPromise = null;
|
||||
let activeTasksVisualSignature = '';
|
||||
const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1';
|
||||
let chatTaskSyncChannel = null;
|
||||
let visibleConversationReplaySyncPromise = null;
|
||||
@@ -1432,7 +1433,7 @@ async function submitUserInterruptHardCancel() {
|
||||
const { progressId, conversationId } = userInterruptModalPending;
|
||||
closeUserInterruptModal();
|
||||
if (progressId) {
|
||||
await performHardCancelProgressTask(progressId);
|
||||
await performHardCancelProgressTask(progressId, conversationId);
|
||||
return;
|
||||
}
|
||||
if (!conversationId) {
|
||||
@@ -1448,11 +1449,12 @@ async function submitUserInterruptHardCancel() {
|
||||
}
|
||||
|
||||
/** 彻底停止任务(原「停止任务」行为) */
|
||||
async function performHardCancelProgressTask(progressId) {
|
||||
async function performHardCancelProgressTask(progressId, conversationId = '') {
|
||||
const state = progressTaskState.get(progressId);
|
||||
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
|
||||
const targetConversationId = String(conversationId || (state && state.conversationId) || '').trim();
|
||||
|
||||
if (!state || !state.conversationId) {
|
||||
if (!targetConversationId) {
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = true;
|
||||
setTimeout(() => {
|
||||
@@ -1463,7 +1465,7 @@ async function performHardCancelProgressTask(progressId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.cancelling) {
|
||||
if (state && state.cancelling) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1474,7 +1476,7 @@ async function performHardCancelProgressTask(progressId) {
|
||||
}
|
||||
|
||||
try {
|
||||
await requestCancel(state.conversationId);
|
||||
await requestCancel(targetConversationId);
|
||||
loadActiveTasks();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error);
|
||||
@@ -6858,6 +6860,17 @@ function syncVisibleConversationTaskReplay(tasks) {
|
||||
visibleConversationReplaySyncId = conversationId;
|
||||
visibleConversationReplaySyncPromise = Promise.resolve()
|
||||
.then(async function () {
|
||||
// 用户可能在任务刷新排队后、此微任务执行前切换了会话。
|
||||
// 不允许旧会话补流取消或覆盖用户刚发起的目标会话加载。
|
||||
if (String(window.currentConversationId || '') !== conversationId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof window.isChatConversationLoadPending === 'function' &&
|
||||
window.isChatConversationLoadPending(conversationId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// 另一标签页已新增用户消息和运行中助手轮次;先重载轻量历史,避免把补流挂到旧助手消息上。
|
||||
if (typeof window.loadConversation === 'function') {
|
||||
await window.loadConversation(conversationId);
|
||||
@@ -6893,6 +6906,33 @@ function getActiveTaskDisplayName(task) {
|
||||
return message || unnamedTaskText;
|
||||
}
|
||||
|
||||
function stableActiveTasksForDisplay(tasks) {
|
||||
return (Array.isArray(tasks) ? tasks : []).slice().sort(function (a, b) {
|
||||
const aStartedAt = Date.parse(a && a.startedAt ? a.startedAt : '');
|
||||
const bStartedAt = Date.parse(b && b.startedAt ? b.startedAt : '');
|
||||
const aTime = Number.isFinite(aStartedAt) ? aStartedAt : Number.MAX_SAFE_INTEGER;
|
||||
const bTime = Number.isFinite(bStartedAt) ? bStartedAt : Number.MAX_SAFE_INTEGER;
|
||||
if (aTime !== bTime) return aTime - bTime;
|
||||
return String(a && a.conversationId || '').localeCompare(String(b && b.conversationId || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function activeTasksRenderSignature(tasks) {
|
||||
const language = typeof i18next !== 'undefined' && i18next.language ? i18next.language : getCurrentTimeLocale();
|
||||
return JSON.stringify({
|
||||
language: language,
|
||||
tasks: (Array.isArray(tasks) ? tasks : []).map(function (task) {
|
||||
return {
|
||||
conversationId: task && task.conversationId || '',
|
||||
title: task && task.title || '',
|
||||
message: task && task.message || '',
|
||||
startedAt: task && task.startedAt || '',
|
||||
status: task && task.status || ''
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function updateActiveTaskConversationTitle(conversationId, newTitle) {
|
||||
const bar = document.getElementById('active-tasks-bar');
|
||||
if (!bar || !conversationId) return;
|
||||
@@ -6909,7 +6949,7 @@ function renderActiveTasks(tasks) {
|
||||
const bar = document.getElementById('active-tasks-bar');
|
||||
if (!bar) return;
|
||||
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
|
||||
const normalizedTasks = stableActiveTasksForDisplay(tasks);
|
||||
conversationExecutionTracker.update(normalizedTasks);
|
||||
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
|
||||
detail: { tasks: normalizedTasks }
|
||||
@@ -6930,10 +6970,20 @@ function renderActiveTasks(tasks) {
|
||||
if (normalizedTasks.length === 0) {
|
||||
bar.style.display = 'none';
|
||||
bar.innerHTML = '';
|
||||
activeTasksVisualSignature = '';
|
||||
return;
|
||||
}
|
||||
|
||||
bar.style.display = 'flex';
|
||||
const nextVisualSignature = activeTasksRenderSignature(normalizedTasks);
|
||||
if (
|
||||
nextVisualSignature === activeTasksVisualSignature &&
|
||||
bar.querySelectorAll('.active-task-item').length === normalizedTasks.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previousScrollLeft = bar.scrollLeft;
|
||||
activeTasksVisualSignature = nextVisualSignature;
|
||||
bar.innerHTML = '';
|
||||
|
||||
function openActiveTaskConversation(conversationId) {
|
||||
@@ -7012,6 +7062,7 @@ function renderActiveTasks(tasks) {
|
||||
|
||||
bar.appendChild(item);
|
||||
});
|
||||
bar.scrollLeft = previousScrollLeft;
|
||||
}
|
||||
|
||||
function reconcileHitlApprovalStateWithActiveTasks(tasks) {
|
||||
|
||||
@@ -3352,15 +3352,17 @@ function appendChatProjectConversationItem(list, conversation, project) {
|
||||
});
|
||||
button.appendChild(label);
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
const targetConversationId = String(event.currentTarget && event.currentTarget.dataset.conversationId || '').trim();
|
||||
if (!targetConversationId) return;
|
||||
projectConversationPreviewSuppressedUntil = Date.now() + 700;
|
||||
hideProjectConversationPreview(true);
|
||||
selectChatProjectConversationItem(conversation.id);
|
||||
selectChatProjectConversationItem(targetConversationId);
|
||||
if (typeof window.loadConversation === 'function') {
|
||||
await window.loadConversation(conversation.id);
|
||||
await window.loadConversation(targetConversationId);
|
||||
}
|
||||
if (window.currentConversationId === conversation.id && completed) {
|
||||
markProjectConversationViewed(conversation.id, completed.completedAt);
|
||||
if (window.currentConversationId === targetConversationId && completed) {
|
||||
markProjectConversationViewed(targetConversationId, completed.completedAt);
|
||||
renderChatProjectFolders(projectsCacheAll);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,12 @@ function buildHashForPage(pageId) {
|
||||
|
||||
let chatConversationFromHashSeq = 0;
|
||||
|
||||
function cancelScheduledChatConversationFromHash() {
|
||||
chatConversationFromHashSeq++;
|
||||
setChatConversationRestorePending('', false);
|
||||
}
|
||||
window.cancelScheduledChatConversationFromHash = cancelScheduledChatConversationFromHash;
|
||||
|
||||
function setChatConversationRestorePending(conversationId, pending) {
|
||||
const container = document.querySelector('.chat-container');
|
||||
if (!container) return;
|
||||
@@ -123,6 +129,9 @@ function switchPage(pageId) {
|
||||
if (!targetPage) return;
|
||||
if (pageId !== 'chat') {
|
||||
setChatConversationRestorePending('', false);
|
||||
if (currentPage === 'chat' && typeof window.abandonChatConversationForPageNavigation === 'function') {
|
||||
window.abandonChatConversationForPageNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
|
||||
<link rel="stylesheet" href="/static/css/c2.css">
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
<script src="/static/js/router.js?v=20260813-2"></script>
|
||||
<script src="/static/js/router.js?v=20260819-3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-overlay" class="login-overlay" style="display: none;">
|
||||
@@ -6845,7 +6845,7 @@
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
|
||||
<script src="/static/js/monitor.js?v=20260819-3"></script>
|
||||
<script src="/static/js/chat.js?v=20260819-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260819-5"></script>
|
||||
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
|
||||
<script src="/static/js/hitl.js?v=20260819-1"></script>
|
||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||
@@ -6858,7 +6858,7 @@
|
||||
<script src="/static/js/knowledge.js"></script>
|
||||
<script src="/static/js/skills.js"></script>
|
||||
<script src="/static/js/fact-graph.js"></script>
|
||||
<script src="/static/js/projects.js?v=20260812-6"></script>
|
||||
<script src="/static/js/projects.js?v=20260819-1"></script>
|
||||
<script src="/static/js/vulnerability.js?v=14"></script>
|
||||
<script src="/static/js/webshell.js"></script>
|
||||
<script src="/static/js/chat-files.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user