Merge remote-tracking branch 'origin/main' into codex/fix-mobile-project-ui

This commit is contained in:
Sycun committed 2026-10-16 11:12:37 +08:00
commit 136b62deb5
65 files changed
+4883 -322

No files matched your search

+72
View File
@@ -16689,6 +16689,63 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
background: rgba(15, 23, 42, 0.3);
}
.attack-chain-context {
margin: 14px 14px 0;
padding: 12px;
flex-shrink: 0;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.8125rem;
}
.attack-chain-context[hidden] {
display: none;
}
.attack-chain-context summary {
cursor: pointer;
font-weight: 600;
overflow-wrap: anywhere;
}
.attack-chain-context-notice {
color: var(--text-secondary);
font-size: 0.75rem;
line-height: 1.6;
margin: 10px 0;
}
.attack-chain-context-entry {
border-top: 1px solid var(--border-color);
margin-top: 10px;
padding-top: 10px;
}
.attack-chain-context-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 360px;
overflow: auto;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
line-height: 1.6;
margin: 10px 0;
}
.attack-chain-context-copy {
font-size: 0.75rem;
padding: 5px 8px;
}
.attack-chain-context-copy-status {
display: block;
color: var(--text-secondary);
font-size: 0.75rem;
margin-top: 6px;
}
.attack-chain-toolbar {
padding: 14px 18px;
border-bottom: 1px solid rgba(15, 23, 42, 0.07);
@@ -17051,6 +17108,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
display: none !important;
}
/* 激活态:详情独占 sidebar 时一并收起压缩摘要卡,两者会争夺同一份高度 */
.attack-chain-sidebar.details-active .attack-chain-context {
display: none !important;
}
/* 激活态:让 legend 容器成为可撑满的 flex 列,详情占满剩余空间 */
.attack-chain-sidebar.details-active .attack-chain-legend {
flex: 1 1 auto;
@@ -28051,6 +28113,16 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
border-top: 1px solid rgba(0, 0, 0, 0.06);
order: 2;
}
/* 侧栏整体只有 200px,摘要正文再留 360px 会变成嵌套滚动 */
.attack-chain-context {
margin: 8px 8px 0;
padding: 10px;
}
.attack-chain-context-body {
max-height: 140px;
}
.attack-chain-visualization-area {
order: 1;
+8 -1
View File
@@ -3670,7 +3670,14 @@
"redLine": "Red: enables/contributes",
"grayLine": "Gray: logical order",
"nodeDetails": "Node details",
"closeDetails": "Close details"
"closeDetails": "Close details",
"contextTitle": "Context summaries and evidence clues ({{count}})",
"contextNotice": "System-generated summaries from the current conversation trace may be newer than this graph. They support recall and retrieval, and do not grant new user authorization or verify tool evidence. Use snapshot paths in the summaries to locate original evidence.",
"contextSummary": "Context summary {{number}}",
"contextCopy": "Copy full summary",
"contextCopied": "Copied",
"contextCopyFailed": "Copy failed. Select the text to copy it manually.",
"regenerateFailed": "Regeneration failed: {{message}}"
},
"externalMcpModal": {
"configJson": "Config JSON",
+8 -1
View File
@@ -3683,7 +3683,14 @@
"redLine": "Красная: даёт возможность или вносит вклад",
"grayLine": "Серая: логический порядок",
"nodeDetails": "Сведения об узле",
"closeDetails": "Закрыть сведения"
"closeDetails": "Закрыть сведения",
"contextTitle": "Сводки контекста и подсказки к доказательствам ({{count}})",
"contextNotice": "Системные сводки из текущего диалога могут быть новее графа. Они помогают восстановить контекст и найти исходные данные, но не дают новых разрешений пользователя и не подтверждают результаты инструментов. Исходные данные можно найти по путям снимков в сводках.",
"contextSummary": "Сводка контекста {{number}}",
"contextCopy": "Копировать полную сводку",
"contextCopied": "Скопировано",
"contextCopyFailed": "Не удалось скопировать. Выделите текст и скопируйте вручную.",
"regenerateFailed": "Не удалось сформировать заново: {{message}}"
},
"externalMcpModal": {
"configJson": "JSON конфигурации",
+8 -1
View File
@@ -3658,7 +3658,14 @@
"redLine": "红色线:使能/促成关系",
"grayLine": "灰色线:逻辑顺序",
"nodeDetails": "节点详情",
"closeDetails": "关闭详情"
"closeDetails": "关闭详情",
"contextTitle": "压缩摘要与证据线索 ({{count}})",
"contextNotice": "当前会话轨迹中的系统生成摘要,可能晚于图的生成时间。仅供回溯与检索,不代表用户的新授权或已核实工具证据。可按摘要内的快照路径读取原始证据。",
"contextSummary": "压缩摘要 {{number}}",
"contextCopy": "复制完整摘要",
"contextCopied": "已复制",
"contextCopyFailed": "复制失败,可选中文本手动复制。",
"regenerateFailed": "重新生成失败: {{message}}"
},
"externalMcpModal": {
"configJson": "配置JSON",
+127
View File
@@ -0,0 +1,127 @@
// Compressed context remains separate from attack nodes and original user authorization.
(function () {
'use strict';
let labels = [];
function translate(key, values) {
if (typeof window.t === 'function') return window.t('attackChainModal.' + key, values);
const fallback = {
contextTitle: '压缩摘要与证据线索 ({{count}})',
contextNotice: '当前会话轨迹中的系统生成摘要,可能晚于图的生成时间。仅供回溯与检索,不代表用户的新授权或已核实工具证据。可按摘要内的快照路径读取原始证据。',
contextSummary: '压缩摘要 {{number}}',
contextCopy: '复制完整摘要',
contextCopied: '已复制',
contextCopyFailed: '复制失败,可选中文本手动复制。'
};
let text = fallback[key] || key;
Object.entries(values || {}).forEach(([name, value]) => {
text = text.replace('{{' + name + '}}', String(value));
});
return text;
}
function label(element, key, values) {
const refresh = () => { element.textContent = translate(key, values); };
labels.push(refresh);
refresh();
}
function clear() {
labels = [];
const card = document.getElementById('attack-chain-context');
const contents = document.getElementById('attack-chain-context-content');
if (contents) contents.replaceChildren();
if (card) {
card.hidden = true;
card.open = false;
}
}
async function copyText(text) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_) {
// Plain HTTP deployments may deny clipboard access; retain manual-copy fallback.
}
}
const previousFocus = document.activeElement;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.readOnly = true;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
try {
textarea.focus();
textarea.select();
if (!document.execCommand('copy')) throw new Error('copy failed');
} finally {
textarea.remove();
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus({ preventScroll: true });
}
}
}
function render(chainData) {
const card = document.getElementById('attack-chain-context');
// A 409 refresh re-renders the same conversation; keep the card's open state.
const wasOpen = !!card && card.open;
clear();
const contents = document.getElementById('attack-chain-context-content');
if (!card || !contents) return;
// Only explicit server provenance qualifies; summary-looking user text stays user text.
const summaries = Array.isArray(chainData && chainData.context_summaries)
? chainData.context_summaries.filter(item => item &&
item.provenance === 'system_generated_summary' &&
typeof item.content === 'string' && item.content.trim())
: [];
if (!summaries.length) return;
label(document.getElementById('attack-chain-context-title'), 'contextTitle', { count: summaries.length });
label(document.getElementById('attack-chain-context-notice'), 'contextNotice');
summaries.forEach((item, index) => {
const entry = document.createElement('details');
entry.className = 'attack-chain-context-entry';
const heading = document.createElement('summary');
label(heading, 'contextSummary', { number: index + 1 });
const body = document.createElement('pre');
body.className = 'attack-chain-context-body';
// Do not render Markdown/HTML or turn evidence paths into clickable URLs.
body.textContent = item.content;
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn-secondary attack-chain-context-copy';
label(button, 'contextCopy');
const status = document.createElement('span');
status.className = 'attack-chain-context-copy-status';
status.setAttribute('role', 'status');
let statusKey = '';
labels.push(() => { status.textContent = statusKey ? translate(statusKey) : ''; });
button.addEventListener('click', async () => {
button.disabled = true;
status.textContent = '';
try {
await copyText(item.content);
statusKey = 'contextCopied';
} catch (_) {
statusKey = 'contextCopyFailed';
} finally {
status.textContent = translate(statusKey);
button.disabled = false;
}
});
entry.append(heading, body, button, status);
contents.appendChild(entry);
});
card.hidden = false;
card.open = wasOpen;
}
document.addEventListener('languagechange', () => labels.forEach(refresh => refresh()));
window.CyberStrikeAttackChainContext = { render, clear };
})();
+292
View File
@@ -0,0 +1,292 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const contextSource = fs.readFileSync('web/static/js/attack-chain-context.js', 'utf8');
const chatSource = fs.readFileSync('web/static/js/chat.js', 'utf8');
class Element {
constructor(tagName, sinks) {
Object.assign(this, { tagName, children: [], listeners: new Map(), attributes: {}, style: {}, textContent: '', hidden: false, open: false, sinks });
this.classList = { remove() {}, add() {} };
}
append(...children) { children.forEach(child => this.appendChild(child)); }
appendChild(child) { this.children.push(child); child.parent = this; return child; }
replaceChildren(...children) { this.children.forEach(child => { child.parent = null; }); this.children = []; this.append(...children); }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
addEventListener(type, listener) { this.listeners.set(type, listener); }
setAttribute(name, value) { this.attributes[name] = value; }
focus() {}
select() {}
set innerHTML(value) { this.sinks.push(String(value)); this.replaceChildren(); this._html = value; }
get innerHTML() { return this._html || ''; }
}
function deferred() {
let resolve, reject;
const promise = new Promise((r, j) => { resolve = r; reject = j; });
return { promise, resolve, reject };
}
function harness(options = {}) {
const sinks = [], copied = [], timers = [], listeners = new Map(), ids = new Map();
for (const id of ['attack-chain-context', 'attack-chain-context-content', 'attack-chain-context-title', 'attack-chain-context-notice', 'attack-chain-container', 'attack-chain-stats', 'attack-chain-modal', 'attack-chain-details']) ids.set(id, new Element('div', sinks));
ids.get('attack-chain-context').hidden = true;
const button = new Element('button', sinks), sidebar = new Element('div', sinks), body = new Element('body', sinks);
const document = {
body, getElementById: id => ids.get(id), createElement: tag => new Element(tag, sinks),
addEventListener(type, callback) { const callbacks = listeners.get(type) || []; callbacks.push(callback); listeners.set(type, callbacks); },
querySelector: selector => selector === '.attack-chain-sidebar' ? sidebar : button,
execCommand: () => { copied.push(body.children.find(child => child.tagName === 'textarea').value); return options.copyWorks !== false; }
};
let language = 'zh-CN';
const dictionaries = Object.fromEntries(['zh-CN', 'en-US', 'ru-RU'].map(lang => [lang, JSON.parse(fs.readFileSync(`web/static/i18n/${lang}.json`, 'utf8'))]));
const window = {
t(key, values) {
let translated = key.split('.').reduce((value, part) => value && value[part], dictionaries[language]) || key;
// Mirror real i18next: interpolated values are escaped unless the caller opts out.
const escape = !(values && values.interpolation && values.interpolation.escapeValue === false);
Object.entries(values || {}).forEach(([name, value]) => {
if (name === 'interpolation') return;
const text = escape
? String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\//g, '&#x2F;')
: String(value);
translated = translated.replace('{{' + name + '}}', text);
});
return translated;
}, attackChainOriginalData: null
};
const runtime = vm.createContext({
document, window, navigator: { clipboard: options.clipboard === false ? undefined : { writeText: async text => { if (options.clipboardRejects) throw Error('denied'); copied.push(text); } } },
setTimeout: callback => { timers.push(callback); }, console: { error() {}, log() {} },
closeAppModal() {}, openAppModal() {}, isAppModalOpen: () => false, closeNodeDetails() {},
alert() {},
currentAttackChainConversationId: 'A', attackChainCytoscape: null,
apiFetch: options.apiFetch || (() => { throw Error('unexpected API request'); })
});
vm.runInContext(contextSource, runtime);
vm.runInContext('let attackChainRequestSequence = 0; const attackChainRequestOwners = new Map(); const attackChainLoadingMap = new Map();', runtime);
for (const [name, nextName] of [
['beginAttackChainRequest', '_acBuildNodeIconDataUrl'], ['isAttackChainLoading', 'addAttackChainButton'],
['resetAttackChainContext', 'showAttackChain'], ['showAttackChain', 'loadAttackChain'],
['loadAttackChain', 'renderAttackChain'], ['renderAttackChain', 'getEdgeNodes'],
['updateAttackChainStats', 'closeNodeDetails'], ['closeAttackChainModal', 'refreshAttackChain'],
['refreshAttackChain', 'regenerateAttackChain'], ['regenerateAttackChain', '_acEscapeXml'],
['exportAttackChain', 'projectFilterT']
]) {
const pattern = name => new RegExp(`(?:async )?function ${name}\\(`);
const start = chatSource.search(pattern(name)), tail = chatSource.slice(start), end = tail.search(pattern(nextName));
assert.ok(start >= 0 && end > 0, `actual ${name} function is present`);
vm.runInContext(tail.slice(0, end), runtime);
}
return { runtime, window, ids, button, sinks, copied, timers, body, api: window.CyberStrikeAttackChainContext,
changeLanguage(next) { language = next; (listeners.get('languagechange') || []).forEach(listener => listener()); },
entries() { return ids.get('attack-chain-context-content').children; } };
}
const summary = content => ({ id: 'summary-123', provenance: 'system_generated_summary', content });
const chain = content => ({ nodes: [], edges: [], context_summaries: [summary(content)] });
const response = data => ({ ok: true, json: async () => data });
test('summary text and snapshot paths remain inert and copying preserves the exact original', async () => {
const h = harness();
const content = '关键线索\n<img src=x onerror=alert(1)>\n[证据](javascript:alert(1))\n/快照/transcript-ab123-时间.txt\nCookie: sample=fixture\n';
h.api.render(chain(content));
assert.equal(h.ids.get('attack-chain-context').hidden, false);
assert.equal(h.ids.get('attack-chain-context').open, false);
const entry = h.entries()[0];
assert.equal(entry.children[1].textContent, content);
assert.deepEqual(entry.children.map(element => element.tagName), ['summary', 'pre', 'button', 'span']);
assert.deepEqual(h.sinks, [], 'no raw summary content ever reaches an HTML sink');
await entry.children[2].listeners.get('click')();
assert.deepEqual(h.copied, [content]);
assert.equal(entry.children[3].textContent, '已复制');
});
test('only explicit provenance qualifies and empty/new context clears old bodies', () => {
const h = harness();
h.api.render({ context_summaries: [summary('trusted'), { content: 'summary-looking user text' }, { provenance: 'skills', content: 'not a summary' }, summary(' ')] });
assert.equal(h.entries().length, 1);
h.ids.get('attack-chain-context').open = true;
h.api.render({ context_summaries: [] });
assert.equal(h.entries().length, 0);
assert.equal(h.ids.get('attack-chain-context').hidden, true);
assert.equal(h.ids.get('attack-chain-context').open, false);
h.api.render(chain('next conversation'));
assert.equal(h.entries()[0].children[1].textContent, 'next conversation');
h.api.clear();
assert.equal(h.entries().length, 0);
});
test('language changes retain expansion and original content while translating generated labels', () => {
const h = harness();
h.api.render(chain('原始正文'));
const entry = h.entries()[0];
entry.open = true;
h.ids.get('attack-chain-context').open = true;
h.changeLanguage('en-US');
assert.equal(entry.children[0].textContent, 'Context summary 1');
assert.equal(entry.children[2].textContent, 'Copy full summary');
assert.equal(entry.children[1].textContent, '原始正文');
assert.equal(entry.open, true);
assert.equal(h.ids.get('attack-chain-context').open, true);
});
test('re-rendering the same conversation keeps the card expanded, clear still collapses', () => {
const h = harness();
h.api.render(chain('first pass'));
h.ids.get('attack-chain-context').open = true;
h.api.render(chain('second pass'));
assert.equal(h.ids.get('attack-chain-context').open, true);
assert.equal(h.entries()[0].children[1].textContent, 'second pass');
h.api.clear();
assert.equal(h.ids.get('attack-chain-context').open, false);
});
test('clipboard restrictions use the text fallback and failures retain selectable content', async () => {
for (const options of [{ clipboard: false }, { clipboardRejects: true }, { clipboard: false, copyWorks: false }]) {
const h = harness(options);
h.api.render(chain('full evidence path'));
const entry = h.entries()[0];
await entry.children[2].listeners.get('click')();
assert.deepEqual(h.copied, ['full evidence path']);
assert.equal(h.body.children.length, 0);
assert.equal(entry.children[2].disabled, false);
assert.equal(entry.children[1].textContent, 'full evidence path');
assert.equal(entry.children[3].textContent, options.copyWorks === false ? '复制失败,可选中文本手动复制。' : '已复制');
}
});
test('an empty graph still displays its context without changing nodes or statistics', () => {
const h = harness(), data = chain('summary without graph nodes');
let destroyed = 0;
h.runtime.attackChainCytoscape = { destroy: () => { destroyed += 1; } };
h.runtime.renderAttackChain(data);
h.runtime.updateAttackChainStats(data);
assert.equal(h.entries()[0].children[1].textContent, 'summary without graph nodes');
assert.equal(h.ids.get('attack-chain-stats').textContent, '节点: 0 | 边: 0');
assert.equal(data.nodes.length, 0);
assert.equal(h.window.attackChainOriginalData, data);
assert.equal(destroyed, 1);
assert.equal(h.runtime.attackChainCytoscape, null, 'old graph is unavailable to filters and exports');
const alerts = [];
h.runtime.alert = message => alerts.push(message);
h.runtime.exportAttackChain('svg');
assert.equal(alerts.length, 1);
assert.equal(h.timers.length, 0, 'export cannot schedule an old graph download');
});
test('late 409/errors cannot replace another conversation or unlock its request', async () => {
for (const operation of ['loadAttackChain', 'regenerateAttackChain']) for (const result of ['409', 'error', 'json-error']) {
const request = deferred(), h = harness({ apiFetch: () => request.promise });
const pending = operation === 'loadAttackChain' ? h.runtime[operation]('A') : h.runtime[operation]();
h.runtime.currentAttackChainConversationId = 'B';
h.api.render(chain('B evidence'));
h.button.disabled = true;
const marker = h.ids.get('attack-chain-container').innerHTML;
if (result === 'error') request.reject(Error('<img onerror=alert(1)>'));
else if (result === 'json-error') request.resolve({ ok: false, status: 500, json: async () => ({ error: 'old error' }) });
else request.resolve({ ok: false, status: 409 });
await pending;
assert.equal(h.entries()[0].children[1].textContent, 'B evidence', `${operation}/${result}`);
assert.equal(h.ids.get('attack-chain-container').innerHTML, marker);
assert.equal(h.button.disabled, true);
assert.equal(h.timers.length, 0);
}
});
test('same-conversation requests keep the latest response and old completion cannot clear loading', async () => {
const requests = [deferred(), deferred()];
let index = 0;
const h = harness({ apiFetch: () => requests[index++].promise });
const older = h.runtime.loadAttackChain('A');
h.runtime.setAttackChainLoading('A', false);
const newer = h.runtime.loadAttackChain('A');
requests[0].resolve(response(chain('old evidence')));
await older;
assert.equal(h.runtime.isAttackChainLoading('A'), true);
requests[1].resolve(response(chain('new evidence')));
await newer;
assert.equal(h.entries()[0].children[1].textContent, 'new evidence');
assert.equal(h.runtime.isAttackChainLoading('A'), false);
const reverse = [deferred(), deferred()];
index = 0;
h.runtime.apiFetch = () => reverse[index++].promise;
const slow = h.runtime.loadAttackChain('A');
h.runtime.setAttackChainLoading('A', false);
const fast = h.runtime.loadAttackChain('A');
reverse[1].resolve(response(chain('newest evidence')));
await fast;
reverse[0].resolve(response(chain('stale evidence')));
await slow;
assert.equal(h.entries()[0].children[1].textContent, 'newest evidence');
});
test('current errors clear context and display server text without HTML execution', async () => {
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: '<img src=x onerror=alert(1)>' }) }) });
h.api.render(chain('old evidence'));
let destroyed = 0;
h.runtime.attackChainCytoscape = { destroy: () => { destroyed += 1; } };
await h.runtime.loadAttackChain('A');
assert.equal(h.entries().length, 0);
assert.equal(h.ids.get('attack-chain-container').children[0].textContent.includes('<img src=x onerror=alert(1)>'), true);
assert.equal(h.sinks.some(html => html.includes('onerror')), false);
assert.equal(destroyed, 1);
assert.equal(h.runtime.attackChainCytoscape, null);
const alerts = [];
h.runtime.alert = message => alerts.push(message);
h.runtime.exportAttackChain('svg');
assert.equal(alerts.length, 1);
assert.equal(h.timers.length, 0);
});
test('failure text stays readable instead of showing i18next escape entities', async () => {
const upstream = '生成攻击链失败: Post "https://api.example.com/v1/chat/completions": context deadline exceeded';
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: upstream }) }) });
await h.runtime.loadAttackChain('A');
const shown = h.ids.get('attack-chain-container').children[0].textContent;
assert.equal(shown.includes(upstream), true);
assert.equal(/&#x2F;|&quot;|&amp;|&#39;/.test(shown), false);
assert.equal(h.sinks.some(html => html.includes('api.example.com')), false);
});
test('regenerate failure reports its own message key unescaped', async () => {
const upstream = 'AI生成失败: Post "https://api.example.com/v1/chat/completions": EOF';
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: upstream }) }) });
await h.runtime.regenerateAttackChain();
const shown = h.ids.get('attack-chain-container').children[0].textContent;
assert.equal(shown.startsWith('重新生成失败: '), true);
assert.equal(shown.includes(upstream), true);
assert.equal(h.sinks.some(html => html.includes('api.example.com')), false);
});
test('regenerate 409 refreshes after completion while superseded timers stay inert', async () => {
const h = harness({ apiFetch: async () => ({ ok: false, status: 409 }) });
let refreshes = 0;
h.runtime.refreshAttackChain = () => { refreshes += 1; };
await h.runtime.regenerateAttackChain();
assert.equal(h.runtime.isAttackChainLoading('A'), false);
h.timers[0]();
assert.equal(refreshes, 1);
h.runtime.beginAttackChainRequest('A');
h.timers[0]();
assert.equal(refreshes, 1);
});
test('closing/reopening pending context clears old bodies and invalidates old responses', async () => {
const requests = [deferred(), deferred()];
let index = 0;
const h = harness({ apiFetch: () => requests[index++].promise });
const oldRequest = h.runtime.loadAttackChain('A');
h.api.render(chain('old context'));
h.runtime.closeAttackChainModal();
assert.equal(h.entries().length, 0);
assert.equal(h.window.attackChainOriginalData, null);
const reopened = h.runtime.showAttackChain('A');
assert.equal(index, 2);
requests[0].resolve(response(chain('stale closed context')));
await oldRequest;
assert.equal(h.entries().length, 0);
assert.equal(h.runtime.isAttackChainLoading('A'), true);
requests[1].resolve(response(chain('reopened context')));
await reopened;
assert.equal(h.entries()[0].children[1].textContent, 'reopened context');
});
+97 -30
View File
@@ -6860,6 +6860,26 @@ function updateActiveConversation() {
// ==================== 攻击链可视化功能 ====================
let attackChainRequestSequence = 0;
const attackChainRequestOwners = new Map();
function beginAttackChainRequest(conversationId) {
const requestSequence = ++attackChainRequestSequence;
attackChainRequestOwners.set(conversationId, requestSequence);
return requestSequence;
}
function isCurrentAttackChainRequest(conversationId, requestSequence) {
return currentAttackChainConversationId === conversationId && attackChainRequestSequence === requestSequence;
}
function finishAttackChainRequest(conversationId, requestSequence) {
if (attackChainRequestOwners.get(conversationId) === requestSequence) {
attackChainRequestOwners.delete(conversationId);
setAttackChainLoading(conversationId, false);
}
}
// 生成节点图标的 data URL(用于 Cytoscape background-image)
// 返回一个精美的渐变色方块 + 白色矢量图标
function _acBuildNodeIconDataUrl(iconType, color, colorDark) {
@@ -6926,6 +6946,29 @@ function updateAttackChainAvailability() {
addAttackChainButton(currentConversationId);
}
function resetAttackChainContext() {
if (attackChainCytoscape) {
attackChainCytoscape.destroy();
attackChainCytoscape = null;
}
window.attackChainOriginalData = null;
if (window.CyberStrikeAttackChainContext) window.CyberStrikeAttackChainContext.clear();
}
function renderAttackChainError(container, message) {
if (!container) return;
const errorElement = document.createElement('div');
errorElement.className = 'error-message';
errorElement.textContent = message;
container.replaceChildren(errorElement);
}
// window.t escapes interpolation for innerHTML; these go to textContent, so opt out.
function attackChainFailureText(key, fallbackPrefix, message) {
if (typeof window.t !== 'function') return fallbackPrefix + message;
return window.t(key, { message, interpolation: { escapeValue: false } });
}
// 显示攻击链模态框
async function showAttackChain(conversationId) {
// 如果当前显示的对话ID不同,或者没有在加载,允许打开
@@ -6940,6 +6983,11 @@ async function showAttackChain(conversationId) {
}
currentAttackChainConversationId = conversationId;
// Reopening or returning to a conversation starts a fresh view, even if an old request is pending.
attackChainRequestSequence += 1;
attackChainRequestOwners.delete(conversationId);
setAttackChainLoading(conversationId, false);
resetAttackChainContext();
const modal = document.getElementById('attack-chain-modal');
if (!modal) {
console.error('攻击链模态框未找到');
@@ -6960,6 +7008,8 @@ async function showAttackChain(conversationId) {
if (detailsPanel) {
detailsPanel.style.display = 'none';
}
const sidebar = document.querySelector('.attack-chain-sidebar');
if (sidebar) sidebar.classList.remove('details-active');
// 禁用重新生成按钮
const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]');
@@ -6980,14 +7030,26 @@ async function loadAttackChain(conversationId) {
}
setAttackChainLoading(conversationId, true);
const requestSequence = beginAttackChainRequest(conversationId);
if (currentAttackChainConversationId === conversationId) {
resetAttackChainContext();
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = '<div class="loading-spinner">' + (typeof window.t === 'function' ? window.t('chat.loading') : '加载中...') + '</div>';
}
}
try {
const response = await apiFetch(`/api/attack-chain/${conversationId}`);
if (!isCurrentAttackChainRequest(conversationId, requestSequence)) {
finishAttackChainRequest(conversationId, requestSequence);
return;
}
if (!response.ok) {
// 处理 409 Conflict(正在生成中)
if (response.status === 409) {
const error = await response.json();
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = `
@@ -7006,7 +7068,7 @@ async function loadAttackChain(conversationId) {
// 使用闭包保存 conversationId,防止串台
setTimeout(() => {
// 检查当前显示的对话ID是否匹配
if (currentAttackChainConversationId === conversationId) {
if (isCurrentAttackChainRequest(conversationId, requestSequence)) {
refreshAttackChain();
}
}, 5000);
@@ -7030,12 +7092,12 @@ async function loadAttackChain(conversationId) {
const chainData = await response.json();
// 检查当前显示的对话ID是否匹配,防止串台
if (currentAttackChainConversationId !== conversationId) {
if (!isCurrentAttackChainRequest(conversationId, requestSequence)) {
console.log('攻击链数据已返回,但当前显示的对话已切换,忽略此次渲染', {
returned: conversationId,
current: currentAttackChainConversationId
});
setAttackChainLoading(conversationId, false);
finishAttackChainRequest(conversationId, requestSequence);
return;
}
@@ -7046,20 +7108,20 @@ async function loadAttackChain(conversationId) {
updateAttackChainStats(chainData);
// 成功加载后,重置加载状态
setAttackChainLoading(conversationId, false);
finishAttackChainRequest(conversationId, requestSequence);
} catch (error) {
console.error('加载攻击链失败:', error);
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = '<div class="error-message">' + (typeof window.t === 'function' ? window.t('chat.loadFailed', { message: error.message }) : '加载失败: ' + error.message) + '</div>';
}
// 错误时也重置加载状态
setAttackChainLoading(conversationId, false);
finishAttackChainRequest(conversationId, requestSequence);
if (!isCurrentAttackChainRequest(conversationId, requestSequence)) return;
resetAttackChainContext();
renderAttackChainError(document.getElementById('attack-chain-container'),
attackChainFailureText('chat.loadFailed', '加载失败: ', error.message));
} finally {
// 恢复重新生成按钮
const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]');
if (regenerateBtn) {
if (regenerateBtn && isCurrentAttackChainRequest(conversationId, requestSequence)) {
regenerateBtn.disabled = false;
regenerateBtn.style.opacity = '1';
regenerateBtn.style.cursor = 'pointer';
@@ -7069,6 +7131,12 @@ async function loadAttackChain(conversationId) {
// 渲染攻击链
function renderAttackChain(chainData) {
if (attackChainCytoscape) {
attackChainCytoscape.destroy();
attackChainCytoscape = null;
}
window.attackChainOriginalData = chainData;
if (window.CyberStrikeAttackChainContext) window.CyberStrikeAttackChainContext.render(chainData);
const container = document.getElementById('attack-chain-container');
if (!container) {
return;
@@ -7681,8 +7749,6 @@ function renderAttackChain(chainData) {
attackChainCytoscape.edges().style({ 'opacity': 0.88, 'width': '' });
});
// 保存原始数据用于过滤
window.attackChainOriginalData = chainData;
}
// 安全地获取边的源节点和目标节点
@@ -8106,16 +8172,14 @@ function closeNodeDetails() {
// 关闭攻击链模态框
function closeAttackChainModal() {
closeAppModal('attack-chain-modal');
attackChainRequestSequence += 1;
attackChainRequestOwners.delete(currentAttackChainConversationId);
setAttackChainLoading(currentAttackChainConversationId, false);
resetAttackChainContext();
// 关闭节点详情
closeNodeDetails();
// 清理Cytoscape实例
if (attackChainCytoscape) {
attackChainCytoscape.destroy();
attackChainCytoscape = null;
}
currentAttackChainConversationId = null;
}
@@ -8153,6 +8217,8 @@ async function regenerateAttackChain() {
// 保存请求时的对话ID,防止串台
const savedConversationId = currentAttackChainConversationId;
setAttackChainLoading(savedConversationId, true);
const requestSequence = beginAttackChainRequest(savedConversationId);
resetAttackChainContext();
const container = document.getElementById('attack-chain-container');
if (container) {
@@ -8173,10 +8239,11 @@ async function regenerateAttackChain() {
method: 'POST'
});
if (!isCurrentAttackChainRequest(savedConversationId, requestSequence)) return;
if (!response.ok) {
// 处理 409 Conflict(正在生成中)
if (response.status === 409) {
const error = await response.json();
if (container) {
container.innerHTML = `
<div class="loading-spinner" style="text-align: center; padding: 40px;">
@@ -8193,9 +8260,8 @@ async function regenerateAttackChain() {
// 5秒后自动刷新
// savedConversationId 已在函数开始处定义
setTimeout(() => {
// 检查当前显示的对话ID是否匹配,且仍在加载中
if (currentAttackChainConversationId === savedConversationId &&
isAttackChainLoading(savedConversationId)) {
// Only this view's latest request may start another refresh.
if (isCurrentAttackChainRequest(savedConversationId, requestSequence)) {
refreshAttackChain();
}
}, 5000);
@@ -8209,12 +8275,12 @@ async function regenerateAttackChain() {
const chainData = await response.json();
// 检查当前显示的对话ID是否匹配,防止串台
if (currentAttackChainConversationId !== savedConversationId) {
if (!isCurrentAttackChainRequest(savedConversationId, requestSequence)) {
console.log('攻击链数据已返回,但当前显示的对话已切换,忽略此次渲染', {
returned: savedConversationId,
current: currentAttackChainConversationId
});
setAttackChainLoading(savedConversationId, false);
finishAttackChainRequest(savedConversationId, requestSequence);
return;
}
@@ -8226,14 +8292,15 @@ async function regenerateAttackChain() {
} catch (error) {
console.error('重新生成攻击链失败:', error);
if (container) {
container.innerHTML = `<div class="error-message">重新生成失败: ${error.message}</div>`;
}
if (!isCurrentAttackChainRequest(savedConversationId, requestSequence)) return;
resetAttackChainContext();
renderAttackChainError(container,
attackChainFailureText('attackChainModal.regenerateFailed', '重新生成失败: ', error.message));
} finally {
setAttackChainLoading(savedConversationId, false);
finishAttackChainRequest(savedConversationId, requestSequence);
// 恢复重新生成按钮
if (regenerateBtn) {
if (regenerateBtn && isCurrentAttackChainRequest(savedConversationId, requestSequence)) {
regenerateBtn.disabled = false;
regenerateBtn.style.opacity = '1';
regenerateBtn.style.cursor = 'pointer';
+8 -2
View File
@@ -39,7 +39,7 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20261012-token-analytics">
<link rel="stylesheet" href="/static/css/style.css?v=20261016-context-memory-token-analytics">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
@@ -5718,6 +5718,11 @@
</div>
<div class="attack-chain-sidebar">
<div class="attack-chain-sidebar-content">
<details id="attack-chain-context" class="attack-chain-context" hidden>
<summary><span id="attack-chain-context-title"></span></summary>
<p id="attack-chain-context-notice" class="attack-chain-context-notice"></p>
<div id="attack-chain-context-content"></div>
</details>
<div class="attack-chain-legend">
<div class="legend-section">
<div class="legend-title" data-i18n="attackChainModal.riskLevel">风险等级</div>
@@ -7020,7 +7025,8 @@
<script src="/static/js/dashboard.js?v=20261011-token-settings-cache-only"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20261011-token-usage"></script>
<script src="/static/js/chat.js?v=20261011-token-usage"></script>
<script src="/static/js/attack-chain-context.js?v=20261012-summary-2"></script>
<script src="/static/js/chat.js?v=20261016-context-memory-token-usage"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/tool-guard.js?v=20261010-conflict"></script>