Add files via upload

This commit is contained in:
公明
2026-04-29 17:05:02 +08:00
committed by GitHub
parent 5b942c7bc8
commit 3e41a47abf
8 changed files with 662 additions and 22 deletions
+42 -16
View File
@@ -7,6 +7,19 @@ function hitlModeNormalize(m) {
return allowed.indexOf(v) >= 0 ? v : 'off';
}
function hitlT(key, fallback, params) {
const fullKey = 'hitl.' + key;
try {
if (typeof window.t === 'function') {
const translated = window.t(fullKey, params || {});
if (typeof translated === 'string' && translated && translated !== fullKey) {
return translated;
}
}
} catch (e) {}
return fallback;
}
function hitlEffectiveEnabled(cfg) {
if (!cfg) return false;
if (cfg.enabled === true) return true;
@@ -36,6 +49,18 @@ function hitlSensitiveToolsToArray(config) {
return [];
}
function normalizeHitlTimeoutSeconds(v, fallback) {
const n = Number(v);
if (Number.isFinite(n)) {
return n > 0 ? Math.floor(n) : 0;
}
const f = Number(fallback);
if (Number.isFinite(f)) {
return f > 0 ? Math.floor(f) : 0;
}
return 0;
}
function getCurrentConversationIdForHitl() {
if (typeof window.currentConversationId === 'string' && window.currentConversationId) {
return window.currentConversationId;
@@ -84,6 +109,7 @@ async function saveHitlConversationConfig(conversationId, config) {
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 resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
@@ -93,7 +119,7 @@ async function saveHitlConversationConfig(conversationId, config) {
enabled: enabled,
mode: mode,
sensitiveTools: sensitiveTools,
timeoutSeconds: config.timeoutSeconds || 300
timeoutSeconds: timeoutSeconds
})
});
if (!resp.ok) {
@@ -126,7 +152,7 @@ async function syncHitlConfigFromServer(conversationId) {
enabled: true,
mode: localMode,
sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
timeoutSeconds: cfg.timeoutSeconds || 300
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
};
saveHitlConversationConfig(conversationId, {
mode: localMode,
@@ -146,7 +172,7 @@ async function syncHitlConfigFromServer(conversationId) {
enabled: true,
mode: glMode,
sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
timeoutSeconds: cfg.timeoutSeconds || 300
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
};
saveHitlConversationConfig(conversationId, {
mode: glMode,
@@ -265,7 +291,7 @@ async function followAgentRunAfterHitlDecision(conversationId) {
async function refreshHitlPending() {
const container = document.getElementById('hitl-pending-list');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Loading...</div>';
container.innerHTML = '<div class="loading-spinner">' + escapeHtml(hitlT('loading', 'Loading...')) + '</div>';
try {
const resp = await hitlApiFetch('/api/hitl/pending', { credentials: 'same-origin' });
if (!resp.ok) {
@@ -274,7 +300,7 @@ async function refreshHitlPending() {
const data = await resp.json();
const items = Array.isArray(data.items) ? data.items : [];
if (!items.length) {
container.innerHTML = '<div class="empty-state">暂无待审批项</div>';
container.innerHTML = '<div class="empty-state">' + escapeHtml(hitlT('emptyState', 'No pending approvals')) + '</div>';
return;
}
container.innerHTML = items.map(function (item) {
@@ -292,25 +318,25 @@ async function refreshHitlPending() {
'<span class="hitl-tool-badge">' + escapeHtml(item.toolName || '-') + '</span>' +
'<span class="hitl-mode-tag hitl-mode-tag--' + escapeHtml(mode) + '">' + escapeHtml(item.mode || '-') + '</span>' +
'</div>' +
'<button class="hitl-dismiss-btn" title="忽略" onclick="dismissHitlItem(' + qId + ')">&times;</button>' +
'<button class="hitl-dismiss-btn" title="' + escapeHtml(hitlT('dismiss', 'Dismiss')) + '" onclick="dismissHitlItem(' + qId + ')">&times;</button>' +
'</div>' +
'<div class="hitl-pending-meta">会话:' + escapeHtml(item.conversationId || '-') + '</div>' +
'<div class="hitl-pending-meta">' + escapeHtml(hitlT('conversationLabel', 'Conversation:')) + ' ' + escapeHtml(item.conversationId || '-') + '</div>' +
'<pre class="hitl-pending-payload">' + escapeHtml(preview) + '</pre>' +
(allowEdit
? ('<div class="hitl-input-help">审查编辑模式:可填写 JSON 对象覆盖参数。示例:{"command":"ls -la"}</div>' +
? ('<div class="hitl-input-help">' + escapeHtml(hitlT('reviewEditHelp', 'Review & edit mode: provide a JSON object to override tool arguments. Example: {"command":"ls -la"}')) + '</div>' +
'<textarea id="hitl-edit-' + escId + '" class="hitl-edit-args" placeholder=\'{"command":"ls -la"}\'></textarea>')
: '<div class="hitl-input-help">审批模式:仅通过/拒绝,不支持改参。</div>') +
'<div class="hitl-input-help">备注(可选):建议写审批依据。</div>' +
'<input id="hitl-comment-' + escId + '" class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">' +
: '<div class="hitl-input-help">' + escapeHtml(hitlT('approvalHelp', 'Approval mode: only approve/reject, argument editing is disabled.')) + '</div>') +
'<div class="hitl-input-help">' + escapeHtml(hitlT('commentHelp', 'Comment (optional): briefly note the approval reason.')) + '</div>' +
'<input id="hitl-comment-' + escId + '" class="hitl-config-input hitl-inline-comment" type="text" placeholder="' + escapeHtml(hitlT('commentPlaceholder', 'e.g. allow read-only command')) + '">' +
'<div class="hitl-pending-actions">' +
'<button class="btn-secondary" onclick="submitHitlDecision(' + qId + ',&quot;reject&quot;,' + qConv + ')">拒绝</button>' +
'<button class="btn-primary" onclick="submitHitlDecision(' + qId + ',&quot;approve&quot;,' + qConv + ')">通过</button>' +
'<button class="btn-secondary" onclick="submitHitlDecision(' + qId + ',&quot;reject&quot;,' + qConv + ')">' + escapeHtml(hitlT('reject', 'Reject')) + '</button>' +
'<button class="btn-primary" onclick="submitHitlDecision(' + qId + ',&quot;approve&quot;,' + qConv + ')">' + escapeHtml(hitlT('approve', 'Approve')) + '</button>' +
'</div>' +
'</div>'
);
}).join('');
} catch (e) {
container.innerHTML = '<div class="empty-state">加载失败</div>';
container.innerHTML = '<div class="empty-state">' + escapeHtml(hitlT('loadFailed', 'Failed to load')) + '</div>';
}
}
@@ -323,7 +349,7 @@ async function submitHitlDecision(interruptId, decision, conversationIdOpt) {
try {
editedArguments = JSON.parse(editBox.value.trim());
} catch (e) {
alert('JSON 参数格式错误');
alert(hitlT('invalidJson', 'Invalid JSON arguments'));
return;
}
}
@@ -344,7 +370,7 @@ async function submitHitlDecisionWithPayload(interruptId, decision, comment, edi
await dismissHitlItem(interruptId, true);
return true;
}
alert('提交失败:' + errText);
alert(hitlT('submitFailedPrefix', 'Submit failed:') + ' ' + errText);
return false;
}
refreshHitlPending();
+24 -2
View File
@@ -2348,9 +2348,28 @@ function renderActiveTasks(tasks) {
bar.style.display = 'flex';
bar.innerHTML = '';
function openActiveTaskConversation(conversationId) {
if (!conversationId) return;
if (typeof switchPage === 'function') {
switchPage('chat');
}
if (typeof window.loadConversation === 'function') {
setTimeout(function () {
window.loadConversation(conversationId);
}, 120);
return;
}
window.location.hash = 'chat?conversation=' + encodeURIComponent(conversationId);
}
normalizedTasks.forEach(task => {
const item = document.createElement('div');
item.className = 'active-task-item';
item.className = 'active-task-item active-task-item-clickable';
if (task && task.conversationId) {
item.title = (typeof window.t === 'function' ? window.t('tasks.viewConversation') : '查看会话');
item.setAttribute('role', 'button');
item.onclick = () => openActiveTaskConversation(task.conversationId);
}
const startedTime = task.startedAt ? new Date(task.startedAt) : null;
const taskTimeLocale = getCurrentTimeLocale();
@@ -2388,7 +2407,10 @@ function renderActiveTasks(tasks) {
if (!isFinalStatus) {
const cancelBtn = item.querySelector('.active-task-cancel');
if (cancelBtn) {
cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn);
cancelBtn.onclick = (evt) => {
evt.stopPropagation();
cancelActiveTask(task.conversationId, cancelBtn);
};
if (task.status === 'cancelling') {
cancelBtn.disabled = true;
cancelBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
+321
View File
@@ -0,0 +1,321 @@
(function () {
const STORAGE_LAST_SEEN_KEY = 'cyberstrike-notification-last-seen-at';
const POLL_INTERVAL_ACTIVE_MS = 15000;
const POLL_INTERVAL_HIDDEN_MS = 60000;
const MAX_RENDER_ITEMS = 20;
const state = {
inFlight: false,
timerId: null,
dropdownOpen: false,
lastSeenAt: readLastSeenAt(),
items: [],
unreadCount: 0,
};
function readLastSeenAt() {
try {
const raw = localStorage.getItem(STORAGE_LAST_SEEN_KEY);
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
} catch (e) {
console.warn('读取通知已读时间失败:', e);
}
return 0;
}
function persistLastSeenAt(ts) {
try {
localStorage.setItem(STORAGE_LAST_SEEN_KEY, String(ts));
} catch (e) {
console.warn('保存通知已读时间失败:', e);
}
}
function getTimeMs(value) {
if (!value) return 0;
const d = new Date(value);
const ms = d.getTime();
return Number.isFinite(ms) ? ms : 0;
}
function getLocale() {
if (typeof window !== 'undefined') {
if (typeof window.__locale === 'string' && window.__locale) {
return window.__locale;
}
if (typeof window.currentLang === 'string' && window.currentLang) {
return window.currentLang;
}
}
return 'zh-CN';
}
function formatTime(value) {
const ms = getTimeMs(value);
if (!ms) return '-';
return new Date(ms).toLocaleString(getLocale());
}
function htmlEscape(value) {
if (typeof window.escapeHtml === 'function') {
return window.escapeHtml(value == null ? '' : String(value));
}
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function t(key, fallback, params) {
if (typeof window !== 'undefined' && typeof window.t === 'function') {
try {
const translated = window.t(key, params || {});
if (translated && translated !== key) return translated;
} catch (_ignored) {}
}
return fallback;
}
async function apiJson(url, options) {
if (typeof window.apiFetch !== 'function') return null;
const res = await window.apiFetch(url, options || {});
if (!res.ok) return null;
return res.json();
}
async function fetchNotificationSummary() {
const url = '/api/notifications/summary?since='
+ encodeURIComponent(String(state.lastSeenAt || 0))
+ '&limit=80&lang=' + encodeURIComponent(getLocale());
try {
const summary = await apiJson(url);
if (summary && typeof summary === 'object') {
return summary;
}
} catch (_ignored) {}
return null;
}
function renderBadge(count) {
const badge = document.getElementById('notification-badge');
const btn = document.getElementById('notification-bell-btn');
if (!badge || !btn) return;
if (count <= 0) {
badge.style.display = 'none';
btn.classList.remove('has-alert');
return;
}
const text = count > 99 ? '99+' : String(count);
badge.innerHTML = '<span class="notification-badge-text">' + htmlEscape(text) + '</span>';
badge.style.display = 'inline-block';
btn.classList.add('has-alert');
}
function countP0(items) {
return (Array.isArray(items) ? items : []).reduce((acc, item) => {
if (!item || item.level !== 'p0') return acc;
if (typeof item.count === 'number' && item.count > 0) return acc + item.count;
return acc + 1;
}, 0);
}
function markableItems(items) {
return (Array.isArray(items) ? items : []).filter(item => item && item.actionable !== true && item.id);
}
function hasAction(item) {
if (!item || !item.type) return false;
if (item.type === 'vulnerability_created' && item.vulnerabilityId) return true;
if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) return true;
if (item.type === 'task_failed' && item.executionId) return true;
if (item.type === 'hitl_pending') return true;
return false;
}
function openNotificationTarget(item) {
if (!item || !item.type) return;
if (item.type === 'vulnerability_created' && item.vulnerabilityId) {
window.location.hash = 'vulnerabilities?id=' + encodeURIComponent(item.vulnerabilityId);
return;
}
if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) {
window.location.hash = 'chat?conversation=' + encodeURIComponent(item.conversationId);
return;
}
if (item.type === 'task_failed' && item.executionId) {
window.location.hash = 'mcp-monitor';
setTimeout(function () {
if (typeof showMCPDetail === 'function') {
showMCPDetail(item.executionId);
}
}, 450);
return;
}
if (item.type === 'hitl_pending') {
window.location.hash = 'hitl';
}
}
async function markItemsRead(eventIds) {
if (!Array.isArray(eventIds) || !eventIds.length) return true;
const payload = { eventIds: eventIds };
try {
const result = await apiJson('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return !!result;
} catch (_ignored) {
return false;
}
}
function renderNotificationList(items) {
const list = document.getElementById('notification-list');
if (!list) return;
const renderItems = Array.isArray(items) ? items.slice(0, MAX_RENDER_ITEMS) : [];
if (!renderItems.length) {
list.innerHTML = '<div class="notification-empty">' + htmlEscape(t('notifications.empty', '暂无新事件')) + '</div>';
return;
}
const html = renderItems.map(item => {
const canMarkRead = item.actionable !== true && !!item.id;
const canView = hasAction(item);
return `
<div class="notification-item notification-level-${htmlEscape(item.level || 'p2')}">
<div class="notification-item-header">
<div class="notification-item-title">${htmlEscape(item.title || t('notifications.itemDefaultTitle', '通知'))}</div>
<div class="notification-item-actions">
${canView ? `<button class="notification-item-action-btn notification-item-view-btn" type="button" data-action-id="${htmlEscape(item.id || '')}">${htmlEscape(t('common.view', '查看'))}</button>` : ''}
${canMarkRead ? `<button class="notification-item-action-btn notification-item-read-btn" type="button" data-notification-id="${htmlEscape(item.id)}">${htmlEscape(t('notifications.markSingleRead', '已读'))}</button>` : ''}
</div>
</div>
<div class="notification-item-desc">${htmlEscape(item.desc || '')}</div>
<div class="notification-item-time">${htmlEscape(formatTime(item.ts))}</div>
</div>
`;
}).join('');
list.innerHTML = html;
const viewButtons = list.querySelectorAll('.notification-item-view-btn');
viewButtons.forEach(btn => {
btn.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
const eventID = btn.getAttribute('data-action-id') || '';
if (!eventID) return;
const item = state.items.find(it => it && it.id === eventID);
if (!item) return;
openNotificationTarget(item);
closeDropdown();
});
});
const readButtons = list.querySelectorAll('.notification-item-read-btn');
readButtons.forEach(btn => {
btn.addEventListener('click', async function (event) {
event.preventDefault();
event.stopPropagation();
const eventID = btn.getAttribute('data-notification-id') || '';
if (!eventID) return;
const ok = await markItemsRead([eventID]);
if (ok) {
await refreshNotifications();
}
});
});
}
function closeDropdown() {
const dropdown = document.getElementById('notification-dropdown');
const bellBtn = document.getElementById('notification-bell-btn');
if (dropdown) dropdown.style.display = 'none';
if (bellBtn) bellBtn.classList.remove('active');
state.dropdownOpen = false;
}
function markSeenNow() {
state.lastSeenAt = Date.now();
persistLastSeenAt(state.lastSeenAt);
}
async function refreshNotifications() {
if (state.inFlight) return;
state.inFlight = true;
try {
const summary = await fetchNotificationSummary();
const items = summary && Array.isArray(summary.items) ? summary.items : [];
state.items = items;
const unreadCount = summary && Number.isFinite(Number(summary.unreadCount))
? Number(summary.unreadCount)
: countP0(items);
state.unreadCount = Math.max(0, unreadCount);
renderBadge(state.unreadCount);
renderNotificationList(items);
} catch (e) {
console.warn('刷新通知失败:', e);
} finally {
state.inFlight = false;
}
}
function scheduleNextPoll() {
if (state.timerId) {
window.clearTimeout(state.timerId);
state.timerId = null;
}
const interval = document.hidden ? POLL_INTERVAL_HIDDEN_MS : POLL_INTERVAL_ACTIVE_MS;
state.timerId = window.setTimeout(async function () {
await refreshNotifications();
scheduleNextPoll();
}, interval);
}
function handleDocumentClick(event) {
const container = document.querySelector('.notification-menu-container');
if (!container) return;
if (!container.contains(event.target)) {
closeDropdown();
}
}
async function toggleDropdown() {
const dropdown = document.getElementById('notification-dropdown');
const bellBtn = document.getElementById('notification-bell-btn');
if (!dropdown || !bellBtn) return;
const isOpen = dropdown.style.display !== 'none';
if (isOpen) {
closeDropdown();
return;
}
dropdown.style.display = 'block';
bellBtn.classList.add('active');
state.dropdownOpen = true;
await refreshNotifications();
}
async function markAllSeen() {
const ids = markableItems(state.items).map(item => item.id);
const ok = await markItemsRead(ids);
if (ok) {
markSeenNow();
await refreshNotifications();
}
}
function initNotifications() {
const bellBtn = document.getElementById('notification-bell-btn');
if (!bellBtn) return;
document.addEventListener('click', handleDocumentClick);
document.addEventListener('visibilitychange', scheduleNextPoll);
document.addEventListener('languagechange', function () {
refreshNotifications();
});
refreshNotifications();
scheduleNextPoll();
}
window.toggleNotificationDropdown = toggleDropdown;
window.markAllNotificationsSeen = markAllSeen;
document.addEventListener('DOMContentLoaded', initNotifications);
})();
+17 -2
View File
@@ -61,7 +61,7 @@ let vulnerabilityPagination = {
totalPages: 1
};
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= 同步筛选(对话菜单任务管理联动)
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动)
function syncVulnerabilityFiltersFromLocationHash() {
const hash = window.location.hash.slice(1);
const hashParts = hash.split('?');
@@ -69,19 +69,27 @@ function syncVulnerabilityFiltersFromLocationHash() {
return;
}
const params = new URLSearchParams(hashParts.slice(1).join('?'));
const vid = (params.get('id') || '').trim();
const cid = (params.get('conversation_id') || '').trim();
const tid = (params.get('task_id') || '').trim();
if (!cid && !tid) {
if (!vid && !cid && !tid) {
return;
}
vulnerabilityFilters.id = '';
vulnerabilityFilters.conversation_id = '';
vulnerabilityFilters.task_id = '';
const idEl = document.getElementById('vulnerability-id-filter');
const convEl = document.getElementById('vulnerability-conversation-filter');
const taskEl = document.getElementById('vulnerability-task-filter');
if (idEl) idEl.value = '';
if (convEl) convEl.value = '';
if (taskEl) taskEl.value = '';
if (vid) {
vulnerabilityFilters.id = vid;
if (idEl) idEl.value = vid;
}
if (cid) {
vulnerabilityFilters.conversation_id = cid;
if (convEl) convEl.value = cid;
@@ -334,6 +342,13 @@ function renderVulnerabilities(vulnerabilities) {
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(listContainer);
}
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
setTimeout(() => {
toggleVulnerabilityDetails(vulnerabilities[0].id);
}, 300);
}
}
// 渲染分页控件