修复人工审批长内容遮挡及刷新审批人状态异常 (#263)

* docs(hitl): remove stale asm_list_resources references

* fix(hitl): constrain approval layout and preserve reviewer
This commit is contained in:
RuoJi6
2026-08-19 13:31:37 +08:00
committed by GitHub
parent 24d06c5220
commit c7cc0bc9da
9 changed files with 172 additions and 38 deletions
+3 -3
View File
@@ -340,7 +340,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260815-2');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260819-3');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -398,7 +398,7 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
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=20260818-3/);
assert.match(html, /chat\.js\?v=20260819-1/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -462,5 +462,5 @@ 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=20260818-3/);
assert.match(html, /style\.css\?v=20260819-4/);
});
+29 -1
View File
@@ -130,6 +130,8 @@ const DEFAULT_HITL_TIMEOUT_SECONDS = 300;
const DEFAULT_HITL_SESSION_TOOL_WHITELIST = 'tool_search, skill, task, write_todos, transfer_to_agent, exit, TaskCreate, TaskGet, TaskUpdate, TaskList, upsert_project_fact, get_project_fact';
let hitlApplyFeedbackTimer = null;
let hitlAutoSaveTimer = null;
let hitlConfigSyncConversationId = '';
let hitlConfigSyncPromise = Promise.resolve();
const sessionSettingsSelects = new Map();
let sessionSettingsSelectDocBound = false;
@@ -706,6 +708,18 @@ function refreshHitlConfigByCurrentConversation() {
applyHitlConfigToUI(cfg);
}
async function waitForHitlConfigReady(conversationId) {
const cid = String(conversationId || '').trim();
if (cid && hitlConfigSyncConversationId === cid) {
await hitlConfigSyncPromise;
return;
}
if (!cid && window.csaiHitlDefaultReviewerReady && typeof window.csaiHitlDefaultReviewerReady.then === 'function') {
await window.csaiHitlDefaultReviewerReady.catch(function () {});
if (!currentConversationId) refreshHitlConfigByCurrentConversation();
}
}
function showHitlApplyFeedback(text, isError, partial) {
const el = document.getElementById('hitl-apply-feedback');
if (hitlApplyFeedbackTimer) {
@@ -2199,6 +2213,15 @@ async function sendMessage() {
return;
}
// A restored conversation renders from the local cache first, while its
// authoritative HITL config is fetched separately. Do not let a fast send
// reuse the temporary/default reviewer (historically "human") before that
// fetch completes, otherwise refreshing could turn Audit Agent review into
// a human approval for the next tool call.
const hitlConversationAtSendStart = String(currentConversationId || '').trim();
await waitForHitlConfigReady(hitlConversationAtSendStart);
if (String(currentConversationId || '').trim() !== hitlConversationAtSendStart) return;
// Enter 会直接调用 sendMessage;同一会话在其他标签页已启动任务时,
// 必须在渲染用户气泡和发起 POST 前做一次权威状态同步,避免生成一轮“已有任务执行中”伪对话。
if (currentConversationId && typeof loadActiveTasks === 'function') {
@@ -6178,7 +6201,12 @@ async function loadConversation(conversationId) {
}
}).catch(() => {})
: Promise.resolve();
void hitlSyncPromise;
hitlConfigSyncConversationId = conversationId;
hitlConfigSyncPromise = Promise.resolve(hitlSyncPromise);
await hitlConfigSyncPromise;
if (seq !== loadConversationRequestSeq || currentConversationId !== conversationId) {
return;
}
updateActiveConversation();
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
+32 -3
View File
@@ -22,6 +22,35 @@ test('输入区提供独立审批入口并暴露可配置等待时限', () => {
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
});
test('超长人工审批内容在限高区域内滚动且操作按钮始终可见', () => {
assert.match(styles, /\.chat-hitl-approval-dock \{[\s\S]*?max-height: min\(62dvh, 560px\);[\s\S]*?padding: 18px 20px 74px;[\s\S]*?overflow: hidden;/);
assert.match(styles, /\.chat-hitl-approval-scroll-region \{[\s\S]*?max-height: max\(76px, calc\(min\(62dvh, 560px\) - 94px\)\);[\s\S]*?overflow-y: auto;[\s\S]*?overscroll-behavior: contain;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-edit-args \{[\s\S]*?max-height: min\(28dvh, 220px\);[\s\S]*?overflow: auto;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-inline-actions \{[\s\S]*?position: absolute;[\s\S]*?bottom: 16px;[\s\S]*?box-shadow: none;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-approval-heading h3 \{[\s\S]*?-webkit-line-clamp: 3;/);
assert.match(monitor, /function wrapChatHitlApprovalScrollRegion\(dock\)/);
assert.match(monitor, /while \(dock\.firstChild && dock\.firstChild !== actions\)/);
assert.match(monitor, /wrapChatHitlApprovalScrollRegion\(dock\);/);
assert.match(monitor, /url\.length > 160[\s\S]*?requestVisitLongUrl/);
assert.equal(zh.hitl.requestVisitLongUrl, '允许 CyberStrikeAI 访问此地址?');
assert.equal(en.hitl.requestVisitLongUrl, 'Allow CyberStrikeAI to visit this address?');
});
test('刷新恢复会话时先完成权威审批配置同步再允许发送', () => {
assert.match(chat, /function waitForHitlConfigReady\(conversationId\)/);
assert.match(chat, /await waitForHitlConfigReady\(hitlConversationAtSendStart\)/);
assert.match(chat, /hitlConfigSyncConversationId = conversationId;[\s\S]{0,240}await hitlConfigSyncPromise;/);
assert.match(chat, /await hitlConfigSyncPromise;[\s\S]{0,220}seq !== loadConversationRequestSeq/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /window\.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer\(\)/);
});
test('同一会话的审批配置写入串行化以防止旧请求后到覆盖新选择', () => {
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(hitlPage, /const hitlConversationConfigSaveQueues = new Map\(\)/);
assert.match(hitlPage, /const previous = hitlConversationConfigSaveQueues\.get\(normalizedConversationId\) \|\| Promise\.resolve\(\)/);
assert.match(hitlPage, /const queued = previous\.catch\(function \(\) \{\}\)\.then\(async function \(\)/);
});
test('输入框可按会话通道获取模型并双向同步会话推理且审批模型只出现在审计 Agent 入口', () => {
assert.match(chat, /function currentSystemModelLabel\(\)/);
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
@@ -262,10 +291,10 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
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=20260815-2/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260818-3/);
assert.match(template, /style\.css\?v=20260818-3/);
assert.match(template, /chat\.js\?v=20260819-1/);
assert.match(template, /style\.css\?v=20260819-4/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
+29 -18
View File
@@ -100,6 +100,7 @@ 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];
const hitlConversationConfigSaveQueues = new Map();
function hitlPaginationT(key, opts, fallback) {
if (typeof window.t === 'function') {
@@ -495,29 +496,39 @@ async function saveHitlPageWhitelist() {
async function saveHitlConversationConfig(conversationId, config) {
if (!conversationId || !config) return false;
const normalizedConversationId = String(conversationId).trim();
const mode = hitlModeNormalize(config.mode || 'off');
const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off');
const sensitiveTools = hitlSensitiveToolsToArray(config);
const timeoutSeconds = normalizeHitlTimeoutSeconds(config.timeoutSeconds, 0);
const reviewer = hitlReviewerNormalize(config.reviewer || 'human');
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: conversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
const previous = hitlConversationConfigSaveQueues.get(normalizedConversationId) || Promise.resolve();
const queued = previous.catch(function () {}).then(async function () {
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: normalizedConversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
});
hitlConversationConfigSaveQueues.set(normalizedConversationId, queued);
return queued.finally(function () {
if (hitlConversationConfigSaveQueues.get(normalizedConversationId) === queued) {
hitlConversationConfigSaveQueues.delete(normalizedConversationId);
}
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
}
async function syncHitlConfigFromServer(conversationId) {
@@ -1809,7 +1820,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners();
}
initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer();
setTimeout(reconcileHitlUiState, 0);
});
+21 -2
View File
@@ -4282,7 +4282,9 @@ function describeHitlApprovalRequest(data) {
if (isBrowser) {
kind = 'browser';
question = url
? hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url })
? (url.length > 160
? hitlApprovalTranslate('hitl.requestVisitLongUrl', '允许 CyberStrikeAI 访问此地址?')
: hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url }))
: hitlApprovalTranslate('hitl.requestBrowser', '允许 CyberStrikeAI 使用浏览器?');
primary = url;
} else if (isCommand) {
@@ -4292,7 +4294,9 @@ function describeHitlApprovalRequest(data) {
} else if (isFile) {
kind = 'file';
question = path
? hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path })
? (path.length > 160
? hitlApprovalTranslate('hitl.requestModifyLongPath', '允许 CyberStrikeAI 修改此文件?')
: hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path }))
: hitlApprovalTranslate('hitl.requestFiles', '允许 CyberStrikeAI 修改文件?');
primary = path;
}
@@ -4835,6 +4839,20 @@ function clearChatHitlApprovalDock(interruptId) {
if (container) container.classList.remove('has-hitl-approval');
}
function wrapChatHitlApprovalScrollRegion(dock) {
if (!dock) return;
const actions = Array.prototype.find.call(dock.children, function (child) {
return child.classList && child.classList.contains('hitl-inline-actions');
});
if (!actions) return;
const scrollRegion = document.createElement('div');
scrollRegion.className = 'chat-hitl-approval-scroll-region';
while (dock.firstChild && dock.firstChild !== actions) {
scrollRegion.appendChild(dock.firstChild);
}
dock.insertBefore(scrollRegion, actions);
}
function renderChatHitlApprovalDock(data) {
const dock = document.getElementById('chat-hitl-approval-dock');
if (!dock || !data || !data.interruptId) return false;
@@ -4855,6 +4873,7 @@ function renderChatHitlApprovalDock(data) {
allowEdit: allowEdit,
argsJSON: JSON.stringify(hitlApprovalArguments(data), null, 2)
});
wrapChatHitlApprovalScrollRegion(dock);
dock.hidden = false;
const container = dock.closest('.chat-input-container');
if (container) container.classList.add('has-hitl-approval');