mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 15:10:20 +02:00
优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)
* feat(chat): add project-based conversation sidebar * feat(chat): refine Codex-style conversation UI * feat(chat): add Codex-style conversation workflow * feat(ui): 优化对话框与项目侧边栏交互 * fix(chat): 修复暗色输入框圆角填色 * fix(chat): 恢复输入区分层错位布局 * fix(hitl): isolate reviewer state per conversation * feat(hitl): 增加双入口审批与倒计时进度 * fix(hitl): 汇总项目审批并隔离对话状态 * fix(ui): 修复审批状态与无项目新任务 * fix(ui): 优化审批状态与对话切换性能 * fix(ui): 修复中断任务审批仍计时 * fix(ui): 修复多对话并发切换卡顿 * fix(hitl): 主动同步审批并关闭中断状态 * fix(ui): 固定项目审批汇总为绿色 * feat(ui): 同步系统模型与推理强度 * feat(ui): 完善项目侧栏预览与新建入口 * fix(ui): 防止无项目文件夹误展开 * fix(ui): 防止长历史对话滚动误触审批 * fix(ui): 修复对话操作并补充项目置顶 * fix(ui): 移除对话分组并调整项目置顶排序 * feat(ui): 优化迭代导航与审批交互 * fix(hitl): 将 write_file 加入内置免审批工具 * fix(chat): 支持回车发送与 Shift 回车换行 * fix(chat): 优化对话刷新与 Codex 风格交互 * fix(ui): 显示对话具体更新时间 * fix(ui): 优化对话刷新与项目加载 * fix(ui): 修复 Agent 审查文字裁切 * fix(chat): 修复刷新续流与多标签页同步 * fix(chat): 修复滚动跟随与中断任务终态 * fix(ui): 修复流式滚动跳动与工具状态恢复 * fix(ui): 修复刷新后流式输出停止粘底
This commit is contained in:
@@ -333,6 +333,15 @@ async function refreshAppData(showTaskErrors = false) {
|
||||
loadConversations(),
|
||||
loadActiveTasks(showTaskErrors),
|
||||
]);
|
||||
// 未登录首屏的项目侧栏可能先收到 401 并显示失败;认证完成后必须主动重试。
|
||||
// 放在对话/任务刷新之后,确保最终渲染一定使用有效登录态且不会被早期失败覆盖。
|
||||
if (typeof window.refreshChatProjectSelector === 'function') {
|
||||
try {
|
||||
await window.refreshChatProjectSelector({ reloadFolders: true });
|
||||
} catch (error) {
|
||||
console.warn('刷新项目侧栏失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp() {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
|
||||
|
||||
test('主对话时间线不再创建用户或助手头像', () => {
|
||||
assert.doesNotMatch(chat, /createMessageAvatar/);
|
||||
assert.doesNotMatch(monitor, /createMessageAvatar/);
|
||||
assert.doesNotMatch(chat, /message-avatar/);
|
||||
assert.doesNotMatch(styles, /\.message-avatar/);
|
||||
});
|
||||
|
||||
test('新对话使用无图标的项目欢迎空状态', () => {
|
||||
assert.match(chat, /function renderChatWelcomeEmptyState\(\)/);
|
||||
assert.match(chat, /chat-welcome-empty-state-title/);
|
||||
assert.match(chat, /chat-welcome-empty-state-subtitle/);
|
||||
assert.doesNotMatch(chat, /chat-welcome-empty-state-icon/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state\s*\{[\s\S]*?justify-content: center/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state-title/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state-subtitle/);
|
||||
assert.match(styles, /\.chat-welcome-project-name\s*\{[\s\S]*?border-bottom: 1px dotted currentColor/);
|
||||
assert.match(chat, /projectName\.className = 'chat-welcome-project-name'/);
|
||||
assert.match(chat, /title\.replaceChildren\(/);
|
||||
});
|
||||
|
||||
test('欢迎语随项目和无项目状态更新', () => {
|
||||
assert.match(chat, /window\.t\('chat\.projectWelcomeMessage', \{ project \}\)/);
|
||||
assert.match(chat, /window\.t\('chat\.noProjectWelcomeMessage'\)/);
|
||||
assert.match(projects, /window\.refreshChatWelcomeEmptyState\(\)/);
|
||||
assert.equal(
|
||||
zh.chat.projectWelcomeMessage,
|
||||
'当前{{project}}项目,请输入您的测试需求,系统将自动执行相应的安全测试。'
|
||||
);
|
||||
assert.equal(zh.chat.projectWelcomeTitlePrefix, '要在 ');
|
||||
assert.equal(zh.chat.projectWelcomeTitleSuffix, ' 项目中测试什么?');
|
||||
assert.equal(zh.chat.welcomeSubtitle, '请输入您的测试需求,系统将自动执行相应的安全测试。');
|
||||
assert.equal(typeof en.chat.projectWelcomeMessage, 'string');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createKeydownHarness() {
|
||||
const context = {
|
||||
isComposing: false,
|
||||
mentionState: { active: false },
|
||||
mentionSuggestionsEl: null,
|
||||
sendCount: 0,
|
||||
sendMessage() {
|
||||
context.sendCount += 1;
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`${functionSource(chat, 'handleChatInputKeydown', 'updateMentionStateFromInput')}; this.handleChatInputKeydown = handleChatInputKeydown;`,
|
||||
context
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
test('聊天输入框按 Enter 发送并阻止原生换行', () => {
|
||||
const context = createKeydownHarness();
|
||||
let prevented = false;
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(context.sendCount, 1);
|
||||
});
|
||||
|
||||
test('聊天输入框按 Shift+Enter 只换行且不发送', () => {
|
||||
const context = createKeydownHarness();
|
||||
let prevented = false;
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: true,
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(context.sendCount, 0);
|
||||
});
|
||||
|
||||
test('输入法确认候选词时按 Enter 不会发送', () => {
|
||||
const context = createKeydownHarness();
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
isComposing: true,
|
||||
keyCode: 229,
|
||||
preventDefault() {
|
||||
throw new Error('IME Enter should not be prevented');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(context.sendCount, 0);
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
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 router = fs.readFileSync('web/static/js/router.js', 'utf8');
|
||||
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
|
||||
const html = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createScrollRuntime() {
|
||||
const listeners = new Map();
|
||||
const buttonListeners = new Map();
|
||||
const classList = { add() {}, remove() {}, toggle() {}, contains() { return false; } };
|
||||
const chatEl = {
|
||||
scrollTop: 500,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 500,
|
||||
children: [],
|
||||
classList,
|
||||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||
scrollTo(options) { this.scrollTop = Number(options && options.top) || 0; },
|
||||
getBoundingClientRect() { return { right: 1000 }; },
|
||||
};
|
||||
const returnLatest = {
|
||||
hidden: true,
|
||||
classList,
|
||||
addEventListener(type, handler) { buttonListeners.set(type, handler); },
|
||||
blur() {},
|
||||
};
|
||||
const rafQueue = new Map();
|
||||
let rafId = 0;
|
||||
const requestAnimationFrame = (handler) => {
|
||||
const id = ++rafId;
|
||||
rafQueue.set(id, handler);
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id) => rafQueue.delete(id);
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
getElementById(id) {
|
||||
if (id === 'chat-messages') return chatEl;
|
||||
if (id === 'chat-return-latest') return returnLatest;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
document,
|
||||
addEventListener() {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
innerWidth: 1440,
|
||||
innerHeight: 900,
|
||||
};
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(scroll, context);
|
||||
return {
|
||||
api: window.CyberStrikeChatScroll,
|
||||
chatEl,
|
||||
listeners,
|
||||
flushAnimationFrames() {
|
||||
while (rafQueue.size) {
|
||||
const pending = Array.from(rafQueue.values());
|
||||
rafQueue.clear();
|
||||
pending.forEach((handler) => handler(Date.now()));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('向上滚动立即解除粘底,只有滚到真实底部才恢复', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.listeners.get('wheel')({ deltaY: -20 });
|
||||
runtime.chatEl.scrollTop = 480;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false);
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 480, '新输出不能抢回用户的阅读位置');
|
||||
|
||||
runtime.chatEl.scrollTop = 597;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false, '距底部 2px 以上仍保持脱离');
|
||||
|
||||
runtime.chatEl.scrollTop = 600;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '用户滚到真实底部后立即恢复跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1200;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1200, '恢复后新增输出继续请求滚到最底部');
|
||||
});
|
||||
|
||||
test('刷新重建详情引起的布局上移不会误判为用户上滑', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.chatEl.scrollTop = 460;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '没有用户输入的布局滚动仍应保持跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1100, '刷新恢复后的后续增量应继续粘底');
|
||||
});
|
||||
|
||||
test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
|
||||
const refreshSource = functionSource(auth, 'refreshAppData', 'bootstrapApp');
|
||||
const conversationsIndex = refreshSource.indexOf('loadConversations()');
|
||||
const projectRetryIndex = refreshSource.indexOf('window.refreshChatProjectSelector({ reloadFolders: true })');
|
||||
|
||||
assert.notEqual(conversationsIndex, -1);
|
||||
assert.ok(projectRetryIndex > conversationsIndex);
|
||||
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
|
||||
assert.match(html, /\/static\/js\/auth\.js\?v=20260813-1/);
|
||||
});
|
||||
|
||||
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
|
||||
const resumeSource = functionSource(scroll, 'resumeFollowingIfAtBottom', 'captureScrollPinState');
|
||||
const captureSource = functionSource(scroll, 'captureScrollPinState', 'setScrollFollowing');
|
||||
const autoSource = functionSource(scroll, 'canAutoScrollNow', 'scheduleChatScrollToBottomIfFollowing');
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
|
||||
assert.match(resumeSource, /thresholdPx/);
|
||||
assert.match(scroll, /CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.doesNotMatch(captureSource, /resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(autoSource, /resumeFollowingIfAtBottom/);
|
||||
assert.match(resumeSource, /if \(!userInitiated\) return false/);
|
||||
assert.match(scrollSource, /scrolledDown/);
|
||||
assert.match(scrollSource, /hasUserScrollIntent/);
|
||||
assert.match(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true\)/);
|
||||
assert.doesNotMatch(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX\)/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.doesNotMatch(scrollSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(scrollSource, /contentShrank/);
|
||||
assert.match(scrollSource, /sh < lastScrollHeight - 1/);
|
||||
assert.match(scrollSource, /if \(scrolledUp && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)\) \{[\s\S]*?setScrollDetached\(\)/);
|
||||
assert.match(scrollSource, /if \(programmaticScroll\) \{[\s\S]*?st < lastScrollTop - 1 && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)[\s\S]*?setScrollDetached\(\)/);
|
||||
});
|
||||
|
||||
test('切换对话模式引起的布局滚动不会重新开启粘底', () => {
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
const selectModeSource = functionSource(chat, 'selectAgentMode', 'initChatAgentModeFromConfig');
|
||||
|
||||
assert.match(scroll, /let userScrollIntentUntil = 0/);
|
||||
assert.match(scrollSource, /const hasUserScrollIntent = Date\.now\(\) <= userScrollIntentUntil/);
|
||||
assert.match(scrollSource, /scrolledDown &&[\s\S]*?hasUserScrollIntent &&[\s\S]*?resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /userScrollIntentUntil = Date\.now\(\) \+ 1800/);
|
||||
assert.doesNotMatch(selectModeSource, /setScrollFollowing|forceScrollToBottom|scrollTop/);
|
||||
});
|
||||
|
||||
test('刷新运行中任务补齐最新详情后保持粘底但尊重用户上滑', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const settleSource = functionSource(scroll, 'settleChatToBottomIfFollowing', 'scrollChatMessagesToBottomIfPinned');
|
||||
|
||||
assert.match(attachSource, /window\.captureScrollPinState\(\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(12\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(18\)/);
|
||||
assert.match(attachSource, /用户期间没有主动上滑/);
|
||||
assert.match(attachSource, /keepFollowingFinalRender/);
|
||||
assert.match(attachSource, /最终消息和详情重绘都会增高 DOM/);
|
||||
assert.match(settleSource, /scrollMode !== 'following'/);
|
||||
assert.match(settleSource, /Date\.now\(\) < detachLockUntil/);
|
||||
assert.match(settleSource, /settleFrame\(remaining - 1\)/);
|
||||
assert.match(settleSource, /scrollChatToBottomInstant\(\)/);
|
||||
assert.match(scroll, /function settleConversationRestoreToBottom\(frameCount\)/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000/);
|
||||
assert.match(scroll, /const generation = \+\+conversationRestoreGeneration/);
|
||||
assert.match(scroll, /scrollMode !== 'following'/);
|
||||
assert.match(scroll, /stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES/);
|
||||
assert.match(scroll, /requestAnimationFrame\(settleRestoreFrame\)/);
|
||||
assert.match(chat, /settleConversationRestoreToBottom\(30\)/);
|
||||
});
|
||||
|
||||
test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解除', () => {
|
||||
const startSource = functionSource(monitor, 'startProcessDetailsLatestFollow', 'loadProcessDetailsPaginated');
|
||||
const loadSource = functionSource(monitor, 'loadProcessDetailsPaginated', 'shouldInitiallyOpenProcessDetailsAtLatest');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(startSource, /new MutationObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /characterData: true/);
|
||||
assert.match(startSource, /new ResizeObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /scrollProcessDetailsToLatest\(String\(assistantMessageId \|\| ''\), false\)/);
|
||||
assert.match(startSource, /event\.deltaY < -1/);
|
||||
assert.match(startSource, /state\.userScrollIntentUntil = Date\.now\(\) \+ 1200/);
|
||||
assert.match(startSource, /event\.clientX >= rect\.right - PROCESS_DETAILS_FOLLOW_SCROLLBAR_GUTTER_PX/);
|
||||
assert.match(startSource, /event\.key === 'ArrowUp'/);
|
||||
assert.match(startSource, /cancelAnimationFrame\(state\.rafId\)/);
|
||||
assert.match(startSource, /if \(scrolledUp && \(state\.detached \|\| Date\.now\(\) <= state\.userScrollIntentUntil\)\) \{[\s\S]*?detachForUserNavigation\(\)/);
|
||||
assert.match(startSource, /state\.detached &&[\s\S]*?scrolledDown &&[\s\S]*?Date\.now\(\) <= state\.userScrollIntentUntil/);
|
||||
assert.match(monitor, /PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.match(startSource, /distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX/);
|
||||
assert.match(startSource, /state\.detached = false/);
|
||||
assert.doesNotMatch(startSource, /if \(distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX\) \{\s*state\.detached = false/);
|
||||
assert.match(loadSource, /startProcessDetailsLatestFollow\(assistantMessageId/);
|
||||
assert.match(attachSource, /startProcessDetailsLatestFollow\(asEl\.id, \{ persistent: true \}\)/);
|
||||
assert.match(attachSource, /stopProcessDetailsLatestFollow\(asEl\.id\)/);
|
||||
});
|
||||
|
||||
test('刷新后的工具调用恢复与实时一致的成功失败徽标', () => {
|
||||
const renderSource = functionSource(chat, 'renderProcessDetails', 'finishProcessDetailsRender');
|
||||
const presentationSource = functionSource(monitor, 'getToolCallStatusPresentation', 'applyToolCallStatus');
|
||||
const applySource = functionSource(monitor, 'applyToolCallStatus', 'updateToolCallStatus');
|
||||
const addSource = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
|
||||
|
||||
assert.match(renderSource, /toolStatusByProcessDetailId/);
|
||||
assert.match(renderSource, /timelineOpts\.toolStatus = toolStatusByProcessDetailId\.get/);
|
||||
assert.match(presentationSource, /normalized === 'completed'/);
|
||||
assert.match(presentationSource, /normalized === 'failed'/);
|
||||
assert.match(applySource, /tool-status-badge/);
|
||||
assert.match(applySource, /item\.dataset\.toolDisplayStatus = presentation\.status/);
|
||||
assert.match(addSource, /initialToolStatus = item\.dataset\.toolDisplayStatus/);
|
||||
assert.match(addSource, /applyToolCallStatus\(item, initialToolStatus\)/);
|
||||
assert.match(monitor, /refreshProgressAndTimelineI18n\(\)[\s\S]*?applyToolCallStatus\(item, item\.dataset\.toolDisplayStatus\)/);
|
||||
});
|
||||
|
||||
test('首次实时输出与刷新恢复都保留独立迭代滚动并跟随最新内容', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const addSource = functionSource(monitor, 'addProgressMessage', 'toggleProgressDetails');
|
||||
const liveSource = functionSource(monitor, 'startLiveProgressLatestFollow', 'stopLiveProgressLatestFollow');
|
||||
|
||||
assert.match(css, /\.progress-container\.is-streaming \.progress-timeline\.expanded,[\s\S]{0,360}max-height: min\(64vh, 720px\);[\s\S]{0,180}overflow-y: auto;/);
|
||||
assert.match(css, /\.message\.progress-message \.progress-timeline\.expanded \{[\s\S]{0,260}max-height: min\(64vh, 720px\);[\s\S]{0,160}overflow-y: auto;/);
|
||||
assert.doesNotMatch(css, /流式执行中[\s\S]{0,320}overflow-y: visible;/);
|
||||
assert.match(addSource, /startLiveProgressLatestFollow\(id\)/);
|
||||
assert.match(liveSource, /stateKey: liveProgressLatestFollowKey\(id\)/);
|
||||
assert.match(liveSource, /persistent: true/);
|
||||
assert.match(liveSource, /target\.scrollTop = Math\.max\(0, target\.scrollHeight - target\.clientHeight\)/);
|
||||
assert.match(monitor, /function finalizeProgressTask\(progressId, finalLabel\) \{[\s\S]{0,120}stopLiveProgressLatestFollow\(progressId\)/);
|
||||
});
|
||||
|
||||
test('同一会话的其他标签页自动补流且发送前阻止重复任务', () => {
|
||||
const syncSource = functionSource(monitor, 'syncVisibleConversationTaskReplay', 'getActiveTaskDisplayName');
|
||||
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
|
||||
|
||||
assert.match(monitor, /new BroadcastChannel\(CHAT_TASK_SYNC_CHANNEL_NAME\)/);
|
||||
assert.match(monitor, /payload\.type !== 'task-started'/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.markRunning\(id\)/);
|
||||
assert.match(syncSource, /await window\.loadConversation\(conversationId\)/);
|
||||
assert.match(syncSource, /return attachRunningTaskEventStream\(conversationId\)/);
|
||||
assert.match(monitor, /syncVisibleConversationTaskReplay\(normalizedTasks\)/);
|
||||
assert.match(sendSource, /await loadActiveTasks\(\)/);
|
||||
assert.match(sendSource, /if \(isCurrentChatTaskActive\(\)\)/);
|
||||
assert.ok(sendSource.indexOf('if (isCurrentChatTaskActive())') < sendSource.indexOf("addMessage('user'"));
|
||||
assert.match(sendSource, /window\.notifyConversationTaskStarted\(streamConversationId\)/);
|
||||
});
|
||||
|
||||
test('刷新补流在订阅竞态或终态帧丢失时从数据库对账最终正文', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const reconcileSource = functionSource(monitor, 'reconcileConversationAfterTaskReplay', 'cancelRunningTaskEventStream');
|
||||
|
||||
assert.match(attachSource, /const eventStreamResponsePromise = apiFetch\(url/);
|
||||
assert.ok(attachSource.indexOf('const eventStreamResponsePromise') < attachSource.indexOf('loadProcessDetailsPaginated'));
|
||||
assert.match(attachSource, /if \(!active\) \{[\s\S]*?assistantMessageNeedsTaskReplayReconcile\(staleAssistant\)[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!response\.ok\) \{[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!replaySawDone\) \{[\s\S]*?reconcileConversationAfterTaskReplay/);
|
||||
assert.match(reconcileSource, /updateAssistantBubbleContent\(assistantEl\.id, finalMessage\.content \|\| '', true\)/);
|
||||
assert.match(reconcileSource, /loadProcessDetailsPaginated\(assistantEl\.id, finalMessage\.id,[\s\S]*?initialLatest: true,[\s\S]*?autoLoadAll: false/);
|
||||
});
|
||||
|
||||
test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
|
||||
assert.match(bindSource, /scrollMode === 'following'/);
|
||||
assert.match(bindSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.match(bindSource, /\{ childList: true, subtree: true, characterData: true \}/);
|
||||
assert.match(bindSource, /new ResizeObserver/);
|
||||
assert.match(bindSource, /chatMessagesResizeObserver\.observe\(el\)/);
|
||||
assert.match(bindSource, /改变消息区 clientHeight/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /e\.deltaY < -1/);
|
||||
assert.match(bindSource, /e\.clientX >= rect\.right - 18/);
|
||||
assert.match(bindSource, /e\.key === 'ArrowUp'/);
|
||||
});
|
||||
|
||||
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
|
||||
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260813-6');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260813-9');
|
||||
|
||||
assert.notEqual(scrollIndex, -1);
|
||||
assert.notEqual(monitorIndex, -1);
|
||||
assert.ok(scrollIndex < monitorIndex);
|
||||
});
|
||||
|
||||
test('直接点击项目对话也会写入 hash 以便刷新后恢复并补流', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const syncSource = functionSource(chat, 'syncChatConversationHash', 'getConversationLiteFromCache');
|
||||
const streamSource = functionSource(monitor, 'setCurrentConversationIdFromStream', 'shouldSkipTaskEventReplayAttach');
|
||||
|
||||
assert.match(syncSource, /window\.location\.hash\.split\('\?'\)\[0\] !== '#chat'/);
|
||||
assert.match(syncSource, /#chat\?conversation=/);
|
||||
assert.match(syncSource, /window\.history\.replaceState/);
|
||||
assert.match(loadSource, /syncChatConversationHash\(conversationId\)/);
|
||||
assert.match(streamSource, /window\.syncChatConversationHash\(cid\)/);
|
||||
});
|
||||
|
||||
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
|
||||
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
|
||||
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
|
||||
assert.match(router, /scheduleChatConversationFromHash\(0\)/);
|
||||
assert.doesNotMatch(router, /scheduleChatConversationFromHash\((200|500)\)/);
|
||||
assert.match(scheduleSource, /setChatConversationRestorePending\(conversationId, true\)/);
|
||||
assert.match(restoreStateSource, /is-conversation-restoring/);
|
||||
assert.match(restoreStateSource, /aria-busy/);
|
||||
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=20260813-3/);
|
||||
});
|
||||
|
||||
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
|
||||
const findSource = functionSource(monitor, 'findRestoredMainResponseStreamItem', 'responseStreamStateFromRestoredItem');
|
||||
const handleSource = functionSource(monitor, 'handleStreamEvent', 'hitlApprovalTranslate');
|
||||
|
||||
assert.match(findSource, /timeline-item-planning/);
|
||||
assert.match(findSource, /dataset\.responseStreamId/);
|
||||
assert.match(handleSource, /case 'response_start':[\s\S]*?findRestoredMainResponseStreamItem/);
|
||||
assert.match(handleSource, /case 'response_delta':[\s\S]*?responseStreamStateFromRestoredItem/);
|
||||
assert.match(monitor, /item\.dataset\.responseStreamId = String\(options\.data\.streamId\)/);
|
||||
});
|
||||
|
||||
test('非仪表盘 hash 首屏在路由确定前隐藏默认仪表盘', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(html, /document\.documentElement\.classList\.add\('initial-route-pending'\)/);
|
||||
assert.match(router, /document\.documentElement\.classList\.remove\('initial-route-pending'\)/);
|
||||
assert.match(css, /html\.initial-route-pending \.content-area \{[\s\S]*?visibility: hidden;/);
|
||||
});
|
||||
|
||||
test('刷新恢复运行中助手消息时隐藏处理中占位且终态正文会重新显示', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning');
|
||||
|
||||
assert.match(loadSource, /hideAssistantPlaceholder: isAssistantPlaceholder/);
|
||||
assert.match(chat, /bubble\.hidden = true/);
|
||||
assert.match(updateSource, /assistant-placeholder-content/);
|
||||
assert.match(updateSource, /bubble\.hidden = false/);
|
||||
});
|
||||
|
||||
test('刷新补流任务完成后强制折叠自动展开的迭代详情', () => {
|
||||
const collapseSource = functionSource(monitor, 'collapseAllProgressDetails', 'getAssistantId');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(collapseSource, /options/);
|
||||
assert.match(collapseSource, /forceCollapse/);
|
||||
assert.match(collapseSource, /delete detailsContainer\.dataset\.userExpanded/);
|
||||
assert.match(attachSource, /collapseAllProgressDetails\(finalAssistant\.id, progressId, \{ force: true \}\)/);
|
||||
assert.doesNotMatch(attachSource, /if \(keepExpanded\)/);
|
||||
});
|
||||
|
||||
test('暗色模式用户气泡使用协调的深蓝灰层级', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.message\.user \.message-bubble \{[\s\S]*?background: #1b2638;/);
|
||||
assert.match(css, /border-color: rgba\(96, 165, 250, 0\.18\)/);
|
||||
});
|
||||
|
||||
test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
|
||||
assert.match(html, /style\.css\?v=20260813-5/);
|
||||
});
|
||||
+549
-49
@@ -7,32 +7,322 @@
|
||||
|
||||
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
|
||||
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||
/** FAB 隐藏:用户已手动滚近底部 */
|
||||
const CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX = 120;
|
||||
/** 只有真正到达底部才恢复跟随;2px 用于兼容高分屏的亚像素滚动。 */
|
||||
const CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2;
|
||||
/** 到达此范围视为位于最后一轮 */
|
||||
const CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX = 120;
|
||||
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
|
||||
const DETACH_LOCK_MS = 280;
|
||||
const DETACH_LOCK_MS = 900;
|
||||
/** 刷新恢复会跨越历史消息、过程详情、字体与流订阅等多轮异步布局。 */
|
||||
const CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000;
|
||||
const CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000;
|
||||
const CONVERSATION_RESTORE_STABLE_FRAMES = 12;
|
||||
|
||||
/** @type {'following' | 'detached'} */
|
||||
let scrollMode = 'following';
|
||||
let scrollFollowRaf = 0;
|
||||
let scrollSettleGeneration = 0;
|
||||
let conversationRestoreGeneration = 0;
|
||||
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
|
||||
let hasPendingNewBelow = false;
|
||||
let listenersBound = false;
|
||||
let lastScrollTop = 0;
|
||||
let lastScrollHeight = 0;
|
||||
let programmaticScroll = false;
|
||||
let detachLockUntil = 0;
|
||||
/** 最近一次由用户发起的滚动意图;布局变化或脚本滚动不得据此恢复粘底。 */
|
||||
let userScrollIntentUntil = 0;
|
||||
let turnRailRefreshRaf = 0;
|
||||
let turnRailSignature = '';
|
||||
let activeTurnIndex = -1;
|
||||
let turnRailObserver = null;
|
||||
let chatMessagesResizeObserver = null;
|
||||
let turnPreviewHideTimer = 0;
|
||||
|
||||
function getChatMessagesEl() {
|
||||
return document.getElementById('chat-messages');
|
||||
}
|
||||
|
||||
/** 主 POST 流 + 刷新后 task-events 补流均视为「流式进行中」 */
|
||||
function getTurnRailEl() {
|
||||
return document.getElementById('chat-turn-rail');
|
||||
}
|
||||
|
||||
function getTurnRailMarkersEl() {
|
||||
return document.getElementById('chat-turn-rail-markers');
|
||||
}
|
||||
|
||||
function getReturnLatestButton() {
|
||||
return document.getElementById('chat-return-latest');
|
||||
}
|
||||
|
||||
function normalizePreviewText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function trimPreviewText(value, maxLength) {
|
||||
const text = normalizePreviewText(value);
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…';
|
||||
}
|
||||
|
||||
function messagePreviewText(messageEl) {
|
||||
if (!messageEl) return '';
|
||||
const original = messageEl.dataset ? messageEl.dataset.originalContent : '';
|
||||
if (original) return normalizePreviewText(original);
|
||||
const bubble = messageEl.querySelector('.assistant-final-result, .message-bubble');
|
||||
if (!bubble) return '';
|
||||
const clone = bubble.cloneNode(true);
|
||||
clone.querySelectorAll('button, .message-copy-btn, .progress-actions, .progress-footer, .process-details-content').forEach(function (el) {
|
||||
el.remove();
|
||||
});
|
||||
return normalizePreviewText(clone.textContent);
|
||||
}
|
||||
|
||||
/** 每条用户消息开始一轮,直到下一条用户消息前的助手消息都归入该轮。 */
|
||||
function collectConversationTurns() {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
if (!messagesEl) return [];
|
||||
const turns = [];
|
||||
let currentTurn = null;
|
||||
Array.from(messagesEl.children).forEach(function (messageEl) {
|
||||
if (!messageEl.classList || !messageEl.classList.contains('message')) return;
|
||||
if (messageEl.classList.contains('user')) {
|
||||
currentTurn = { user: messageEl, assistants: [] };
|
||||
turns.push(currentTurn);
|
||||
return;
|
||||
}
|
||||
if (currentTurn && messageEl.classList.contains('assistant')) {
|
||||
currentTurn.assistants.push(messageEl);
|
||||
}
|
||||
});
|
||||
return turns;
|
||||
}
|
||||
|
||||
function localizedTurnLabel(index, question) {
|
||||
const number = index + 1;
|
||||
const prefix = typeof window.t === 'function'
|
||||
? window.t('chat.turnNumber', { number: number })
|
||||
: '第 ' + number + ' 轮';
|
||||
const safePrefix = prefix && prefix !== 'chat.turnNumber' ? prefix : ('第 ' + number + ' 轮');
|
||||
return question ? safePrefix + ':' + question : safePrefix;
|
||||
}
|
||||
|
||||
function turnPreviewData(turn, index) {
|
||||
const question = trimPreviewText(messagePreviewText(turn && turn.user), 100)
|
||||
|| localizedTurnLabel(index, '');
|
||||
const assistants = turn && turn.assistants ? turn.assistants : [];
|
||||
let assistant = null;
|
||||
for (let i = assistants.length - 1; i >= 0; i--) {
|
||||
if (!assistants[i].classList.contains('progress-message')) {
|
||||
assistant = assistants[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!assistant && assistants.length) assistant = assistants[assistants.length - 1];
|
||||
let summary = trimPreviewText(messagePreviewText(assistant), 220);
|
||||
if (!summary) {
|
||||
summary = typeof window.t === 'function' ? window.t('chat.turnPending') : '正在处理…';
|
||||
if (!summary || summary === 'chat.turnPending') summary = '正在处理…';
|
||||
}
|
||||
return { question: question, summary: summary };
|
||||
}
|
||||
|
||||
function hideTurnPreview() {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
const preview = document.getElementById('chat-turn-rail-preview');
|
||||
if (preview) preview.hidden = true;
|
||||
}
|
||||
|
||||
function scheduleHideTurnPreview() {
|
||||
if (turnPreviewHideTimer) window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = window.setTimeout(hideTurnPreview, 160);
|
||||
}
|
||||
|
||||
function showTurnPreview(marker, index) {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
const preview = document.getElementById('chat-turn-rail-preview');
|
||||
const title = document.getElementById('chat-turn-rail-preview-title');
|
||||
const summary = document.getElementById('chat-turn-rail-preview-summary');
|
||||
const turn = collectConversationTurns()[index];
|
||||
if (!preview || !title || !summary || !marker || !turn) return;
|
||||
|
||||
const data = turnPreviewData(turn, index);
|
||||
title.textContent = data.question;
|
||||
summary.textContent = data.summary;
|
||||
preview.hidden = false;
|
||||
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const left = Math.min(markerRect.right + 18, window.innerWidth - previewRect.width - 12);
|
||||
const desiredTop = markerRect.top + markerRect.height / 2 - previewRect.height / 2;
|
||||
const top = Math.max(12, Math.min(desiredTop, window.innerHeight - previewRect.height - 12));
|
||||
preview.style.left = Math.max(12, left) + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function setActiveTurnMarker(index) {
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
if (!markersEl) return;
|
||||
const markers = Array.from(markersEl.querySelectorAll('.chat-turn-rail-marker'));
|
||||
if (!markers.length) return;
|
||||
const nextIndex = Math.max(0, Math.min(index, markers.length - 1));
|
||||
markers.forEach(function (marker, markerIndex) {
|
||||
const active = markerIndex === nextIndex;
|
||||
marker.classList.toggle('is-active', active);
|
||||
if (active) marker.setAttribute('aria-current', 'step');
|
||||
else marker.removeAttribute('aria-current');
|
||||
});
|
||||
markers[markers.length - 1].classList.toggle('has-pending-new', hasPendingNewBelow);
|
||||
|
||||
if (activeTurnIndex !== nextIndex) {
|
||||
activeTurnIndex = nextIndex;
|
||||
const activeMarker = markers[nextIndex];
|
||||
const markerTop = activeMarker.offsetTop;
|
||||
const markerBottom = markerTop + activeMarker.offsetHeight;
|
||||
if (markerTop < markersEl.scrollTop) {
|
||||
markersEl.scrollTop = Math.max(0, markerTop - 8);
|
||||
} else if (markerBottom > markersEl.scrollTop + markersEl.clientHeight) {
|
||||
markersEl.scrollTop = markerBottom - markersEl.clientHeight + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTurnRailActive() {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
const turns = collectConversationTurns();
|
||||
if (!messagesEl || !turns.length) return;
|
||||
if (isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX)) {
|
||||
setActiveTurnMarker(turns.length - 1);
|
||||
return;
|
||||
}
|
||||
const readingLine = messagesEl.scrollTop + messagesEl.clientHeight * 0.34;
|
||||
let index = 0;
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
if (turns[i].user.offsetTop <= readingLine) index = i;
|
||||
else break;
|
||||
}
|
||||
setActiveTurnMarker(index);
|
||||
}
|
||||
|
||||
function jumpToConversationTurn(index) {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
const turn = collectConversationTurns()[index];
|
||||
if (!messagesEl || !turn || !turn.user) return;
|
||||
setScrollDetached();
|
||||
programmaticScroll = true;
|
||||
messagesEl.scrollTo({
|
||||
top: Math.max(0, turn.user.offsetTop - 20),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
setActiveTurnMarker(index);
|
||||
hideTurnPreview();
|
||||
window.setTimeout(function () {
|
||||
programmaticScroll = false;
|
||||
lastScrollTop = messagesEl.scrollTop;
|
||||
updateTurnRailActive();
|
||||
}, 420);
|
||||
}
|
||||
|
||||
function focusTurnMarker(index) {
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
const marker = markersEl && markersEl.querySelector('.chat-turn-rail-marker[data-turn-index="' + index + '"]');
|
||||
if (marker) marker.focus();
|
||||
}
|
||||
|
||||
function rebuildTurnRail(force) {
|
||||
const rail = getTurnRailEl();
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
if (!rail || !markersEl) return;
|
||||
const turns = collectConversationTurns();
|
||||
rail.hidden = turns.length === 0;
|
||||
if (!turns.length) {
|
||||
markersEl.replaceChildren();
|
||||
turnRailSignature = '';
|
||||
activeTurnIndex = -1;
|
||||
hideTurnPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = turns.map(function (turn, index) {
|
||||
return (turn.user.id || ('turn-' + index)) + ':' + messagePreviewText(turn.user);
|
||||
}).join('|');
|
||||
if (!force && signature === turnRailSignature) {
|
||||
updateTurnRailActive();
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
turns.forEach(function (turn, index) {
|
||||
const marker = document.createElement('button');
|
||||
const question = trimPreviewText(messagePreviewText(turn.user), 88);
|
||||
marker.type = 'button';
|
||||
marker.className = 'chat-turn-rail-marker';
|
||||
marker.dataset.turnIndex = String(index);
|
||||
marker.setAttribute('aria-label', localizedTurnLabel(index, question));
|
||||
marker.addEventListener('click', function () {
|
||||
jumpToConversationTurn(index);
|
||||
});
|
||||
marker.addEventListener('mouseenter', function () {
|
||||
showTurnPreview(marker, index);
|
||||
});
|
||||
marker.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||||
marker.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(Math.min(turns.length - 1, index + 1));
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(Math.max(0, index - 1));
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(0);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(turns.length - 1);
|
||||
}
|
||||
});
|
||||
fragment.appendChild(marker);
|
||||
});
|
||||
markersEl.replaceChildren(fragment);
|
||||
turnRailSignature = signature;
|
||||
activeTurnIndex = -1;
|
||||
updateTurnRailActive();
|
||||
}
|
||||
|
||||
function scheduleTurnRailRefresh(force) {
|
||||
cancelAnimationFrame(turnRailRefreshRaf);
|
||||
turnRailRefreshRaf = requestAnimationFrame(function () {
|
||||
rebuildTurnRail(force === true);
|
||||
});
|
||||
}
|
||||
|
||||
function streamBelongsToVisibleConversation(stream) {
|
||||
if (!stream || !stream.active) return false;
|
||||
const visibleConversationId = typeof window.currentConversationId === 'string'
|
||||
? window.currentConversationId.trim()
|
||||
: '';
|
||||
const streamConversationId = typeof stream.conversationId === 'string'
|
||||
? stream.conversationId.trim()
|
||||
: '';
|
||||
|
||||
// 新建对话在后端返回 conversationId 前,两边都为空,仍属于当前界面。
|
||||
if (!streamConversationId) return !visibleConversationId;
|
||||
return streamConversationId === visibleConversationId;
|
||||
}
|
||||
|
||||
/** 只有当前可见对话的主 POST 流 / task-events 补流才视为「正在输出」 */
|
||||
function isStreamActive() {
|
||||
try {
|
||||
const live = window.__csAgentLiveStream;
|
||||
if (live && live.active) return true;
|
||||
if (streamBelongsToVisibleConversation(live)) return true;
|
||||
const replay = window.__csTaskEventStream;
|
||||
return !!(replay && replay.active);
|
||||
return streamBelongsToVisibleConversation(replay);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -51,34 +341,42 @@
|
||||
}
|
||||
|
||||
function isChatMessagesPinnedToBottom() {
|
||||
return isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
return isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached) */
|
||||
function resumeFollowingIfAtBottom() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (!isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX)) return false;
|
||||
if (scrollMode === 'detached') setScrollFollowing();
|
||||
function resumeFollowingIfAtBottom(thresholdPx, userInitiated) {
|
||||
if (!userInitiated && Date.now() < detachLockUntil) return false;
|
||||
const threshold = Number.isFinite(Number(thresholdPx))
|
||||
? Math.max(0, Number(thresholdPx))
|
||||
: CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
|
||||
if (!isNearBottom(threshold)) return false;
|
||||
// detached 是用户明确上滑后的阅读状态。布局变化、流式增高和模式切换
|
||||
// 即使让视口暂时接近底部,也不能自行恢复;只有用户明确向下滚到底才恢复。
|
||||
if (scrollMode === 'detached') {
|
||||
if (!userInitiated) return false;
|
||||
setScrollFollowing();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function captureScrollPinState() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
return scrollMode === 'following';
|
||||
}
|
||||
|
||||
function setScrollFollowing() {
|
||||
scrollMode = 'following';
|
||||
detachLockUntil = 0;
|
||||
userScrollIntentUntil = 0;
|
||||
hasPendingNewBelow = false;
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function markPendingNewBelow() {
|
||||
if (scrollMode !== 'detached') return;
|
||||
hasPendingNewBelow = true;
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function setScrollDetached() {
|
||||
@@ -88,7 +386,7 @@
|
||||
if (isStreamActive()) {
|
||||
hasPendingNewBelow = true;
|
||||
}
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function scrollChatToBottomInstant() {
|
||||
@@ -98,6 +396,7 @@
|
||||
programmaticScroll = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
});
|
||||
@@ -111,34 +410,52 @@
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
const node = getChatMessagesEl();
|
||||
if (node) lastScrollTop = node.scrollTop;
|
||||
if (node) {
|
||||
lastScrollTop = node.scrollTop;
|
||||
lastScrollHeight = node.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateScrollToBottomFab() {
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (!fab) return;
|
||||
function updateTurnRailState() {
|
||||
updateTurnRailActive();
|
||||
updateReturnLatestButton();
|
||||
}
|
||||
|
||||
const show = scrollMode === 'detached' && !isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
fab.classList.toggle('visible', show);
|
||||
function updateReturnLatestButton() {
|
||||
const button = getReturnLatestButton();
|
||||
const messagesEl = getChatMessagesEl();
|
||||
if (!button || !messagesEl) return;
|
||||
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
|
||||
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||||
const streaming = shouldShow && isStreamActive();
|
||||
button.hidden = !shouldShow;
|
||||
button.classList.toggle('is-streaming', streaming);
|
||||
button.classList.toggle('has-pending-new', shouldShow && hasPendingNewBelow);
|
||||
}
|
||||
|
||||
let label;
|
||||
if (hasPendingNewBelow) {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottomHasNew')
|
||||
: '↓ 有新内容';
|
||||
} else {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottom')
|
||||
: '回到底部';
|
||||
function isolateReturnLatestPointerEvent(event) {
|
||||
if (!event) return;
|
||||
// 该按钮会在点击后立即隐藏。阻止指针事件继续冒泡,避免长历史对话中
|
||||
// 按钮隐藏与底部审批卡片重排发生在同一帧时产生点击穿透。
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onReturnLatestClick(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
forceScrollChatToBottom(true);
|
||||
const button = getReturnLatestButton();
|
||||
if (button) {
|
||||
button.hidden = true;
|
||||
button.blur();
|
||||
}
|
||||
fab.setAttribute('aria-label', label);
|
||||
fab.textContent = label;
|
||||
}
|
||||
|
||||
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
if (scrollMode === 'detached') return false;
|
||||
if (wasPinnedBeforeDomUpdate === true) return true;
|
||||
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
|
||||
@@ -153,6 +470,79 @@
|
||||
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
|
||||
}
|
||||
|
||||
/**
|
||||
* 长详情恢复/终态对账会跨多个 requestAnimationFrame 分批增高 DOM。
|
||||
* 单次滚底可能早于最后一批节点;在仍处于 following 时连续若干帧校准,
|
||||
* 用户一旦主动上滑进入 detached,后续帧立即停止,避免抢回阅读位置。
|
||||
*/
|
||||
function settleChatToBottomIfFollowing(frameCount) {
|
||||
const frames = Number.isFinite(Number(frameCount))
|
||||
? Math.max(1, Math.min(30, Math.floor(Number(frameCount))))
|
||||
: 12;
|
||||
const generation = ++scrollSettleGeneration;
|
||||
|
||||
function settleFrame(remaining) {
|
||||
if (generation !== scrollSettleGeneration) return;
|
||||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||||
scrollChatToBottomInstant();
|
||||
if (remaining > 1) {
|
||||
requestAnimationFrame(function () {
|
||||
settleFrame(remaining - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
settleFrame(frames);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新恢复长会话时,消息、详情和审批卡会跨多帧继续增高。
|
||||
* 进入恢复流程时明确回到 following;用户随后若主动上滑,既有输入监听会立即
|
||||
* 切换为 detached,并使后续校准帧停止,不会抢回阅读位置。
|
||||
*/
|
||||
function settleConversationRestoreToBottom(frameCount) {
|
||||
setScrollFollowing();
|
||||
const requestedFrames = Number.isFinite(Number(frameCount))
|
||||
? Math.max(1, Math.floor(Number(frameCount)))
|
||||
: 30;
|
||||
const minimumDuration = Math.max(
|
||||
CONVERSATION_RESTORE_SETTLE_MIN_MS,
|
||||
Math.ceil(requestedFrames * (1000 / 60))
|
||||
);
|
||||
const generation = ++conversationRestoreGeneration;
|
||||
const startedAt = Date.now();
|
||||
let lastHeight = -1;
|
||||
let stableFrames = 0;
|
||||
|
||||
function settleRestoreFrame() {
|
||||
if (generation !== conversationRestoreGeneration) return;
|
||||
// wheel / touch / keyboard / scrollbar drag 会进入 detached;立即尊重用户阅读位置。
|
||||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
scrollChatToBottomInstant();
|
||||
const currentHeight = el.scrollHeight;
|
||||
if (currentHeight === lastHeight && isNearBottom(1)) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
lastHeight = currentHeight;
|
||||
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const reachedStableMinimum = elapsed >= minimumDuration
|
||||
&& stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES;
|
||||
if (!reachedStableMinimum && elapsed < CONVERSATION_RESTORE_SETTLE_MAX_MS) {
|
||||
requestAnimationFrame(settleRestoreFrame);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(settleRestoreFrame);
|
||||
}
|
||||
|
||||
/** @param {boolean} wasPinned DOM 更新前是否应跟随(由 captureScrollPinState 传入) */
|
||||
function scrollChatMessagesToBottomIfPinned(wasPinned) {
|
||||
scheduleChatScrollToBottomIfFollowing(wasPinned);
|
||||
@@ -210,7 +600,8 @@
|
||||
try {
|
||||
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
|
||||
} catch (e) { /* ignore */ }
|
||||
updateScrollToBottomFab();
|
||||
scheduleTurnRailRefresh(true);
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
|
||||
@@ -225,7 +616,8 @@
|
||||
} catch (e) { /* ignore */ }
|
||||
markProcessDetailsStreaming(true, assistantDomId);
|
||||
resumeFollowingIfAtBottom();
|
||||
updateScrollToBottomFab();
|
||||
scheduleTurnRailRefresh();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function onTaskEventStreamEnd() {
|
||||
@@ -233,6 +625,7 @@
|
||||
}
|
||||
|
||||
function applyMessageScrollOption(options) {
|
||||
scheduleTurnRailRefresh();
|
||||
const opt = (options && options.scroll) || 'follow';
|
||||
if (opt === 'none') return;
|
||||
if (opt === 'force') {
|
||||
@@ -252,22 +645,51 @@
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
const st = el.scrollTop;
|
||||
const sh = el.scrollHeight;
|
||||
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil;
|
||||
|
||||
if (programmaticScroll) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
// 正在执行恢复/流式粘底时,用户仍可能反向滚轮或拖动滚动条。
|
||||
// 脚本滚底只会让 scrollTop 增大;此处出现减小必定是用户在中断跟随。
|
||||
if (st < lastScrollTop - 1 && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||||
setScrollDetached();
|
||||
}
|
||||
lastScrollTop = st;
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
return;
|
||||
}
|
||||
|
||||
const st = el.scrollTop;
|
||||
const scrolledUp = st < lastScrollTop - 1;
|
||||
const scrolledDown = st > lastScrollTop + 1;
|
||||
const contentShrank = sh < lastScrollHeight - 1;
|
||||
|
||||
if (scrolledUp) {
|
||||
// 刷新/终态重绘会先清空或折叠旧 DOM,浏览器会被动把 scrollTop 压小。
|
||||
// 这不是用户上滑,不应错误退出 following。
|
||||
if (contentShrank) {
|
||||
lastScrollTop = st;
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新恢复会重建消息和详情,滚动锚定可能在没有用户输入时让 scrollTop
|
||||
// 暂时减小。只有明确的滚轮、触控、键盘或滚动条意图才解除粘底。
|
||||
if (scrolledUp && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||||
setScrollDetached();
|
||||
} else if (resumeFollowingIfAtBottom()) {
|
||||
/* 拖滚动条/点击轨道跳到底部时也恢复跟随 */
|
||||
} else if (
|
||||
scrolledDown &&
|
||||
hasUserScrollIntent &&
|
||||
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true)
|
||||
) {
|
||||
// 仅在用户明确向下滚动并到达真实底部时恢复跟随,不主动改写 scrollTop。
|
||||
// 后续新增内容再按 following 状态自然粘底,避免接近底部时突然跳动。
|
||||
}
|
||||
|
||||
lastScrollTop = st;
|
||||
updateScrollToBottomFab();
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function bindChatScrollListeners() {
|
||||
@@ -276,13 +698,38 @@
|
||||
if (!el) return;
|
||||
listenersBound = true;
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
|
||||
el.addEventListener('wheel', function (e) {
|
||||
if (e.deltaY < -1) setScrollDetached();
|
||||
if (Math.abs(e.deltaY) > 1) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
}
|
||||
if (e.deltaY < -1) {
|
||||
setScrollDetached();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// 拖动原生纵向滚动条不会产生 wheel;先记录指针意图,再由 scroll 事件确认方向。
|
||||
el.addEventListener('pointerdown', function (e) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (e.clientX >= rect.right - 18) {
|
||||
userScrollIntentUntil = Date.now() + 1800;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('keydown', function (e) {
|
||||
const scrollKeys = ['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '];
|
||||
if (scrollKeys.includes(e.key)) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home' || (e.key === ' ' && e.shiftKey)) {
|
||||
setScrollDetached();
|
||||
}
|
||||
});
|
||||
|
||||
el.addEventListener('touchmove', function (e) {
|
||||
if (e.touches && e.touches.length === 1) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
|
||||
if (e.touches[0].clientY > el._csTouchLastY + 4) {
|
||||
setScrollDetached();
|
||||
@@ -301,19 +748,68 @@
|
||||
|
||||
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
|
||||
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (fab) {
|
||||
fab.addEventListener('click', function () {
|
||||
forceScrollChatToBottom(true);
|
||||
});
|
||||
const returnLatestButton = getReturnLatestButton();
|
||||
if (returnLatestButton) {
|
||||
returnLatestButton.addEventListener('pointerdown', isolateReturnLatestPointerEvent);
|
||||
returnLatestButton.addEventListener('pointerup', isolateReturnLatestPointerEvent);
|
||||
returnLatestButton.addEventListener('click', onReturnLatestClick);
|
||||
}
|
||||
|
||||
const turnPreview = document.getElementById('chat-turn-rail-preview');
|
||||
if (turnPreview) {
|
||||
turnPreview.addEventListener('mouseenter', function () {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
});
|
||||
turnPreview.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
turnRailObserver = new MutationObserver(function () {
|
||||
scheduleTurnRailRefresh();
|
||||
// 最终回复会替换消息气泡内部 HTML,任务详情也会在子树内持续增高。
|
||||
// 只在仍处于 following 时按帧合并粘底;用户上滑后的 detached 状态不受影响。
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
}
|
||||
});
|
||||
turnRailObserver.observe(el, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
chatMessagesResizeObserver = new ResizeObserver(function () {
|
||||
// 顶部运行任务条、输入框或视口变化会改变消息区 clientHeight,
|
||||
// 但不会触发消息子树 MutationObserver。跟随模式下需重新精确粘底。
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
} else {
|
||||
updateTurnRailState();
|
||||
}
|
||||
});
|
||||
chatMessagesResizeObserver.observe(el);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
hideTurnPreview();
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
} else {
|
||||
updateTurnRailState();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function initChatScroll() {
|
||||
bindChatScrollListeners();
|
||||
const el = getChatMessagesEl();
|
||||
if (el) lastScrollTop = el.scrollTop;
|
||||
updateScrollToBottomFab();
|
||||
if (el) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
}
|
||||
scheduleTurnRailRefresh(true);
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
window.CyberStrikeChatScroll = {
|
||||
@@ -325,6 +821,8 @@
|
||||
captureScrollPinState: captureScrollPinState,
|
||||
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
|
||||
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
|
||||
settleToBottomIfFollowing: settleChatToBottomIfFollowing,
|
||||
settleConversationRestoreToBottom: settleConversationRestoreToBottom,
|
||||
forceScrollToBottom: forceScrollChatToBottom,
|
||||
applyMessageScroll: applyMessageScrollOption,
|
||||
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
|
||||
@@ -333,6 +831,8 @@
|
||||
markProcessDetailsStreaming: markProcessDetailsStreaming,
|
||||
setScrollFollowing: setScrollFollowing,
|
||||
setScrollDetached: setScrollDetached,
|
||||
refreshReturnLatest: updateReturnLatestButton,
|
||||
refreshTurnRail: function () { scheduleTurnRailRefresh(true); },
|
||||
};
|
||||
|
||||
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
|
||||
|
||||
+1650
-215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('全局置顶检查接口结果并即时通知项目文件夹', () => {
|
||||
const source = functionSource(chat, 'pinConversation', 'showMoveToGroupSubmenu');
|
||||
|
||||
assert.match(source, /assertConversationActionResponse\(updateResponse, '更新置顶状态失败'\)/);
|
||||
assert.match(source, /notifyConversationPinnedChanged\(convId, newPinned\)/);
|
||||
assert.match(source, /loadConversationsWithGroups\(\)/);
|
||||
});
|
||||
|
||||
test('项目文件夹内置顶对话优先排序并显示图钉', () => {
|
||||
const sortSource = functionSource(projects, 'sortProjectFolderConversations', 'updateChatProjectConversationPinnedState');
|
||||
const itemSource = functionSource(projects, 'appendChatProjectConversationItem', 'selectChatProjectConversationItem');
|
||||
|
||||
assert.match(sortSource, /Number\(!!b\?\.pinned\) - Number\(!!a\?\.pinned\)/);
|
||||
assert.match(itemSource, /if \(conversation\.pinned\)/);
|
||||
assert.match(itemSource, /project-conversation-pinned/);
|
||||
});
|
||||
|
||||
test('删除事件立即移除项目缓存并触发权威刷新', () => {
|
||||
const removeSource = functionSource(projects, 'removeChatProjectConversation', 'refreshChatProjectFoldersAfterAction');
|
||||
|
||||
assert.match(removeSource, /chatProjectFolderContext\.conversations = chatProjectFolderContext\.conversations\.filter/);
|
||||
assert.match(projects, /document\.addEventListener\('conversation-deleted',[\s\S]{0,300}removeChatProjectConversation\(conversationId\)[\s\S]{0,180}refreshChatProjectFoldersAfterAction\(\)/);
|
||||
assert.match(chat, /document\.dispatchEvent\(new CustomEvent\('conversation-deleted'/);
|
||||
});
|
||||
|
||||
test('较旧的项目文件夹请求不能覆盖较新的操作结果', () => {
|
||||
const source = functionSource(projects, 'loadChatProjectFolderContext', 'getProjectConversationSortTime');
|
||||
|
||||
assert.match(source, /const loadSeq = \+\+chatProjectFolderContextLoadSeq/);
|
||||
assert.match(source, /if \(loadSeq !== chatProjectFolderContextLoadSeq\) return false/);
|
||||
});
|
||||
|
||||
test('项目文件夹菜单可以置顶并立即更新排序', () => {
|
||||
const toggleSource = functionSource(projects, 'toggleProjectPinnedFromListMenu', 'initProjectListActionMenu');
|
||||
const folderSource = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
assert.match(template, /onclick="toggleProjectPinnedFromListMenu\(\)"/);
|
||||
assert.match(toggleSource, /JSON\.stringify\(\{ pinned: nextPinned \}\)/);
|
||||
assert.match(toggleSource, /updateCachedProjectPinnedState\(projectId, nextPinned\)/);
|
||||
assert.match(folderSource, /if \(!isUnassigned && project\.pinned\)/);
|
||||
assert.match(folderSource, /project-folder-pinned/);
|
||||
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
|
||||
});
|
||||
|
||||
test('对话侧栏不再显示对话分组区域', () => {
|
||||
assert.doesNotMatch(template, /class="conversation-groups-section"/);
|
||||
assert.doesNotMatch(template, /id="conversation-groups-list"/);
|
||||
});
|
||||
|
||||
test('删除对话分组检查接口结果并先清理本地状态', () => {
|
||||
const deleteSource = functionSource(chat, 'deleteConversationGroupById', 'deleteGroup');
|
||||
const contextSource = functionSource(chat, 'deleteGroupFromContext', 'closeGroupContextMenu');
|
||||
|
||||
assert.match(deleteSource, /assertConversationActionResponse\(deleteResponse, '删除分组失败'\)/);
|
||||
assert.match(deleteSource, /removeConversationGroupFromLocalState\(groupId\)/);
|
||||
assert.match(deleteSource, /if \(currentGroupId === groupId\) exitGroupDetail\(\)/);
|
||||
assert.match(contextSource, /deleteConversationGroupById\(groupId, \{ closeContextMenu: true \}\)/);
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const chatScroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
const handler = fs.readFileSync('internal/handler/hitl.go', 'utf8');
|
||||
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
|
||||
|
||||
test('输入区提供独立审批入口并暴露可配置等待时限', () => {
|
||||
assert.match(template, /id="chat-hitl-approval-dock"/);
|
||||
assert.match(template, /id="hitl-timeout-select"/);
|
||||
assert.match(template, /option value="300" selected/);
|
||||
assert.match(chat, /DEFAULT_HITL_TIMEOUT_SECONDS = 300/);
|
||||
assert.match(chat, /timeoutSeconds: normalizeHitlTimeoutForChat/);
|
||||
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
|
||||
});
|
||||
|
||||
test('输入框可直接保存系统模型和系统推理强度且审批模型只出现在审计 Agent 入口', () => {
|
||||
assert.match(chat, /function currentSystemModelLabel\(\)/);
|
||||
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
|
||||
assert.match(chat, /function currentHitlAuditModelLabel\(\)/);
|
||||
assert.match(chat, /const label = currentSystemModelLabel\(\)/);
|
||||
assert.doesNotMatch(chat, /const label = data\.model \|\| currentChatModelLabel\(\)/);
|
||||
assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditModelLabel\(\) : ''/);
|
||||
assert.match(chat, /hitlAuditModel\.model\.trim\(\)/);
|
||||
assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/);
|
||||
assert.match(template, /id="chat-system-model-menu"[^>]+hidden/);
|
||||
assert.doesNotMatch(template, /id="chat-reasoning-shortcut"/);
|
||||
assert.match(template, /openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/);
|
||||
assert.match(chat, /function renderChatReasoningEffortOptions\(\)/);
|
||||
assert.match(chat, /function currentSystemReasoningEffort\(\)[\s\S]{0,500}reasoning\.effort/);
|
||||
assert.match(chat, /case 'low': return 'low'[\s\S]{0,300}case 'max': return 'max'/);
|
||||
assert.match(chat, /chatTranslate\('chat\.reasoningEffortUnset', '不指定'\)/);
|
||||
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,2400}reasoning: \{ \.\.\.\(state\.channel\.reasoning \|\| \{\}\), effort: chosen \}/);
|
||||
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,4200}body: JSON\.stringify\(\{ ai: state\.ai \}\)[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
|
||||
assert.match(chat, /function openChatSystemModelPicker\(event\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/);
|
||||
assert.match(chat, /function selectChatSystemModel\(model\)[\s\S]{0,2600}method: 'PUT'[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
|
||||
assert.match(chat, /body: JSON\.stringify\(\{ ai: state\.ai \}\)/);
|
||||
assert.equal(zh.chat.modelSettingsAria, '选择模型与推理强度');
|
||||
assert.equal(en.chat.modelSettingsAria, 'Choose model and reasoning effort');
|
||||
});
|
||||
|
||||
test('审批请求按浏览器、命令、文件和通用工具动态描述', () => {
|
||||
assert.match(monitor, /function hitlApprovalTemplate/);
|
||||
assert.match(monitor, /hitlApprovalTranslate\(key, fallback\)/);
|
||||
assert.match(monitor, /replaceAll\('\{\{' \+ name \+ '\}\}'/);
|
||||
assert.match(monitor, /function describeHitlApprovalRequest/);
|
||||
assert.match(monitor, /requestVisitUrl/);
|
||||
assert.match(monitor, /requestCommand/);
|
||||
assert.match(monitor, /requestFile/);
|
||||
assert.match(monitor, /requestGeneric/);
|
||||
assert.match(monitor, /let displayTool = rawToolName/);
|
||||
assert.doesNotMatch(monitor, /displayTool = 'Browser'/);
|
||||
assert.doesNotMatch(monitor, /displayTool = hitlApprovalTranslate\('hitl\.toolTerminal'/);
|
||||
assert.doesNotMatch(monitor, /displayTool = hitlApprovalTranslate\('hitl\.toolFiles'/);
|
||||
});
|
||||
|
||||
test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () => {
|
||||
const logsHandler = fs.readFileSync('internal/handler/hitl_logs.go', 'utf8');
|
||||
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/);
|
||||
assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/);
|
||||
assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/);
|
||||
assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/);
|
||||
assert.match(monitor, /function isAgentReviewedHitl\(data\)/);
|
||||
assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/);
|
||||
assert.match(monitor, /if \(!isAgentReviewedHitl\(data\)\) \{[\s\S]{0,240}bindHitlApprovalCountdown/);
|
||||
assert.match(monitor, /if \(isAgentReviewedHitl\(data\)\) return false/);
|
||||
assert.match(projects, /filter\(isHumanProjectPendingApproval\)/);
|
||||
assert.match(projects, /if \(!isHumanProjectPendingApproval\(details\)\) return/);
|
||||
assert.match(hitlPage, /const items = rawItems\.filter/);
|
||||
});
|
||||
|
||||
test('人工批准不要求输入备注,审查编辑仅发送真正修改过的参数', () => {
|
||||
assert.match(monitor, /if \(!approveBtn \|\| !rejectBtn \|\| !statusEl\) return/);
|
||||
assert.doesNotMatch(monitor, /!commentInput \|\| !statusEl/);
|
||||
assert.match(monitor, /JSON\.stringify\(editedArgs\) === JSON\.stringify\(originalArgs\)/);
|
||||
assert.match(monitor, /editedArgs = null/);
|
||||
});
|
||||
|
||||
test('长历史对话的回到最新按钮不会把滚动点击穿透到审批操作', () => {
|
||||
assert.match(chatScroll, /function isolateReturnLatestPointerEvent\(event\)/);
|
||||
assert.match(chatScroll, /returnLatestButton\.addEventListener\('pointerdown', isolateReturnLatestPointerEvent\)/);
|
||||
assert.match(chatScroll, /function onReturnLatestClick\(event\)[\s\S]{0,260}event\.preventDefault\(\)[\s\S]{0,180}event\.stopPropagation\(\)/);
|
||||
assert.match(monitor, /const bindExplicitHitlAction = function \(button, decision\)/);
|
||||
assert.match(monitor, /button\.addEventListener\('pointerdown'[\s\S]{0,900}pointerClick && !explicitlyPressed/);
|
||||
assert.match(monitor, /bindExplicitHitlAction\(approveBtn, 'approve'\)/);
|
||||
assert.match(monitor, /bindExplicitHitlAction\(rejectBtn, 'reject'\)/);
|
||||
});
|
||||
|
||||
test('轮次导航使用连续大热区并允许鼠标平滑进入 Codex 风格预览卡', () => {
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,260}gap: 0;/);
|
||||
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,420}overflow-x: hidden;/);
|
||||
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,520}touch-action: pan-y;/);
|
||||
assert.match(styles, /\.chat-turn-rail-marker \{[\s\S]{0,260}width: 36px;[\s\S]{0,160}height: 11px;/);
|
||||
assert.match(styles, /\.chat-turn-rail-marker::before \{[\s\S]{0,420}width: 12px;[\s\S]{0,120}height: 3px;/);
|
||||
assert.match(styles, /\.chat-turn-rail-marker:hover::before \{[\s\S]{0,100}width: 22px;/);
|
||||
assert.match(styles, /\.chat-turn-rail-preview \{[\s\S]{0,520}pointer-events: auto;/);
|
||||
assert.match(chatScroll, /function scheduleHideTurnPreview\(\)/);
|
||||
assert.match(chatScroll, /window\.setTimeout\(hideTurnPreview, 160\)/);
|
||||
assert.match(chatScroll, /turnPreview\.addEventListener\('mouseenter'/);
|
||||
assert.match(chatScroll, /marker\.addEventListener\('mouseleave', scheduleHideTurnPreview\)/);
|
||||
});
|
||||
|
||||
test('倒计时由服务端时间驱动,到期时只锁定界面并等待服务端拒绝', () => {
|
||||
assert.match(handler, /payload\["hitlApproval"\]/);
|
||||
assert.match(handler, /"expiresAt":\s+approvalExpiresAt/);
|
||||
assert.match(handler, /status = "timeout"/);
|
||||
assert.match(handler, /decidedBy = "system"/);
|
||||
assert.match(monitor, /function bindHitlApprovalCountdown/);
|
||||
assert.match(monitor, /setInterval\(update, 250\)/);
|
||||
assert.match(monitor, /expiredAutoRejected/);
|
||||
assert.doesNotMatch(monitor, /remaining <= 0[\s\S]{0,240}submitHitlDecisionWithPayload/);
|
||||
});
|
||||
|
||||
test('项目对话列表能同时显示等待批准与运行状态', () => {
|
||||
assert.match(projects, /pendingApprovalByConversation: new Map/);
|
||||
assert.match(projects, /statusKinds\.push\('approval'\)/);
|
||||
assert.match(projects, /statusKinds\.push\('running'\)/);
|
||||
assert.match(projects, /window\.setProjectConversationApprovalStatus/);
|
||||
assert.match(projects, /api\/hitl\/pending\?page=1&pageSize=200/);
|
||||
assert.match(projects, /function bindProjectApprovalProgress/);
|
||||
assert.match(projects, /project-approval-progress-value/);
|
||||
assert.match(projects, /PROJECT_APPROVAL_TICK_INTERVAL_MS = 1000/);
|
||||
assert.match(projects, /function registerProjectApprovalTicker/);
|
||||
assert.match(monitor, /function renderDirectHitlSidebarApproval/);
|
||||
assert.match(monitor, /hitlSidebarApprovalSyncTimer = window\.setInterval/);
|
||||
});
|
||||
|
||||
test('项目文件夹汇总始终为绿色且只有具体对话按剩余时间变色', () => {
|
||||
assert.match(projects, /waitingApprovalCount/);
|
||||
assert.match(projects, /aggregate: true, count: folderApprovals\.length/);
|
||||
assert.match(projects, /project-task-status--approval-summary', 'is-urgency-normal'/);
|
||||
assert.match(projects, /status\.dataset\.approvalUrgency = 'normal'/);
|
||||
assert.match(projects, /if \(isApprovalSummary\)[\s\S]{0,520}else \{[\s\S]{0,160}bindProjectApprovalUrgency\(status, details, label\)/);
|
||||
assert.doesNotMatch(projects, /currentExpiry < earliestExpiry/);
|
||||
assert.match(projects, /PROJECT_APPROVAL_URGENCY_CLASSES/);
|
||||
assert.match(projects, /remaining <= 60 \* 1000/);
|
||||
assert.match(projects, /remaining <= 3 \* 60 \* 1000/);
|
||||
assert.doesNotMatch(projects, /remaining <= 5 \* 60 \* 1000/);
|
||||
assert.match(projects, /project-task-status--approval-summary/);
|
||||
assert.equal(zh.hitl.waitingApprovalCount, '等待批准 {{count}}');
|
||||
assert.equal(zh.hitl.approvalUrgencyMoreThanThree, '最早审批将在 3 分钟后到期');
|
||||
assert.equal(typeof en.hitl.waitingApprovalCount, 'string');
|
||||
const urgencyFunctionSource = projects.match(
|
||||
/function projectApprovalUrgencyLevel\(remainingMilliseconds, hasDeadline\) \{[\s\S]*?\n\}/
|
||||
);
|
||||
assert.ok(urgencyFunctionSource, '应提供可测试的审批紧急程度函数');
|
||||
const urgencyLevel = vm.runInNewContext(`(${urgencyFunctionSource[0]})`);
|
||||
assert.equal(urgencyLevel(6 * 60 * 1000, true), 'normal');
|
||||
assert.equal(urgencyLevel(4 * 60 * 1000, true), 'normal');
|
||||
assert.equal(urgencyLevel(3 * 60 * 1000 + 1, true), 'normal');
|
||||
assert.equal(urgencyLevel(3 * 60 * 1000, true), 'warning');
|
||||
assert.equal(urgencyLevel(2 * 60 * 1000, true), 'warning');
|
||||
assert.equal(urgencyLevel(30 * 1000, true), 'critical');
|
||||
assert.equal(urgencyLevel(0, false), 'normal');
|
||||
});
|
||||
|
||||
test('切换对话后主按钮只读取当前可见对话的运行状态', () => {
|
||||
assert.match(chat, /function getVisibleChatConversationId\(\)/);
|
||||
assert.match(chat, /function shouldTreatLiveChatTaskAsCurrent\(/);
|
||||
assert.match(chat, /function isLiveChatTaskVisible\(/);
|
||||
assert.match(chat, /if \(visibleConversationId\) return visibleConversationId/);
|
||||
assert.match(chat, /isConversationTaskRunning\(visibleConversationId\)/);
|
||||
assert.doesNotMatch(
|
||||
chat,
|
||||
/function getCurrentChatTaskConversationId\(\) \{[\s\S]{0,220}if \(live && live\.active && live\.conversationId\) \{[\s\S]{0,100}return String\(live\.conversationId\)/
|
||||
);
|
||||
const visibilityFunctionSource = chat.match(
|
||||
/function shouldTreatLiveChatTaskAsCurrent\(liveConversationId, visibleConversationId, hasVisibleProgress\) \{[\s\S]*?\n\}/
|
||||
);
|
||||
assert.ok(visibilityFunctionSource, '应提供可测试的当前任务隔离函数');
|
||||
const isCurrent = vm.runInNewContext(`(${visibilityFunctionSource[0]})`);
|
||||
assert.equal(isCurrent('running-conversation', '', true), false);
|
||||
assert.equal(isCurrent('running-conversation', 'new-conversation', true), false);
|
||||
assert.equal(isCurrent('running-conversation', 'running-conversation', false), true);
|
||||
assert.equal(isCurrent('', '', true), true);
|
||||
assert.equal(isCurrent('', '', false), false);
|
||||
});
|
||||
|
||||
test('无项目使用独立虚拟文件夹且顶部新任务继承当前项目', () => {
|
||||
assert.match(projects, /CHAT_UNASSIGNED_PROJECT_FOLDER_ID/);
|
||||
assert.match(projects, /_isUnassigned: true/);
|
||||
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
|
||||
assert.match(projects, /window\.startNewConversation\(\{ projectId: isUnassigned \? '' : project\.id \}\)/);
|
||||
assert.match(chat, /Object\.prototype\.hasOwnProperty\.call\(options, 'projectId'\)/);
|
||||
assert.match(chat, /typeof resolveChatProjectSelection === 'function'/);
|
||||
assert.match(chat, /String\(inheritedProjectId \|\| ''\)\.trim\(\)/);
|
||||
assert.match(chat, /typeof setActiveProjectId === 'function'\) setActiveProjectId\(requestedProjectId\)/);
|
||||
assert.equal(zh.chat.newUnassignedConversation, '新建无项目对话');
|
||||
assert.equal(typeof en.chat.newUnassignedConversation, 'string');
|
||||
});
|
||||
|
||||
test('单个对话的审批徽标随倒计时同步切换紧急颜色', () => {
|
||||
assert.match(projects, /bindProjectApprovalProgress\(status, details\);\s*bindProjectApprovalUrgency\(status, details, label\);/);
|
||||
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /\.project-task-status--approval\.is-urgency-critical/);
|
||||
});
|
||||
|
||||
test('项目状态刷新复用单一计时器且切换对话不重复请求完整项目上下文', () => {
|
||||
assert.match(projects, /const projectApprovalTickerEntries = new Set\(\)/);
|
||||
assert.match(projects, /if \(!changed && !approvalChanged\) return/);
|
||||
assert.match(projects, /options\.reloadFolders !== false/);
|
||||
assert.match(chat, /refreshChatProjectSelector\(\{ reloadFolders: false, renderFolders: false \}\)/);
|
||||
assert.match(projects, /function selectChatProjectConversationItem/);
|
||||
assert.match(projects, /options\.renderFolders !== false/);
|
||||
assert.match(projects, /projectConversationPreviewSuppressedUntil = Date\.now\(\) \+ 700/);
|
||||
assert.match(projects, /project-task-status-group--folder/);
|
||||
assert.doesNotMatch(fs.readFileSync('web/static/css/style.css', 'utf8'), /project-task-status-group--folder \.project-task-status--running/);
|
||||
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /\.active-tasks-bar \{[\s\S]*?padding: 13px 24px 14px;/);
|
||||
});
|
||||
|
||||
test('运行中对话切换会取消旧事件流并仅恢复最新一页过程详情', () => {
|
||||
assert.match(chat, /window\.cancelRunningTaskEventStream\(conversationId\)/);
|
||||
assert.match(monitor, /function cancelRunningTaskEventStream/);
|
||||
assert.match(monitor, /abortController\.abort\(\)/);
|
||||
assert.match(monitor, /signal: abortController\.signal/);
|
||||
assert.match(monitor, /initialLatest: true/);
|
||||
assert.match(monitor, /autoLoadAll: false/);
|
||||
});
|
||||
|
||||
test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状态', () => {
|
||||
assert.match(chat, /function ownsLiveChatStream\(liveStream\)/);
|
||||
assert.match(chat, /function clearLiveChatStreamIfOwned\(liveStream\)/);
|
||||
assert.match(chat, /function detachLiveChatStreamForNavigation\(nextConversationId, force = false\)/);
|
||||
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, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
|
||||
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
|
||||
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
|
||||
assert.match(chat, /window\.clearChatHitlApprovalDock\(\)/);
|
||||
assert.match(monitor, /if \(conversationId && conversationId !== currentId\) return false/);
|
||||
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, /signal: conversationLoadController\.signal/);
|
||||
assert.match(template, /monitor\.js\?v=20260813-9/);
|
||||
assert.match(template, /chat-scroll\.js\?v=20260813-6/);
|
||||
assert.match(template, /chat\.js\?v=20260813-3/);
|
||||
assert.match(template, /style\.css\?v=20260813-5/);
|
||||
});
|
||||
|
||||
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/);
|
||||
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?line-height: 1\.4/);
|
||||
});
|
||||
|
||||
test('任务结束后对话内审批按钮会变灰并禁止继续操作', () => {
|
||||
assert.match(monitor, /ready: false/);
|
||||
assert.match(monitor, /function setHitlApprovalTaskAvailability/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.ready && !conversationExecutionTracker\.isRunning\(id\)/);
|
||||
assert.match(monitor, /hitlPendingInterruptTracker\.ready/);
|
||||
assert.match(monitor, /!hitlPendingInterruptTracker\.has\(interruptId\)/);
|
||||
assert.match(monitor, /button\.disabled = true/);
|
||||
assert.match(monitor, /function setHitlApprovalInterruptedVisualState/);
|
||||
assert.match(monitor, /stopHitlApprovalCountdown\(panel\)/);
|
||||
assert.match(monitor, /removeAttribute\('data-hitl-expires-at'\)/);
|
||||
assert.match(monitor, /hitl\.interruptedApprovalCancelled/);
|
||||
assert.match(monitor, /reconcileHitlApprovalStateWithActiveTasks\(normalizedTasks\)/);
|
||||
assert.match(monitor, /syncHitlApprovalTaskAvailability\(\)/);
|
||||
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /hitl-approval-task-closed/);
|
||||
assert.equal(zh.hitl.taskClosedApprovalUnavailable, '任务已结束,审批不可用');
|
||||
assert.equal(zh.hitl.interruptedApprovalCancelled, '任务已中断,审批已取消');
|
||||
assert.equal(typeof en.hitl.taskClosedApprovalUnavailable, 'string');
|
||||
assert.equal(typeof en.hitl.interruptedApprovalCancelled, 'string');
|
||||
});
|
||||
|
||||
test('项目树只保留当前进程仍在运行任务的审批状态', () => {
|
||||
assert.match(projects, /chatProjectFolderContext\.runningIds\.has\(conversationId\)/);
|
||||
assert.match(projects, /pendingApprovalByConversation\.delete\(conversationId\)/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.ready && !conversationExecutionTracker\.isRunning\(conversationId\)/);
|
||||
});
|
||||
|
||||
test('审批状态主动轮询并在服务不可用时立即关闭旧审批', () => {
|
||||
assert.match(monitor, /ACTIVE_TASK_REFRESH_INTERVAL = 2000/);
|
||||
assert.match(monitor, /apiFetch\('\/api\/hitl\/pending\?page=1&pageSize=200'\)/);
|
||||
assert.match(monitor, /function reconcilePendingHitlState\(rawItems\)/);
|
||||
assert.match(monitor, /renderChatHitlApprovalDock\(currentPending\)/);
|
||||
assert.match(monitor, /restoreHitlInlineForConversation\(currentId\)/);
|
||||
assert.match(monitor, /case 'conversation':[\s\S]{0,1800}window\.refreshChatProjectFolders\(\)/);
|
||||
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/);
|
||||
});
|
||||
|
||||
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /shouldMigrateLegacyHitlTimeout/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /timeoutSeconds: 300/);
|
||||
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /markLegacyHitlTimeoutMigrated/);
|
||||
});
|
||||
|
||||
test('审批体验文案具有完整中英文资源', () => {
|
||||
const hitlKeys = [
|
||||
'waitingApprovalShort',
|
||||
'requestVisitUrl',
|
||||
'requestCommand',
|
||||
'viewRequestDetails',
|
||||
'timeoutAutoReject',
|
||||
'expiredRejected',
|
||||
];
|
||||
const chatKeys = [
|
||||
'hitlTimeoutLabel',
|
||||
'hitlTimeoutFiveMinutes',
|
||||
'hitlTimeoutUnlimited',
|
||||
'hitlTimeoutHint',
|
||||
];
|
||||
hitlKeys.forEach((key) => {
|
||||
assert.equal(typeof zh.hitl[key], 'string');
|
||||
assert.equal(typeof en.hitl[key], 'string');
|
||||
});
|
||||
chatKeys.forEach((key) => {
|
||||
assert.equal(typeof zh.chat[key], 'string');
|
||||
assert.equal(typeof en.chat[key], 'string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const hitl = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('已有会话缺少本地配置时不会继承其他会话的最近审批设置', () => {
|
||||
const source = functionSource(chat, 'getHitlConfigForConversation', 'setHitlReviewerUI');
|
||||
const existingConversationBranch = source.slice(source.indexOf('const key = getHitlStorageKeyByConversation(cid)'));
|
||||
|
||||
assert.doesNotMatch(existingConversationBranch, /getHitlLastGlobalConfig/);
|
||||
assert.match(existingConversationBranch, /if \(!raw\) \{\s*return fallback;/);
|
||||
assert.match(existingConversationBranch, /catch \(e\) \{\s*return fallback;/);
|
||||
});
|
||||
|
||||
test('服务端默认审批人只更新默认值,不覆盖最近会话选择', () => {
|
||||
const source = functionSource(hitl, 'applyHitlDefaultReviewerFromServer', 'fetchHitlDefaultReviewer');
|
||||
|
||||
assert.match(source, /window\.csaiHitlDefaultReviewer = v/);
|
||||
assert.doesNotMatch(source, /saveHitlLastGlobalConfig/);
|
||||
});
|
||||
|
||||
test('恢复会话审批配置时保留该会话自己的审批人', () => {
|
||||
const source = functionSource(hitl, 'syncHitlConfigFromServer', 'syncHitlConfigToServerByCurrentConversation');
|
||||
|
||||
assert.match(source, /const localReviewer = hitlReviewerNormalize\(local && local\.reviewer\)/);
|
||||
assert.match(source, /merged = \{[\s\S]*?reviewer: localReviewer/);
|
||||
assert.match(source, /saveHitlConversationConfig\(conversationId, \{[\s\S]*?reviewer: localReviewer/);
|
||||
assert.doesNotMatch(source, /getHitlLastGlobalConfig/);
|
||||
});
|
||||
|
||||
test('异步同步只能刷新仍处于当前会话的审批界面', () => {
|
||||
const source = functionSource(hitl, 'syncHitlConfigFromServer', 'syncHitlConfigToServerByCurrentConversation');
|
||||
|
||||
assert.match(source, /getCurrentConversationIdForHitl\(\) === conversationId[\s\S]*?window\.applyHitlConfigToUI\(normalizedCfg\)/);
|
||||
});
|
||||
+48
-38
@@ -98,6 +98,7 @@ function hitlT(key, fallback, params) {
|
||||
|
||||
const HITL_LOGS_PAGE_SIZE_KEY = 'cyberstrike_hitl_logs_page_size';
|
||||
const HITL_PENDING_PAGE_SIZE_KEY = 'cyberstrike_hitl_pending_page_size';
|
||||
const HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX = 'cyberstrike-hitl-timeout-default-v1:';
|
||||
const HITL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function hitlPaginationT(key, opts, fallback) {
|
||||
@@ -212,6 +213,21 @@ function normalizeHitlTimeoutSeconds(v, fallback) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function shouldMigrateLegacyHitlTimeout(conversationId, timeoutSeconds) {
|
||||
if (!conversationId || normalizeHitlTimeoutSeconds(timeoutSeconds, 0) > 0) return false;
|
||||
try {
|
||||
return localStorage.getItem(HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX + conversationId) !== '1';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLegacyHitlTimeoutMigrated(conversationId) {
|
||||
try {
|
||||
localStorage.setItem(HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX + conversationId, '1');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function getCurrentConversationIdForHitl() {
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId) {
|
||||
return window.currentConversationId;
|
||||
@@ -241,16 +257,6 @@ function applyHitlDefaultReviewerFromServer(reviewer) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlDefaultReviewer = v;
|
||||
}
|
||||
if (typeof window.saveHitlLastGlobalConfig === 'function' && typeof window.getHitlLastGlobalConfig === 'function') {
|
||||
const gl = window.getHitlLastGlobalConfig();
|
||||
const base = gl && typeof gl === 'object'
|
||||
? gl
|
||||
: { mode: 'off', sensitiveTools: '', updatedAt: '' };
|
||||
window.saveHitlLastGlobalConfig(Object.assign({}, base, {
|
||||
reviewer: v,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -352,9 +358,9 @@ function showHitlPageWhitelistFeedback(text, isError) {
|
||||
el.className = 'hitl-apply-feedback' + (isError ? ' hitl-apply-feedback--error' : '');
|
||||
}
|
||||
|
||||
function syncHitlSidebarWhitelistDisplay(toolsStr) {
|
||||
const sidebarEl = document.getElementById('hitl-sensitive-tools');
|
||||
if (sidebarEl) sidebarEl.value = toolsStr;
|
||||
function syncHitlSidebarWhitelistDisplay(_toolsStr) {
|
||||
// The chat field is conversation-scoped. Updating the global allowlist page
|
||||
// must not replace it with a merged global + conversation display value.
|
||||
}
|
||||
|
||||
async function fetchHitlGlobalToolWhitelist() {
|
||||
@@ -534,45 +540,41 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
const local = readHitlLocalStorageConv(conversationId);
|
||||
const localMode = local && local.mode ? hitlModeNormalize(local.mode) : 'off';
|
||||
if (localMode !== 'off') {
|
||||
const localReviewer = hitlReviewerNormalize(local && local.reviewer);
|
||||
let localToolsStr = typeof local.sensitiveTools === 'string' ? local.sensitiveTools : '';
|
||||
localToolsStr = strip(globalWL, localToolsStr);
|
||||
merged = {
|
||||
enabled: true,
|
||||
mode: localMode,
|
||||
reviewer: localReviewer,
|
||||
sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(
|
||||
local && local.timeoutSeconds,
|
||||
normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
)
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: localMode,
|
||||
reviewer: localReviewer,
|
||||
sensitiveTools: localToolsStr,
|
||||
enabled: true,
|
||||
timeoutSeconds: merged.timeoutSeconds
|
||||
}).catch(function (err) {
|
||||
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
|
||||
});
|
||||
} else {
|
||||
const gl = typeof window.getHitlLastGlobalConfig === 'function' ? window.getHitlLastGlobalConfig() : null;
|
||||
const glMode = gl && gl.mode ? hitlModeNormalize(gl.mode) : 'off';
|
||||
if (glMode !== 'off') {
|
||||
let glToolsStr = typeof gl.sensitiveTools === 'string' ? gl.sensitiveTools : '';
|
||||
glToolsStr = strip(globalWL, glToolsStr);
|
||||
merged = {
|
||||
enabled: true,
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr,
|
||||
enabled: true,
|
||||
timeoutSeconds: merged.timeoutSeconds
|
||||
}).catch(function (err) {
|
||||
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldMigrateLegacyHitlTimeout(conversationId, merged.timeoutSeconds)) {
|
||||
merged = Object.assign({}, merged, { timeoutSeconds: 300 });
|
||||
try {
|
||||
await saveHitlConversationConfig(conversationId, merged);
|
||||
markLegacyHitlTimeoutMigrated(conversationId);
|
||||
} catch (err) {
|
||||
console.warn('HITL 旧会话等待时限迁移失败,将在下次加载时重试:', err);
|
||||
}
|
||||
} else if (normalizeHitlTimeoutSeconds(merged.timeoutSeconds, 0) > 0) {
|
||||
markLegacyHitlTimeoutMigrated(conversationId);
|
||||
}
|
||||
const uiMode = hitlEffectiveEnabled(merged) ? hitlModeNormalize(merged.mode) : 'off';
|
||||
const rawArr = Array.isArray(merged.sensitiveTools)
|
||||
? merged.sensitiveTools
|
||||
@@ -590,7 +592,10 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
localStorage.setItem('chat_hitl_config_' + conversationId, JSON.stringify(normalizedCfg));
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof window.applyHitlConfigToUI === 'function') {
|
||||
if (
|
||||
getCurrentConversationIdForHitl() === conversationId &&
|
||||
typeof window.applyHitlConfigToUI === 'function'
|
||||
) {
|
||||
window.applyHitlConfigToUI(normalizedCfg);
|
||||
}
|
||||
reconcileHitlUiState();
|
||||
@@ -835,7 +840,11 @@ async function refreshHitlPending() {
|
||||
throw new Error('request failed');
|
||||
}
|
||||
const data = await resp.json();
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const rawItems = Array.isArray(data.items) ? data.items : [];
|
||||
const items = rawItems.filter(function (item) {
|
||||
return hitlReviewerNormalize(item && (item.reviewer || item.decidedBy || item.decided_by)) !== 'audit_agent' &&
|
||||
String(item && item.status || '').trim().toLowerCase() !== 'audit_running';
|
||||
});
|
||||
let workflowRuns = [];
|
||||
try {
|
||||
const wfResp = await hitlApiFetch('/api/workflows/runs/pending', { credentials: 'same-origin' });
|
||||
@@ -856,7 +865,8 @@ async function refreshHitlPending() {
|
||||
return conv.indexOf(searchQ) >= 0 || wfId.indexOf(searchQ) >= 0 || runId.indexOf(searchQ) >= 0 || label.indexOf(searchQ) >= 0;
|
||||
});
|
||||
}
|
||||
hitlPendingTotal = (typeof data.total === 'number' ? data.total : items.length) + workflowRuns.length;
|
||||
const hiddenAgentItems = rawItems.length - items.length;
|
||||
hitlPendingTotal = Math.max(0, (typeof data.total === 'number' ? data.total : rawItems.length) - hiddenAgentItems) + workflowRuns.length;
|
||||
const maxPage = Math.max(1, Math.ceil(hitlPendingTotal / hitlPendingPageSize));
|
||||
if (hitlPendingPage > maxPage) {
|
||||
hitlPendingPage = maxPage;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('主代理迭代节点获得可访问的分割线语义', () => {
|
||||
const source = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
|
||||
|
||||
assert.match(source, /if \(type === 'iteration'\)/);
|
||||
assert.match(source, /if \(scope !== 'sub'\)/);
|
||||
assert.match(source, /classList\.add\('timeline-iteration-divider'\)/);
|
||||
assert.match(source, /setAttribute\('role', 'separator'\)/);
|
||||
assert.match(source, /setAttribute\('aria-label', String\(options\.title \|\| ''\)\)/);
|
||||
});
|
||||
|
||||
test('迭代分割线只在主对话时间线中使用轻量渐变横线', () => {
|
||||
assert.match(styles, /\.timeline-item-iteration\.timeline-iteration-divider::after/);
|
||||
assert.match(styles, /linear-gradient\(/);
|
||||
assert.match(styles, /color-mix\(in srgb, var\(--border-color\) 88%, transparent\)/);
|
||||
assert.match(styles, /@media \(max-width: 768px\)/);
|
||||
});
|
||||
+1708
-238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const html = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
const rbac = fs.readFileSync('web/static/js/rbac-guards.js', 'utf8');
|
||||
const zh = fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8');
|
||||
const en = fs.readFileSync('web/static/i18n/en-US.json', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
|
||||
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
assert.match(source, /row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/);
|
||||
assert.match(source, /button\.addEventListener\('focus', \(\) => scheduleShowProjectFolderPreview/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(!isUnassigned\) \{\s*row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/
|
||||
);
|
||||
});
|
||||
|
||||
test('无项目预览隐藏测试范围和编辑入口', () => {
|
||||
const source = functionSource(projects, 'showProjectFolderPreview', 'scheduleShowProjectFolderPreview');
|
||||
|
||||
assert.match(source, /preview\.classList\.toggle\('is-unassigned', isUnassigned\)/);
|
||||
assert.match(source, /scopeRow\.hidden = isUnassigned \|\| !scope/);
|
||||
assert.match(source, /editButton\.hidden = isUnassigned/);
|
||||
assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-edit\s*\{\s*display: none !important;/);
|
||||
assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-details\s*\{\s*border-bottom: 0;/);
|
||||
});
|
||||
|
||||
test('项目标题提供受权限保护的新建项目入口', () => {
|
||||
const source = functionSource(projects, 'showNewProjectModalFromChatSidebar', 'saveProjectModal');
|
||||
|
||||
assert.match(html, /class="add-group-btn project-folders-add-btn"[\s\S]*?onclick="showNewProjectModalFromChatSidebar\(\)"/);
|
||||
assert.match(chat, /projectHeader\.querySelector\('\.project-folders-add-btn'\)/);
|
||||
assert.match(source, /window\._projectModalFromChat = false/);
|
||||
assert.match(source, /window\._projectModalFromChatSidebar = true/);
|
||||
assert.match(rbac, /showNewProjectModalFromChatSidebar: 'project:write'/);
|
||||
});
|
||||
|
||||
test('对话项目归属尚未加载时不会误展开无项目', () => {
|
||||
const resolver = functionSource(projects, 'resolveChatProjectFolderSelection', 'renderChatProjectFolders');
|
||||
const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders');
|
||||
|
||||
assert.match(resolver, /if \(!chatProjectFolderContext\.ready\) return null/);
|
||||
assert.match(resolver, /if \(!conversation\) return null/);
|
||||
assert.match(resolver, /conversation\.projectId \|\| conversation\.project_id \|\| ''/);
|
||||
assert.match(render, /const selectedId = resolveChatProjectFolderSelection\(\)/);
|
||||
assert.match(render, /selectedId !== null && chatProjectFolderLastSelectionId !== selectedId/);
|
||||
});
|
||||
|
||||
test('项目按展开状态切换 Codex 风格的打开和关闭文件夹', () => {
|
||||
const icon = functionSource(projects, 'projectFolderIconMarkup', 'clampProjectPreviewText');
|
||||
const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
assert.match(icon, /const path = isExpanded/);
|
||||
assert.match(icon, /M3\.5 18V6\.5/);
|
||||
assert.match(icon, /M3\.5 7a2 2 0 0 1 2-2h4l2 2/);
|
||||
assert.match(folder, /icon\.className = 'project-folder-icon';/);
|
||||
assert.match(folder, /icon\.innerHTML = projectFolderIconMarkup\(isExpanded\);/);
|
||||
});
|
||||
|
||||
test('项目名仅在界面按 12 个 Unicode 字符省略并保留完整悬浮信息', () => {
|
||||
const formatterSource = functionSource(chat, 'formatProjectNameForDisplay', 'applyProjectNameDisplay');
|
||||
const formatter = new Function(
|
||||
'PROJECT_NAME_DISPLAY_MAX_CHARACTERS',
|
||||
`${formatterSource}; return formatProjectNameForDisplay;`
|
||||
)(12);
|
||||
const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
const picker = functionSource(projects, 'appendChatProjectPanelItem', 'appendChatProjectPanelMessage');
|
||||
const button = functionSource(projects, 'updateChatProjectButtonLabel', 'renderChatProjectPanel');
|
||||
|
||||
assert.equal(formatter('十二字符以内'), '十二字符以内');
|
||||
assert.equal(formatter('这是一个非常非常长的项目名称'), '这是一个非常非常长的项目…');
|
||||
assert.equal(formatter('😀😀😀😀😀😀😀😀😀😀😀😀😀'), '😀😀😀😀😀😀😀😀😀😀😀😀…');
|
||||
assert.match(chat, /const PROJECT_NAME_DISPLAY_MAX_CHARACTERS = 12/);
|
||||
assert.match(folder, /applyProjectNameDisplay\(title, project\.name/);
|
||||
assert.match(projects, /applyProjectNameDisplay\(titleEl, text\)/);
|
||||
assert.match(picker, /title="\$\{escapeAttr\(fullName\)\}"/);
|
||||
assert.match(picker, /setAttribute\('aria-label', fullName\)/);
|
||||
assert.match(button, /applyProjectNameDisplay/);
|
||||
assert.match(styles, /\.project-selector-wrapper \.role-selector-text\s*\{[\s\S]*?max-width: 13em/);
|
||||
});
|
||||
|
||||
test('项目文件夹首批显示 6 个并通过加载更多按批追加', () => {
|
||||
const loadMore = functionSource(projects, 'loadMoreChatProjectFolders', 'renderChatProjectFolders');
|
||||
const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders');
|
||||
const search = functionSource(projects, 'handleProjectFolderSearch', 'clearProjectFolderSearch');
|
||||
|
||||
assert.match(projects, /const CHAT_PROJECT_FOLDER_PAGE_SIZE = 6/);
|
||||
assert.match(loadMore, /chatProjectFolderVisibleCount \+= CHAT_PROJECT_FOLDER_PAGE_SIZE/);
|
||||
assert.match(render, /const visibleFolders = folders\.slice\(0, chatProjectFolderVisibleCount\)/);
|
||||
assert.match(render, /appendChatProjectFoldersLoadMore\(list, folders\.length - visibleFolders\.length\)/);
|
||||
assert.match(render, /chatProjectFolderVisibleCount = selectedIndex \+ 1/);
|
||||
assert.match(search, /renderChatProjectFolders\(projectsCacheAll\)/);
|
||||
assert.match(styles, /\.project-folders-load-more\s*\{/);
|
||||
assert.match(zh, /"projectFoldersLoadMoreRemaining": "加载更多,剩余 \{\{count\}\} 个项目"/);
|
||||
assert.match(en, /"projectFoldersLoadMoreRemaining": "Load more, \{\{count\}\} projects remaining"/);
|
||||
});
|
||||
|
||||
test('对话悬浮预览显示本地年月日时分', () => {
|
||||
const age = functionSource(projects, 'formatProjectConversationPreviewAge', 'getProjectConversationModeLabel');
|
||||
|
||||
assert.match(age, /date\.getFullYear\(\)/);
|
||||
assert.match(age, /date\.getMonth\(\) \+ 1/);
|
||||
assert.match(age, /date\.getDate\(\)/);
|
||||
assert.match(age, /date\.getHours\(\)/);
|
||||
assert.match(age, /date\.getMinutes\(\)/);
|
||||
assert.match(age, /chat\.conversationPreviewDateTime/);
|
||||
assert.doesNotMatch(age, /elapsedMs|conversationPreviewDays|conversationPreviewHours/);
|
||||
assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/);
|
||||
assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/);
|
||||
});
|
||||
+1256
-12
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@
|
||||
// 项目
|
||||
showNewProjectModal: 'project:write',
|
||||
showNewProjectModalFromChat: 'project:write',
|
||||
showNewProjectModalFromChatSidebar: 'project:write',
|
||||
showNewProjectModalFromWebshellAi: 'project:write',
|
||||
showEditProjectModal: 'project:write',
|
||||
saveProjectModal: 'project:write',
|
||||
|
||||
+29
-2
@@ -17,6 +17,27 @@ function buildHashForPage(pageId) {
|
||||
}
|
||||
|
||||
let chatConversationFromHashSeq = 0;
|
||||
|
||||
function setChatConversationRestorePending(conversationId, pending) {
|
||||
const container = document.querySelector('.chat-container');
|
||||
if (!container) return;
|
||||
const id = String(conversationId || '').trim();
|
||||
if (pending && id) {
|
||||
container.classList.add('is-conversation-restoring');
|
||||
container.dataset.restoringConversationId = id;
|
||||
container.setAttribute('aria-busy', 'true');
|
||||
return;
|
||||
}
|
||||
container.classList.remove('is-conversation-restoring');
|
||||
delete container.dataset.restoringConversationId;
|
||||
container.removeAttribute('aria-busy');
|
||||
}
|
||||
|
||||
function finishChatConversationRestore(conversationId) {
|
||||
setChatConversationRestorePending(conversationId, false);
|
||||
}
|
||||
window.finishChatConversationRestore = finishChatConversationRestore;
|
||||
|
||||
function scheduleChatConversationFromHash(delayMs) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const hashParts = hash.split('?');
|
||||
@@ -35,6 +56,8 @@ function scheduleChatConversationFromHash(delayMs) {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
// 同一事件循环内先遮住默认新对话状态,避免网络请求返回前闪出“无项目”。
|
||||
setChatConversationRestorePending(conversationId, true);
|
||||
const token = ++chatConversationFromHashSeq;
|
||||
setTimeout(() => {
|
||||
if (token !== chatConversationFromHashSeq) {
|
||||
@@ -84,7 +107,7 @@ function initRouter() {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(500);
|
||||
scheduleChatConversationFromHash(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -98,6 +121,9 @@ function initRouter() {
|
||||
function switchPage(pageId) {
|
||||
const targetPage = document.getElementById(`page-${pageId}`);
|
||||
if (!targetPage) return;
|
||||
if (pageId !== 'chat') {
|
||||
setChatConversationRestorePending('', false);
|
||||
}
|
||||
|
||||
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
|
||||
// 同一页面已经激活时不再重复初始化,避免接口重复请求和页面二次重绘。
|
||||
@@ -563,6 +589,7 @@ async function initPage(pageId) {
|
||||
// 页面加载完成后初始化路由
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initRouter();
|
||||
document.documentElement.classList.remove('initial-route-pending');
|
||||
initSidebarState();
|
||||
|
||||
// 监听hash变化
|
||||
@@ -576,7 +603,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(200);
|
||||
scheduleChatConversationFromHash(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
|
||||
function timingSource(source) {
|
||||
const start = source.indexOf('function formatAssistantTurnDuration(');
|
||||
const end = source.indexOf('window.setAssistantTurnTiming = setAssistantTurnTiming;', start);
|
||||
assert.notEqual(start, -1, 'formatAssistantTurnDuration should exist');
|
||||
assert.notEqual(end, -1, 'timing exports should exist');
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createClassList() {
|
||||
return {
|
||||
toggle() {},
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage() {
|
||||
const label = {
|
||||
innerHTML: '',
|
||||
classList: createClassList(),
|
||||
setAttribute() {},
|
||||
};
|
||||
return {
|
||||
dataset: {},
|
||||
querySelector(selector) {
|
||||
if (selector === '.mcp-call-label.turn-process-summary') return label;
|
||||
return null;
|
||||
},
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(nowMs) {
|
||||
const RealDate = Date;
|
||||
class TestDate extends RealDate {
|
||||
static now() {
|
||||
return nowMs;
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
Date: TestDate,
|
||||
Number,
|
||||
Math,
|
||||
String,
|
||||
document: {
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
getElementById() { return null; },
|
||||
},
|
||||
window: {},
|
||||
escapeHtml(value) { return String(value); },
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`,
|
||||
context
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
test('刷新运行中任务时忽略摘要中的零耗时并按开始时间恢复', () => {
|
||||
const startedAt = '2026-08-12T02:00:00.000Z';
|
||||
const startedMs = Date.parse(startedAt);
|
||||
const context = createHarness(startedMs + 65_000);
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt,
|
||||
durationMs: 0,
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnDurationMs, undefined);
|
||||
assert.match(message.label.innerHTML, /已处理 1 分钟 5 秒/);
|
||||
});
|
||||
|
||||
test('已完成任务仍优先使用持久化耗时', () => {
|
||||
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt: '2026-08-12T02:00:00.000Z',
|
||||
completedAt: '2026-08-12T02:01:05.000Z',
|
||||
durationMs: 65_000,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnDurationMs, '65000');
|
||||
assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/);
|
||||
});
|
||||
|
||||
test('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
|
||||
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt: '2026-08-11T09:47:14.000Z',
|
||||
completedAt: '2026-08-11T09:51:39.000Z',
|
||||
durationMs: 265_000,
|
||||
status: 'cancelled',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnDurationMs, '265000');
|
||||
assert.match(message.label.innerHTML, /已中断 · 耗时 4 分钟 25 秒/);
|
||||
assert.doesNotMatch(message.label.innerHTML, /已处理/);
|
||||
});
|
||||
|
||||
test('历史占位消息存在取消事件时不会再判定为运行中', () => {
|
||||
assert.match(chat, /function assistantTurnTerminalState\(processDetails\)/);
|
||||
assert.match(chat, /const isRunning = isAssistantPlaceholder && !terminalState/);
|
||||
assert.match(chat, /status: status/);
|
||||
});
|
||||
Reference in New Issue
Block a user