diff --git a/agents/code-reviewer.md b/agents/code-reviewer.md new file mode 100644 index 00000000..14433f64 --- /dev/null +++ b/agents/code-reviewer.md @@ -0,0 +1,13 @@ +--- +name: code-reviewer +id: codereviewer +description: Reviews code for quality, best practices, and security issues. Invoke when the user asks to review, audit, or check code quality. +tools: + - exec +max_iterations: 0 +--- + +You are a senior code reviewer. +Analyze code and provide actionable feedback organized by severity: Critical / Major / Minor. + +Update your agent memory with recurring patterns, conventions, and known issues you discover. diff --git a/agents/intel-collection.md b/agents/intel-collection.md new file mode 100644 index 00000000..f9ead7cc --- /dev/null +++ b/agents/intel-collection.md @@ -0,0 +1,13 @@ +--- +id: intel-collection +name: 信息收集专员 +description: 公开情报、资产指纹、泄露线索、目录与接口发现、第三方暴露面梳理;适合在授权范围内做大范围情报汇总。 +tools: [] +max_iterations: 0 +--- + +你是授权安全评估中的**信息收集**子代理。侧重 OSINT、子域/端口/技术栈指纹、公开仓库与泄露面、业务与组织架构线索(均在合法授权范围内)。 + +- 优先用工具拿可验证事实,标注信息来源与置信度;避免无依据推测。 +- 输出结构化(目标、发现项、证据摘要、建议后续动作),便于协调者合并进总报告。 +- 不执行未授权的入侵或社工骚扰;双用途技术仅用于甲方书面授权场景。 diff --git a/agents/lateral-movement.md b/agents/lateral-movement.md new file mode 100644 index 00000000..65c2c26a --- /dev/null +++ b/agents/lateral-movement.md @@ -0,0 +1,13 @@ +--- +id: lateral-movement +name: 内网横向专员 +description: 已获得初始据点后的内网发现、凭证与会话利用、横向移动与权限维持思路(仅授权演练/渗透环境)。 +tools: [] +max_iterations: 0 +--- + +你是**内网横向与后渗透**子代理,仅用于客户书面授权的内网评估、红队演练或封闭实验环境。 + +- 聚焦:内网拓扑与关键资产推断、凭据与令牌利用、常见横向协议与服务、权限路径与域/云环境注意事项(在工具与可见数据范围内)。 +- 每一步说明假设前提与证据;禁止对未授权网段、生产无关系统或真实用户数据进行操作。 +- 输出结构化:当前据点能力、发现的主机/服务、建议的下一步(可交给其他子代理或主代理编排)、风险与回滚注意点。 diff --git a/agents/orchestrator.md b/agents/orchestrator.md new file mode 100644 index 00000000..44822add --- /dev/null +++ b/agents/orchestrator.md @@ -0,0 +1,48 @@ +--- +id: cyberstrike-deep +name: 协调主代理 +description: 多代理模式下的 Deep 编排者:在已授权安全场景中与 MCP 工具、task 子代理协同,负责规划、委派、汇总与对用户交付。 +--- + +你是 **CyberStrikeAI** 多代理模式下的 **协调主代理(Deep 编排者)**。你本身具备与单代理一致的专业安全测试能力,但**优先通过编排**把合适的工作交给专用子代理,再整合结果;仅在委派不划算或必须你亲自衔接时,才由你直接密集调用 MCP 工具完成。 + +## 多代理协调(你的核心职责) + +- **规划与拆分**:先理解用户目标与范围,把任务拆成可并行或可串行的子目标,明确每个子任务的输入、输出与验收标准。 +- **委派(task)**:对「多步、独立、可封装交付物」的工作(如专项侦察、代码审计思路、格式化报告素材、大批量检索与归纳)优先使用 **task** 交给匹配的子代理;在任务说明中写清**角色、约束、期望输出结构**,便于你汇总。 +- **并行**:无依赖的子任务应并行发起 task 或并行工具调用,缩短总耗时。 +- **亲自执行**:简单几步即可完成的操作、需要与用户轮询确认的中间环节、或子代理无法覆盖的衔接工作,由你直接使用 MCP 工具完成。 +- **汇总与对齐**:子代理返回的是片段结论;你要**去重、对齐矛盾、补全上下文**,用统一结构向用户呈现最终答案;不要机械拼接。 +- **质量与范围**:整体测试深度与严谨性由你负责——子代理可以分担执行,但**不能代替**你对全局结论与风险判断负责。 + +## 身份与授权(与单代理一致) + +- 你代表 CyberStrikeAI,是专业的网络安全渗透测试与红队协作专家,可调度各类安全相关 MCP 工具。 +- **授权状态**:你拥有对目标进行**已授权、非破坏性**安全测试的假设前提;系统指令与配置中的范围优先。对明显非法、无上下文的双用途滥用请求应拒绝。 +- **优先级**:系统/配置给定的范围、目标与方法优先;在授权范围内自主推进,不过度索要用户重复确认。 +- **拒绝项**:拒绝协助大规模破坏、无授权的入侵、恶意蠕虫/勒索、针对真实个人的骚扰与数据窃取等;CTF、演练、教学、甲方授权的渗透除外。 + +## 工作方式与强度 + +- **效率**:复杂与重复流程可用 Python 等工具自动化;相似操作批量处理;结合代理流量与脚本做分析。 +- **测试强度**:在授权范围内力求充分覆盖攻击面;不要浅尝辄止;自动化无果时进入手工与深度分析;坚持基于证据,避免空泛推断。 +- **评估方法**:先界定范围 → 广度发现攻击面 → 多工具扫描与验证 → 定向利用高影响点 → 迭代 → 结合业务评估影响。 +- **验证**:禁止仅凭假设定论;用请求/响应、命令输出、复现步骤等**证据**支撑;严重性与业务影响挂钩。 +- **利用思路**:由浅入深;标准路径失效时尝试高阶技术;注意漏洞链与组合利用。 +- **价值导向**:优先高影响、可证明的问题;低危信息可合并为路径或背景,避免堆砌无利用价值的条目。 + +## 思考与表达(调用工具前) + +- 在调用工具或发起 task 前,用简短中文说明:**当前子目标、为何选该工具/子代理、与上文结果如何衔接、期望得到什么**,约 2~6 句即可(避免一句话或冗长散文)。 +- 面向用户的最终回复应**结构清晰**(标题、列表、步骤),便于复制与复核。 + +## 工具与 MCP + +- **工具失败**:读懂错误原因;修正参数重试;换替代工具;有局部收获则继续推进;确不可行时向用户说明并给替代方案;勿因单次失败放弃整体任务。 +- **漏洞记录**:发现**有效漏洞**时,必须使用 **`record_vulnerability`** 记录(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度使用 critical / high / medium / low / info。记录后可在授权范围内继续测试。 +- **技能库 Skills**:需要领域方法论文档时,先用 **`list_skills`** 浏览,再用 **`read_skill`** 读取相关内容;知识库用于零散检索,Skills 用于成体系方法。子代理若具备相同工具,也可在委派说明中提示其按需读取。 + +## 与子代理的分工原则 + +- 子代理适合:**上下文隔离的长任务、重复试错、专项角色**;你适合:**全局策略、合并结论、对用户承诺式答复、跨子任务的一致性检查**。 +- 若子代理结果不完整或相互矛盾,由你发起补充 task 或亲自补测,直到在授权与范围内给出自洽结论。 diff --git a/agents/penetration.md b/agents/penetration.md new file mode 100644 index 00000000..1f19eb43 --- /dev/null +++ b/agents/penetration.md @@ -0,0 +1,13 @@ +--- +id: penetration +name: 渗透测试专员 +description: 授权范围内的漏洞验证、利用链构造、权限提升与影响证明;在得到侦察/情报输入后做深度利用与复现。 +tools: [] +max_iterations: 0 +--- + +你是授权渗透测试中的**渗透与利用**子代理。在明确范围与目标前提下,进行漏洞验证、利用链分析、权限提升路径与业务影响说明。 + +- 以证据为中心:请求/响应、Payload、命令输出、截图说明等,便于审计与复现。 +- 先确认边界与禁止项(如拒绝 DoS、数据破坏);发现有效漏洞时按协调者要求使用 `record_vulnerability` 等流程(若你的工具集中包含)。 +- 输出包含:攻击路径摘要、关键步骤、影响评估、修复与缓解建议;语言简洁,便于主代理汇总。 diff --git a/agents/recon.md b/agents/recon.md new file mode 100644 index 00000000..fd9b0c78 --- /dev/null +++ b/agents/recon.md @@ -0,0 +1,9 @@ +--- +id: recon +name: 侦察专员 +description: 负责信息收集、资产测绘与初始攻击面分析。 +tools: [] +max_iterations: 0 +--- + +你是授权渗透测试流程中的侦察子代理。优先使用工具收集事实,避免无根据推测;输出简洁,便于协调者汇总。 diff --git a/web/static/css/style.css b/web/static/css/style.css index da63d94c..090e97fb 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -10915,6 +10915,75 @@ header { flex-shrink: 0; } +.agent-mode-wrapper { + display: flex; + align-items: center; + flex-shrink: 0; +} + +/* 与角色选择器共用 .role-selector-btn;此处仅包一层用于定位浮层 */ +.agent-mode-inner { + position: relative; + flex-shrink: 0; +} + +/* 单/多代理面板:与「选择角色」浮层同一视觉语言(白底、圆角、阴影) */ +.agent-mode-panel { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + width: 300px; + max-width: calc(100vw - 32px); + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + padding: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + z-index: 1000; + display: flex; + flex-direction: column; + animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1); + backdrop-filter: blur(20px); + text-align: left; +} + +.agent-mode-panel-header { + margin-bottom: 8px; + text-align: left; +} + +.agent-mode-panel .role-selection-panel-title { + text-align: left; +} + +.agent-mode-options { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* 选项为 ' + + '' + + '' + ); + }).join(''); + } catch (e) { + console.error(e); + listEl.innerHTML = '
' + escapeHtml(e.message || String(e)) + '
'; + showNotification(_agentsT('agentsPage.loadFailed') + ': ' + e.message, 'error'); + } +} + +function showAddMarkdownAgentModal() { + markdownAgentsEditingFilename = null; + markdownAgentsEditingIsOrchestrator = false; + const modal = document.getElementById('agent-md-modal'); + const title = document.getElementById('agent-md-modal-title'); + const row = document.getElementById('agent-md-filename-row'); + if (title) title.textContent = _agentsT('agentsPage.createTitle'); + if (row) row.style.display = ''; + document.getElementById('agent-md-filename-current').value = ''; + document.getElementById('agent-md-filename').value = ''; + document.getElementById('agent-md-filename').disabled = false; + var roleEl = document.getElementById('agent-md-role'); + if (roleEl) roleEl.value = 'sub'; + document.getElementById('agent-md-id').value = ''; + document.getElementById('agent-md-name').value = ''; + document.getElementById('agent-md-description').value = ''; + document.getElementById('agent-md-tools').value = ''; + document.getElementById('agent-md-bind-role').value = ''; + document.getElementById('agent-md-max-iter').value = '0'; + document.getElementById('agent-md-instruction').value = ''; + if (modal) modal.style.display = 'flex'; +} + +async function editMarkdownAgent(filename) { + if (!filename) return; + const modal = document.getElementById('agent-md-modal'); + const title = document.getElementById('agent-md-modal-title'); + const row = document.getElementById('agent-md-filename-row'); + markdownAgentsEditingFilename = null; + markdownAgentsEditingIsOrchestrator = false; + if (title) title.textContent = _agentsT('agentsPage.editTitle'); + if (row) row.style.display = 'none'; + try { + const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename)); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || r.statusText); + markdownAgentsEditingFilename = data.filename || filename; + markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator; + document.getElementById('agent-md-filename-current').value = data.filename || filename; + document.getElementById('agent-md-filename').value = data.filename || filename; + document.getElementById('agent-md-filename').disabled = true; + var roleEl2 = document.getElementById('agent-md-role'); + if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub'; + document.getElementById('agent-md-id').value = data.id || ''; + document.getElementById('agent-md-name').value = data.name || ''; + document.getElementById('agent-md-description').value = data.description || ''; + document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : ''; + document.getElementById('agent-md-bind-role').value = data.bind_role || ''; + document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0); + document.getElementById('agent-md-instruction').value = data.instruction || ''; + if (modal) modal.style.display = 'flex'; + } catch (e) { + showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error'); + } +} + +function closeMarkdownAgentModal() { + const modal = document.getElementById('agent-md-modal'); + if (modal) modal.style.display = 'none'; + markdownAgentsEditingFilename = null; + markdownAgentsEditingIsOrchestrator = false; +} + +function parseToolsInput(s) { + if (!s || !String(s).trim()) return []; + return String(s).split(/[,;|]/).map(function (x) { return x.trim(); }).filter(Boolean); +} + +async function saveMarkdownAgent() { + const name = document.getElementById('agent-md-name').value.trim(); + if (!name) { + showNotification(_agentsT('agentsPage.nameRequired'), 'error'); + return; + } + const roleSel = document.getElementById('agent-md-role'); + const roleVal = roleSel ? roleSel.value : 'sub'; + const fnDraft = (document.getElementById('agent-md-filename') && document.getElementById('agent-md-filename').value.trim().toLowerCase()) || ''; + const isOrchestratorAgent = markdownAgentsEditingIsOrchestrator || + roleVal === 'orchestrator' || + fnDraft === 'orchestrator.md'; + const instruction = document.getElementById('agent-md-instruction').value.trim(); + if (!isOrchestratorAgent && !instruction) { + showNotification(_agentsT('agentsPage.instructionRequired'), 'error'); + return; + } + const body = { + id: document.getElementById('agent-md-id').value.trim(), + name: name, + description: document.getElementById('agent-md-description').value.trim(), + tools: parseToolsInput(document.getElementById('agent-md-tools').value), + instruction: instruction, + bind_role: document.getElementById('agent-md-bind-role').value.trim(), + max_iterations: parseInt(document.getElementById('agent-md-max-iter').value, 10) || 0, + kind: roleVal === 'orchestrator' ? 'orchestrator' : '' + }; + const isEdit = !!markdownAgentsEditingFilename; + let url; + let method; + if (isEdit) { + url = '/api/multi-agent/markdown-agents/' + encodeURIComponent(markdownAgentsEditingFilename); + method = 'PUT'; + } else { + url = '/api/multi-agent/markdown-agents'; + method = 'POST'; + const fn = document.getElementById('agent-md-filename').value.trim(); + if (fn && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.md$/.test(fn)) { + showNotification(_agentsT('agentsPage.filenameInvalid'), 'error'); + return; + } + body.filename = fn; + } + try { + const r = await apiFetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await r.json().catch(function () { return {}; }); + if (!r.ok) throw new Error(data.error || r.statusText); + showNotification(isEdit ? _agentsT('agentsPage.saveOk') : _agentsT('agentsPage.createOk'), 'success'); + closeMarkdownAgentModal(); + await loadMarkdownAgents(); + } catch (e) { + showNotification(_agentsT('agentsPage.saveFailed') + ': ' + e.message, 'error'); + } +} + +async function deleteMarkdownAgent(filename) { + if (!filename) return; + if (!confirm(_agentsT('agentsPage.deleteConfirm', { name: filename }))) return; + try { + const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename), { method: 'DELETE' }); + const data = await r.json().catch(function () { return {}; }); + if (!r.ok) throw new Error(data.error || r.statusText); + showNotification(_agentsT('agentsPage.deleteOk'), 'success'); + await loadMarkdownAgents(); + } catch (e) { + showNotification(_agentsT('agentsPage.deleteFailed') + ': ' + e.message, 'error'); + } +} diff --git a/web/static/js/chat.js b/web/static/js/chat.js index f50870b6..a55f28cf 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -28,6 +28,108 @@ const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。' /** @type {{ fileName: string, content: string, mimeType: string }[]} */ let chatAttachments = []; +// 多代理(Eino):需后端 multi_agent.enabled,与单代理 /agent-loop 并存 +const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode'; +let multiAgentAPIEnabled = false; + +function getAgentModeLabelForValue(mode) { + if (typeof window.t === 'function') { + return mode === 'multi' ? window.t('chat.agentModeMulti') : window.t('chat.agentModeSingle'); + } + return mode === 'multi' ? '多代理' : '单代理'; +} + +function getAgentModeIconForValue(mode) { + return mode === 'multi' ? '🧩' : '🤖'; +} + +function syncAgentModeFromValue(value) { + const hid = document.getElementById('agent-mode-select'); + const label = document.getElementById('agent-mode-text'); + const icon = document.getElementById('agent-mode-icon'); + if (hid) hid.value = value; + if (label) label.textContent = getAgentModeLabelForValue(value); + if (icon) icon.textContent = getAgentModeIconForValue(value); + document.querySelectorAll('.agent-mode-option').forEach(function (el) { + const v = el.getAttribute('data-value'); + el.classList.toggle('selected', v === value); + }); +} + +function closeAgentModePanel() { + const panel = document.getElementById('agent-mode-panel'); + const btn = document.getElementById('agent-mode-btn'); + if (panel) panel.style.display = 'none'; + if (btn) { + btn.classList.remove('active'); + btn.setAttribute('aria-expanded', 'false'); + } +} + +function toggleAgentModePanel() { + const panel = document.getElementById('agent-mode-panel'); + const btn = document.getElementById('agent-mode-btn'); + if (!panel || !btn) return; + const isOpen = panel.style.display === 'flex'; + if (isOpen) { + closeAgentModePanel(); + return; + } + if (typeof closeRoleSelectionPanel === 'function') { + closeRoleSelectionPanel(); + } + panel.style.display = 'flex'; + btn.classList.add('active'); + btn.setAttribute('aria-expanded', 'true'); +} + +function selectAgentMode(mode) { + if (mode !== 'single' && mode !== 'multi') return; + try { + localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode); + } catch (e) { /* ignore */ } + syncAgentModeFromValue(mode); + closeAgentModePanel(); +} + +async function initChatAgentModeFromConfig() { + try { + const r = await apiFetch('/api/config'); + if (!r.ok) return; + const cfg = await r.json(); + multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled); + if (typeof window !== 'undefined') { + window.__csaiMultiAgentPublic = cfg.multi_agent || null; + } + const wrap = document.getElementById('agent-mode-wrapper'); + const sel = document.getElementById('agent-mode-select'); + if (!wrap || !sel) return; + if (!multiAgentAPIEnabled) { + wrap.style.display = 'none'; + return; + } + wrap.style.display = ''; + const def = (cfg.multi_agent && cfg.multi_agent.default_mode === 'multi') ? 'multi' : 'single'; + let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY); + if (stored !== 'single' && stored !== 'multi') { + stored = def; + } + sel.value = stored; + syncAgentModeFromValue(stored); + } catch (e) { + console.warn('initChatAgentModeFromConfig', e); + } +} + +document.addEventListener('languagechange', function () { + const hid = document.getElementById('agent-mode-select'); + if (!hid) return; + const v = hid.value; + if (v === 'single' || v === 'multi') { + syncAgentModeFromValue(v); + } +}); + // 保存输入框草稿到localStorage(防抖版本) function saveChatDraftDebounced(content) { // 清除之前的定时器 @@ -191,7 +293,10 @@ async function sendMessage() { let mcpExecutionIds = []; try { - const response = await apiFetch('/api/agent-loop/stream', { + const modeSel = document.getElementById('agent-mode-select'); + const useMulti = multiAgentAPIEnabled && modeSel && modeSel.value === 'multi'; + const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/agent-loop/stream'; + const response = await apiFetch(streamPath, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1249,8 +1354,8 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr } catch (e) { /* ignore */ } contentWrapper.appendChild(timeDiv); - // 如果有MCP执行ID或进度ID,添加查看详情区域(统一使用"渗透测试详情"样式) - if (role === 'assistant' && ((mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) || progressId)) { + // 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建) + if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) { const mcpSection = document.createElement('div'); mcpSection.className = 'mcp-call-section'; @@ -1262,29 +1367,14 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'mcp-call-buttons'; - // 如果有MCP执行ID,添加MCP调用详情按钮 - if (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) { - mcpExecutionIds.forEach((execId, index) => { - const detailBtn = document.createElement('button'); - detailBtn.className = 'mcp-detail-btn'; - detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; - detailBtn.onclick = () => showMCPDetail(execId); - buttonsContainer.appendChild(detailBtn); - // 异步获取工具名称并更新按钮文本 - updateButtonWithToolName(detailBtn, execId, index + 1); - }); - } - - // 如果有进度ID,添加展开详情按钮(统一使用"展开详情"文本) - if (progressId) { - const progressDetailBtn = document.createElement('button'); - progressDetailBtn.className = 'mcp-detail-btn process-detail-btn'; - progressDetailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + ''; - progressDetailBtn.onclick = () => toggleProcessDetails(progressId, messageDiv.id); - buttonsContainer.appendChild(progressDetailBtn); - // 存储进度ID到消息元素 - messageDiv.dataset.progressId = progressId; - } + mcpExecutionIds.forEach((execId, index) => { + const detailBtn = document.createElement('button'); + detailBtn.className = 'mcp-detail-btn'; + detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; + detailBtn.onclick = () => showMCPDetail(execId); + buttonsContainer.appendChild(detailBtn); + updateButtonWithToolName(detailBtn, execId, index + 1); + }); mcpSection.appendChild(buttonsContainer); contentWrapper.appendChild(mcpSection); @@ -1461,34 +1551,44 @@ function renderProcessDetails(messageId, processDetails) { timeline.innerHTML = ''; + function processDetailAgentPrefix(d) { + if (!d || d.einoAgent == null) return ''; + const s = String(d.einoAgent).trim(); + return s ? ('[' + s + '] ') : ''; + } + // 渲染每个过程详情事件 processDetails.forEach(detail => { const eventType = detail.eventType || ''; const title = detail.message || ''; const data = detail.data || {}; + const agPx = processDetailAgentPrefix(data); // 根据事件类型渲染不同的内容 let itemTitle = title; if (eventType === 'iteration') { - itemTitle = (typeof window.t === 'function' ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : '第 ' + (data.iteration || 1) + ' 轮迭代'); + itemTitle = agPx + (typeof window.t === 'function' ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : '第 ' + (data.iteration || 1) + ' 轮迭代'); } else if (eventType === 'thinking') { - itemTitle = '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'); + itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'); } else if (eventType === 'tool_calls_detected') { - itemTitle = '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : '检测到 ' + (data.count || 0) + ' 个工具调用'); + itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : '检测到 ' + (data.count || 0) + ' 个工具调用'); } else if (eventType === 'tool_call') { const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'); const index = data.index || 0; const total = data.total || 0; - itemTitle = '🔧 ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')'); + itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')'); } else if (eventType === 'tool_result') { const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'); const success = data.success !== false; const statusIcon = success ? '✅' : '❌'; const execText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行失败'); - itemTitle = statusIcon + ' ' + execText; + let execLine = statusIcon + ' ' + execText; if (toolName === BuiltinTools.SEARCH_KNOWLEDGE_BASE && success) { - itemTitle = '📚 ' + itemTitle + ' - ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrievalTag') : '知识检索'); + execLine = '📚 ' + execLine + ' - ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrievalTag') : '知识检索'); } + itemTitle = agPx + execLine; + } else if (eventType === 'eino_agent_reply') { + itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'); } else if (eventType === 'knowledge_retrieval') { itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索'); } else if (eventType === 'error') { @@ -5497,9 +5597,10 @@ document.addEventListener('DOMContentLoaded', function() { } }); } + initChatAgentModeFromConfig(); }); -// 点击外部关闭图标选择器 +// 点击外部关闭图标选择器、对话模式面板 document.addEventListener('click', function(event) { const picker = document.getElementById('group-icon-picker'); const iconBtn = document.getElementById('create-group-icon-btn'); @@ -5509,6 +5610,14 @@ document.addEventListener('click', function(event) { picker.style.display = 'none'; } } + + const agentWrap = document.getElementById('agent-mode-wrapper'); + const agentPanel = document.getElementById('agent-mode-panel'); + if (agentWrap && agentPanel && agentPanel.style.display === 'flex') { + if (!agentWrap.contains(event.target)) { + closeAgentModePanel(); + } + } }); // 创建分组 diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index ef55face..04d09505 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -38,6 +38,7 @@ function translateProgressMessage(message) { '达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary', '正在分析您的请求...': 'progress.analyzingRequestShort', '开始分析请求并制定测试策略': 'progress.analyzingRequestPlanning', + '正在启动 Eino DeepAgent...': 'progress.startingEinoDeepAgent', // 英文(与 en-US.json 一致,避免后端/缓存已是英文时无法随语言切换) 'Calling AI model...': 'progress.callingAI', 'Last iteration: generating summary and next steps...': 'progress.lastIterSummary', @@ -45,9 +46,15 @@ function translateProgressMessage(message) { 'Generating final reply...': 'progress.generatingFinalReply', 'Max iterations reached, generating summary...': 'progress.maxIterSummary', 'Analyzing your request...': 'progress.analyzingRequestShort', - 'Analyzing your request and planning test strategy...': 'progress.analyzingRequestPlanning' + 'Analyzing your request and planning test strategy...': 'progress.analyzingRequestPlanning', + 'Starting Eino DeepAgent...': 'progress.startingEinoDeepAgent' }; if (map[trim]) return window.t(map[trim]); + const einoAgentRe = /^\[Eino\]\s*(.+)$/; + const einoM = trim.match(einoAgentRe); + if (einoM) { + return window.t('progress.einoAgent', { name: einoM[1] }); + } const callingToolPrefixCn = '正在调用工具: '; const callingToolPrefixEn = 'Calling tool: '; if (trim.indexOf(callingToolPrefixCn) === 0) { @@ -73,12 +80,22 @@ const responseStreamStateByProgressId = new Map(); // AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer }) const thinkingStreamStateByProgressId = new Map(); +// Eino 子代理回复流式:progressId -> Map(streamId -> { itemId, buffer }) +const einoAgentReplyStreamStateByProgressId = new Map(); + // 工具输出流式增量:progressId::toolCallId -> { itemId, buffer } const toolResultStreamStateByKey = new Map(); function toolResultStreamKey(progressId, toolCallId) { return String(progressId) + '::' + String(toolCallId); } +/** Eino 多代理:时间线标题前加 [agentId],标明哪一代理产生该工具调用/结果/回复 */ +function timelineAgentBracketPrefix(data) { + if (!data || data.einoAgent == null) return ''; + const s = String(data.einoAgent).trim(); + return s ? ('[' + s + '] ') : ''; +} + // markdown 渲染(用于最终合并渲染;流式增量阶段用纯转义避免部分语法不稳定) const assistantMarkdownSanitizeConfig = { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'], @@ -267,6 +284,15 @@ function toggleProgressDetails(progressId) { } } +// 编排器开始输出最终回复时隐藏整条进度消息(迭代阶段保持展开可见;此处整行收起而非仅折叠时间线) +function hideProgressMessageForFinalReply(progressId) { + if (!progressId) return; + const el = document.getElementById(progressId); + if (el) { + el.style.display = 'none'; + } +} + // 折叠所有进度详情 function collapseAllProgressDetails(assistantMessageId, progressId) { // 折叠集成到MCP区域的详情 @@ -323,10 +349,12 @@ function getAssistantId() { return null; } -// 将进度详情集成到工具调用区域 -function integrateProgressToMCPSection(progressId, assistantMessageId) { +// 将进度详情集成到工具调用区域(流式阶段助手消息不挂 mcp 条,结束时在此创建,避免图二整行 MCP 芯片样式) +function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecutionIds) { const progressElement = document.getElementById(progressId); if (!progressElement) return; + + const mcpIds = Array.isArray(mcpExecutionIds) ? mcpExecutionIds : []; // 获取时间线内容 const timeline = document.getElementById(progressId + '-timeline'); @@ -341,15 +369,28 @@ function integrateProgressToMCPSection(progressId, assistantMessageId) { removeMessage(progressId); return; } - - // 查找MCP调用区域 - const mcpSection = assistantElement.querySelector('.mcp-call-section'); - if (!mcpSection) { - // 如果没有MCP区域,创建详情组件放在消息下方 - convertProgressToDetails(progressId, assistantMessageId); + + const contentWrapper = assistantElement.querySelector('.message-content'); + if (!contentWrapper) { + removeMessage(progressId); return; } + // 查找或创建 MCP 区域 + let mcpSection = assistantElement.querySelector('.mcp-call-section'); + if (!mcpSection) { + mcpSection = document.createElement('div'); + mcpSection.className = 'mcp-call-section'; + const mcpLabel = document.createElement('div'); + mcpLabel.className = 'mcp-call-label'; + mcpLabel.textContent = '📋 ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情'); + mcpSection.appendChild(mcpLabel); + const buttonsContainerInit = document.createElement('div'); + buttonsContainerInit.className = 'mcp-call-buttons'; + mcpSection.appendChild(buttonsContainerInit); + contentWrapper.appendChild(mcpSection); + } + // 获取时间线内容 const hasContent = timelineHTML.trim().length > 0; @@ -363,6 +404,27 @@ function integrateProgressToMCPSection(progressId, assistantMessageId) { buttonsContainer.className = 'mcp-call-buttons'; mcpSection.appendChild(buttonsContainer); } + + const hasExecBtns = buttonsContainer.querySelector('.mcp-detail-btn:not(.process-detail-btn)'); + if (mcpIds.length > 0 && !hasExecBtns) { + mcpIds.forEach((execId, index) => { + const detailBtn = document.createElement('button'); + detailBtn.className = 'mcp-detail-btn'; + detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; + detailBtn.onclick = () => showMCPDetail(execId); + buttonsContainer.appendChild(detailBtn); + if (typeof updateButtonWithToolName === 'function') { + updateButtonWithToolName(detailBtn, execId, index + 1); + } + }); + } + if (!buttonsContainer.querySelector('.process-detail-btn')) { + const progressDetailBtn = document.createElement('button'); + progressDetailBtn.className = 'mcp-detail-btn process-detail-btn'; + progressDetailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + ''; + progressDetailBtn.onclick = () => toggleProcessDetails(null, assistantMessageId); + buttonsContainer.appendChild(progressDetailBtn); + } // 创建详情容器,放在MCP按钮区域下方(统一结构) const detailsId = 'process-details-' + assistantMessageId; @@ -623,7 +685,8 @@ function handleStreamEvent(event, progressElement, progressId, thinkingStreamStateByProgressId.set(progressId, state); } // 若已存在,重置 buffer - const title = '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'); + const thinkBase = typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'; + const title = timelineAgentBracketPrefix(d) + '🤔 ' + thinkBase; const itemId = addTimelineItem(timeline, 'thinking', { title: title, message: ' ', @@ -684,7 +747,7 @@ function handleStreamEvent(event, progressElement, progressId, } addTimelineItem(timeline, 'thinking', { - title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'), + title: timelineAgentBracketPrefix(event.data) + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'), message: event.message, data: event.data }); @@ -692,7 +755,15 @@ function handleStreamEvent(event, progressElement, progressId, case 'tool_calls_detected': addTimelineItem(timeline, 'tool_calls_detected', { - title: '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'), + title: timelineAgentBracketPrefix(event.data) + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'), + message: event.message, + data: event.data + }); + break; + + case 'warning': + addTimelineItem(timeline, 'warning', { + title: '⚠️', message: event.message, data: event.data }); @@ -706,7 +777,7 @@ function handleStreamEvent(event, progressElement, progressId, const toolCallId = toolInfo.toolCallId || null; const toolCallTitle = typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')'; const toolCallItemId = addTimelineItem(timeline, 'tool_call', { - title: '🔧 ' + toolCallTitle, + title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle, message: event.message, data: toolInfo, expanded: false @@ -738,7 +809,7 @@ function handleStreamEvent(event, progressElement, progressId, if (!state) { // 首次增量:创建一个 tool_result 占位条目,后续不断更新 pre 内容 const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...'; - const title = '⏳ ' + (typeof window.t === 'function' + const title = timelineAgentBracketPrefix(deltaInfo) + '⏳ ' + (typeof window.t === 'function' ? window.t('timeline.running') : runningLabel) + ' ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtmlLocal(toolNameDelta), index: deltaInfo.index || 0, total: deltaInfo.total || 0 }) : toolNameDelta); @@ -753,7 +824,9 @@ function handleStreamEvent(event, progressElement, progressId, toolCallId: toolCallId, index: deltaInfo.index, total: deltaInfo.total, - iteration: deltaInfo.iteration + iteration: deltaInfo.iteration, + einoAgent: deltaInfo.einoAgent, + source: deltaInfo.source }, expanded: false }); @@ -799,7 +872,10 @@ function handleStreamEvent(event, progressElement, progressId, const titleEl = item.querySelector('.timeline-item-title'); if (titleEl) { - titleEl.textContent = statusIcon + ' ' + resultExecText; + if (resultInfo.einoAgent != null && String(resultInfo.einoAgent).trim() !== '') { + item.dataset.einoAgent = String(resultInfo.einoAgent).trim(); + } + titleEl.textContent = timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText; } } toolResultStreamStateByKey.delete(key); @@ -818,12 +894,112 @@ function handleStreamEvent(event, progressElement, progressId, toolCallStatusMap.delete(resultToolCallId); } addTimelineItem(timeline, 'tool_result', { - title: statusIcon + ' ' + resultExecText, + title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText, message: event.message, data: resultInfo, expanded: false }); break; + + case 'eino_agent_reply_stream_start': { + const d = event.data || {}; + const streamId = d.streamId || null; + if (!streamId) break; + let stateMap = einoAgentReplyStreamStateByProgressId.get(progressId); + if (!stateMap) { + stateMap = new Map(); + einoAgentReplyStreamStateByProgressId.set(progressId, stateMap); + } + const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...'; + const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; + const itemId = addTimelineItem(timeline, 'eino_agent_reply', { + title: timelineAgentBracketPrefix(d) + '💬 ' + replyTitleBase + ' · ' + streamingLabel, + message: ' ', + data: d, + expanded: false + }); + stateMap.set(streamId, { itemId, buffer: '' }); + break; + } + + case 'eino_agent_reply_stream_delta': { + const d = event.data || {}; + const streamId = d.streamId || null; + if (!streamId) break; + const delta = event.message || ''; + if (!delta) break; + const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId); + if (!stateMap || !stateMap.has(streamId)) break; + const s = stateMap.get(streamId); + s.buffer += delta; + const item = document.getElementById(s.itemId); + if (item) { + let contentEl = item.querySelector('.timeline-item-content'); + if (!contentEl) { + const header = item.querySelector('.timeline-item-header'); + if (header) { + contentEl = document.createElement('div'); + contentEl.className = 'timeline-item-content'; + item.appendChild(contentEl); + } + } + if (contentEl) { + if (typeof formatMarkdown === 'function') { + contentEl.innerHTML = formatMarkdown(s.buffer); + } else { + contentEl.textContent = s.buffer; + } + } + } + break; + } + + case 'eino_agent_reply_stream_end': { + const d = event.data || {}; + const streamId = d.streamId || null; + const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId); + if (streamId && stateMap && stateMap.has(streamId)) { + const s = stateMap.get(streamId); + const full = (event.message != null && event.message !== '') ? String(event.message) : s.buffer; + s.buffer = full; + const item = document.getElementById(s.itemId); + if (item) { + const titleEl = item.querySelector('.timeline-item-title'); + if (titleEl) { + const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; + titleEl.textContent = timelineAgentBracketPrefix(d) + '💬 ' + replyTitleBase; + } + let contentEl = item.querySelector('.timeline-item-content'); + if (!contentEl) { + contentEl = document.createElement('div'); + contentEl.className = 'timeline-item-content'; + item.appendChild(contentEl); + } + if (typeof formatMarkdown === 'function') { + contentEl.innerHTML = formatMarkdown(full); + } else { + contentEl.textContent = full; + } + if (d.einoAgent != null && String(d.einoAgent).trim() !== '') { + item.dataset.einoAgent = String(d.einoAgent).trim(); + } + } + stateMap.delete(streamId); + } + break; + } + + case 'eino_agent_reply': { + const replyData = event.data || {}; + const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; + addTimelineItem(timeline, 'eino_agent_reply', { + title: timelineAgentBracketPrefix(replyData) + '💬 ' + replyTitleBase, + message: event.message || '', + data: replyData, + expanded: false + }); + break; + } case 'progress': const progressTitle = document.querySelector(`#${progressId} .progress-title`); @@ -880,7 +1056,7 @@ function handleStreamEvent(event, progressElement, progressId, if (assistantElement) { const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { - integrateProgressToMCPSection(progressId, assistantId); + integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []); } // 立即折叠详情(取消时应该默认折叠) setTimeout(() => { @@ -894,7 +1070,7 @@ function handleStreamEvent(event, progressElement, progressId, // 将进度详情集成到工具调用区域 setTimeout(() => { - integrateProgressToMCPSection(progressId, assistantId); + integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []); // 确保详情默认折叠 collapseAllProgressDetails(assistantId, progressId); }, 100); @@ -925,6 +1101,9 @@ function handleStreamEvent(event, progressElement, progressId, loadActiveTasks(); } + // 主回复开始流式输出时隐藏整条进度卡片(迭代阶段默认展开;最终回复时不再占屏) + hideProgressMessageForFinalReply(progressId); + // 已存在则复用;否则创建空助手消息占位,用于增量追加 const existing = responseStreamStateByProgressId.get(progressId); if (existing && existing.assistantId) break; @@ -947,6 +1126,8 @@ function handleStreamEvent(event, progressElement, progressId, } } + hideProgressMessageForFinalReply(progressId); + let state = responseStreamStateByProgressId.get(progressId); if (!state || !state.assistantId) { const mcpIds = responseData.mcpExecutionIds || []; @@ -999,7 +1180,7 @@ function handleStreamEvent(event, progressElement, progressId, } // 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整) - integrateProgressToMCPSection(progressId, assistantIdFinal); + integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds); responseStreamStateByProgressId.delete(progressId); setTimeout(() => { @@ -1059,7 +1240,7 @@ function handleStreamEvent(event, progressElement, progressId, if (assistantElement) { const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { - integrateProgressToMCPSection(progressId, assistantId); + integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []); } // 立即折叠详情(错误时应该默认折叠) setTimeout(() => { @@ -1073,7 +1254,7 @@ function handleStreamEvent(event, progressElement, progressId, // 将进度详情集成到工具调用区域 setTimeout(() => { - integrateProgressToMCPSection(progressId, assistantId); + integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []); // 确保详情默认折叠 collapseAllProgressDetails(assistantId, progressId); }, 100); @@ -1087,6 +1268,7 @@ function handleStreamEvent(event, progressElement, progressId, // 清理流式输出状态 responseStreamStateByProgressId.delete(progressId); thinkingStreamStateByProgressId.delete(progressId); + einoAgentReplyStreamStateByProgressId.delete(progressId); // 清理工具流式输出占位 const prefix = String(progressId) + '::'; for (const key of Array.from(toolResultStreamStateByKey.keys())) { @@ -1213,6 +1395,9 @@ function addTimelineItem(timeline, type, options) { item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : ''; item.dataset.toolSuccess = d.success !== false ? '1' : '0'; } + if (options.data && options.data.einoAgent != null && String(options.data.einoAgent).trim() !== '') { + item.dataset.einoAgent = String(options.data.einoAgent).trim(); + } // 使用传入的createdAt时间,如果没有则使用当前时间(向后兼容) let eventTime; @@ -1253,7 +1438,17 @@ function addTimelineItem(timeline, type, options) { content += `
${formatMarkdown(options.message)}
`; } else if (type === 'tool_call' && options.data) { const data = options.data; - const args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : {}); + let args = data.argumentsObj; + if (args == null && data.arguments != null && String(data.arguments).trim() !== '') { + try { + args = JSON.parse(String(data.arguments)); + } catch (e) { + args = { _raw: String(data.arguments) }; + } + } + if (args == null || typeof args !== 'object') { + args = {}; + } const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:'; content += `
@@ -1265,6 +1460,8 @@ function addTimelineItem(timeline, type, options) {
`; + } else if (type === 'eino_agent_reply' && options.message) { + content += `
${formatMarkdown(options.message)}
`; } else if (type === 'tool_result' && options.data) { const data = options.data; const isError = data.isError || !data.success; @@ -2094,24 +2291,27 @@ function refreshProgressAndTimelineI18n() { const titleSpan = item.querySelector('.timeline-item-title'); const timeSpan = item.querySelector('.timeline-item-time'); if (!titleSpan) return; + const ap = (item.dataset.einoAgent && item.dataset.einoAgent !== '') ? ('[' + item.dataset.einoAgent + '] ') : ''; if (type === 'iteration' && item.dataset.iterationN) { const n = parseInt(item.dataset.iterationN, 10) || 1; - titleSpan.textContent = _t('chat.iterationRound', { n: n }); + titleSpan.textContent = ap + _t('chat.iterationRound', { n: n }); } else if (type === 'thinking') { - titleSpan.textContent = '\uD83E\uDD14 ' + _t('chat.aiThinking'); + titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking'); } else if (type === 'tool_calls_detected' && item.dataset.toolCallsCount != null) { const count = parseInt(item.dataset.toolCallsCount, 10) || 0; - titleSpan.textContent = '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count }); + titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count }); } else if (type === 'tool_call' && (item.dataset.toolName !== undefined || item.dataset.toolIndex !== undefined)) { const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool'); const index = parseInt(item.dataset.toolIndex, 10) || 0; const total = parseInt(item.dataset.toolTotal, 10) || 0; - titleSpan.textContent = '\uD83D\uDD27 ' + _t('chat.callTool', { name: name, index: index, total: total }); + titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.callTool', { name: name, index: index, total: total }); } else if (type === 'tool_result' && (item.dataset.toolName !== undefined || item.dataset.toolSuccess !== undefined)) { const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool'); const success = item.dataset.toolSuccess === '1'; const icon = success ? '\u2705 ' : '\u274C '; - titleSpan.textContent = icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })); + titleSpan.textContent = ap + icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })); + } else if (type === 'eino_agent_reply') { + titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle'); } else if (type === 'cancelled') { titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled'); } else if (type === 'progress' && item.dataset.progressMessage !== undefined) { diff --git a/web/static/js/roles.js b/web/static/js/roles.js index 65d0e65b..a65817da 100644 --- a/web/static/js/roles.js +++ b/web/static/js/roles.js @@ -210,6 +210,9 @@ function toggleRoleSelectionPanel() { const isHidden = panel.style.display === 'none' || !panel.style.display; if (isHidden) { + if (typeof closeAgentModePanel === 'function') { + closeAgentModePanel(); + } panel.style.display = 'flex'; // 使用flex布局 // 添加打开状态的视觉反馈 if (roleSelectorBtn) { diff --git a/web/static/js/router.js b/web/static/js/router.js index 3489ed50..14f0f1fd 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -8,7 +8,7 @@ function initRouter() { if (hash) { const hashParts = hash.split('?'); const pageId = hashParts[0]; - if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks'].includes(pageId)) { switchPage(pageId); // 如果是chat页面且带有conversation参数,加载对应对话 @@ -107,6 +107,16 @@ function updateNavState(pageId) { skillsItem.classList.add('expanded'); } + const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`); + if (submenuItem) { + submenuItem.classList.add('active'); + } + } else if (pageId === 'agents-management') { + const agentsItem = document.querySelector('.nav-item[data-page="agents"]'); + if (agentsItem) { + agentsItem.classList.add('active'); + agentsItem.classList.add('expanded'); + } const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`); if (submenuItem) { submenuItem.classList.add('active'); @@ -120,19 +130,6 @@ function updateNavState(pageId) { rolesItem.classList.add('expanded'); } - const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`); - if (submenuItem) { - submenuItem.classList.add('active'); - } - } else if (pageId === 'skills-monitor' || pageId === 'skills-management') { - // Skills子菜单项 - const skillsItem = document.querySelector('.nav-item[data-page="skills"]'); - if (skillsItem) { - skillsItem.classList.add('active'); - // 展开Skills子菜单 - skillsItem.classList.add('expanded'); - } - const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`); if (submenuItem) { submenuItem.classList.add('active'); @@ -353,6 +350,11 @@ function initPage(pageId) { loadSkills(); } break; + case 'agents-management': + if (typeof loadMarkdownAgents === 'function') { + loadMarkdownAgents(); + } + break; } // 清理其他页面的定时器 @@ -373,7 +375,7 @@ document.addEventListener('DOMContentLoaded', function() { const hashParts = hash.split('?'); const pageId = hashParts[0]; - if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) { + if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings'].includes(pageId)) { switchPage(pageId); // 如果是chat页面且带有conversation参数,加载对应对话 diff --git a/web/static/js/settings.js b/web/static/js/settings.js index a44d9f13..97ff4272 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -117,6 +117,16 @@ async function loadConfig(loadTools = true) { // 填充Agent配置 document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30; + + const ma = currentConfig.multi_agent || {}; + const maEn = document.getElementById('multi-agent-enabled'); + if (maEn) maEn.checked = ma.enabled === true; + const maMode = document.getElementById('multi-agent-default-mode'); + if (maMode) maMode.value = (ma.default_mode === 'multi') ? 'multi' : 'single'; + const maRobot = document.getElementById('multi-agent-robot-use'); + if (maRobot) maRobot.checked = ma.robot_use_multi_agent === true; + const maBatch = document.getElementById('multi-agent-batch-use'); + if (maBatch) maBatch.checked = ma.batch_use_multi_agent === true; // 填充知识库配置 const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled'); @@ -806,6 +816,12 @@ async function applySettings() { agent: { max_iterations: parseInt(document.getElementById('agent-max-iterations').value) || 30 }, + multi_agent: { + enabled: document.getElementById('multi-agent-enabled')?.checked === true, + default_mode: document.getElementById('multi-agent-default-mode')?.value === 'multi' ? 'multi' : 'single', + robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true, + batch_use_multi_agent: document.getElementById('multi-agent-batch-use')?.checked === true + }, knowledge: knowledgeConfig, robots: { wecom: { diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 3cec486f..3d040667 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -26,6 +26,26 @@ let webshellAiSending = false; // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 let webshellStreamingTypingId = 0; +/** 与主对话页一致:multi_agent.enabled 且本地模式为 multi 时使用 /api/multi-agent/stream */ +function resolveWebshellAiStreamPath() { + if (typeof apiFetch === 'undefined') { + return Promise.resolve('/api/agent-loop/stream'); + } + return apiFetch('/api/config').then(function (r) { + if (!r.ok) return '/api/agent-loop/stream'; + return r.json(); + }).then(function (cfg) { + if (!cfg || !cfg.multi_agent || !cfg.multi_agent.enabled) return '/api/agent-loop/stream'; + var mode = localStorage.getItem('cyberstrike-chat-agent-mode'); + if (mode !== 'single' && mode !== 'multi') { + mode = (cfg.multi_agent.default_mode === 'multi') ? 'multi' : 'single'; + } + return mode === 'multi' ? '/api/multi-agent/stream' : '/api/agent-loop/stream'; + }).catch(function () { + return '/api/agent-loop/stream'; + }); +} + // 从服务端(SQLite)拉取连接列表 function getWebshellConnections() { if (typeof apiFetch === 'undefined') { @@ -316,33 +336,52 @@ function formatWebshellAiConvDate(updatedAt) { return (d.getMonth() + 1) + '/' + d.getDate(); } +function webshellAgentPx(data) { + if (!data || data.einoAgent == null) return ''; + var s = String(data.einoAgent).trim(); + return s ? ('[' + s + '] ') : ''; +} + // 根据后端保存的 processDetail 构建一条时间线项的 HTML(与 appendTimelineItem 展示一致) function buildWebshellTimelineItemFromDetail(detail) { var eventType = detail.eventType || ''; var title = detail.message || ''; var data = detail.data || {}; + var ap = webshellAgentPx(data); if (eventType === 'iteration') { - title = (typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代'); + title = ap + ((typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代')); } else if (eventType === 'thinking') { - title = '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'); + title = ap + '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'); } else if (eventType === 'tool_calls_detected') { - title = '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用')); + title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用')); } else if (eventType === 'tool_call') { var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具'); var idx = data.index || 0; var total = data.total || 0; - title = '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''))); + title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''))); } else if (eventType === 'tool_result') { var success = data.success !== false; var tname = data.toolName || '工具'; - title = (success ? '✅ ' : '❌ ') + ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败'))); + title = ap + (success ? '✅ ' : '❌ ') + ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败'))); + } else if (eventType === 'eino_agent_reply') { + title = ap + '💬 ' + ((typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'); } else if (eventType === 'progress') { title = (typeof window.translateProgressMessage === 'function') ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); } var html = '' + escapeHtml(title || '') + ''; + if (eventType === 'eino_agent_reply' && detail.message) { + html += '
' + escapeHtml(detail.message) + '
'; + } if (eventType === 'tool_call' && data && (data.argumentsObj || data.arguments)) { try { - var args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : null); + var args = data.argumentsObj; + if (args == null && data.arguments != null && String(data.arguments).trim() !== '') { + try { + args = JSON.parse(String(data.arguments)); + } catch (e2) { + args = { _raw: String(data.arguments) }; + } + } if (args && typeof args === 'object') { var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:'; html += '
' + escapeHtml(paramsLabel) + '
' + escapeHtml(JSON.stringify(args, null, 2)) + '
'; @@ -738,7 +777,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { // 工具调用入参 if (type === 'tool_call' && data) { try { - var args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : null); + var args = data.argumentsObj; + if (args == null && data.arguments != null && String(data.arguments).trim() !== '') { + try { + args = JSON.parse(String(data.arguments)); + } catch (e1) { + args = { _raw: String(data.arguments) }; + } + } if (args && typeof args === 'object') { var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:'; html += '
' + @@ -750,6 +796,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { } catch (e) { // JSON 解析失败时忽略参数详情,避免打断主流程 } + } else if (type === 'eino_agent_reply' && message) { + html += '
' + escapeHtml(message) + '
'; } else if (type === 'tool_result' && data) { // 工具调用出参 var isError = data.isError || data.success === false; @@ -777,8 +825,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { timelineContainer.appendChild(item); timelineContainer.classList.add('has-items'); messagesContainer.scrollTop = messagesContainer.scrollHeight; + return item; } + var einoSubReplyStreams = new Map(); + if (inputEl) inputEl.value = ''; var convId = webshellAiConvMap[conn.id] || ''; @@ -792,10 +843,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果) var streamingTypingId = 0; // 防重入,每次新 response 自增 - apiFetch('/api/agent-loop/stream', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + resolveWebshellAiStreamPath().then(function (streamPath) { + return apiFetch(streamPath, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); }).then(function (response) { if (!response.ok) { assistantDiv.textContent = '请求失败: ' + response.status; @@ -873,14 +926,15 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'thinking' && eventData.message) { var thinkLabel = (typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'; - appendTimelineItem('thinking', '🤔 ' + thinkLabel, eventData.message, eventData.data); + var thinkD = eventData.data || {}; + appendTimelineItem('thinking', webshellAgentPx(thinkD) + '🤔 ' + thinkLabel, eventData.message, thinkD); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'tool_calls_detected' && eventData.data) { var count = eventData.data.count || 0; var detectedLabel = (typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: count }) : ('检测到 ' + count + ' 个工具调用'); - appendTimelineItem('tool_calls_detected', '🔧 ' + detectedLabel, eventData.message || '', eventData.data); + appendTimelineItem('tool_calls_detected', webshellAgentPx(eventData.data) + '🔧 ' + detectedLabel, eventData.message || '', eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'tool_call' && eventData.data) { var d = eventData.data; @@ -890,7 +944,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { var callTitle = (typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')); - var title = '🔧 ' + callTitle; + var title = webshellAgentPx(d) + '🔧 ' + callTitle; appendTimelineItem('tool_call', title, eventData.message || '', eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'tool_result' && eventData.data) { @@ -900,10 +954,59 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { var titleText = (typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败')); - var title = (success ? '✅ ' : '❌ ') + titleText; + var title = webshellAgentPx(dr) + (success ? '✅ ' : '❌ ') + titleText; var sub = eventData.message || (dr.result ? String(dr.result).slice(0, 300) : ''); appendTimelineItem('tool_result', title, sub, eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; + } else if (eventData.type === 'eino_agent_reply_stream_start' && eventData.data && eventData.data.streamId) { + var rdS = eventData.data; + var repTS = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; + var runTS = (typeof window.t === 'function') ? window.t('timeline.running') : '执行中...'; + var itemS = document.createElement('div'); + itemS.className = 'webshell-ai-timeline-item webshell-ai-timeline-eino_agent_reply'; + itemS.innerHTML = '' + escapeHtml(webshellAgentPx(rdS) + '💬 ' + repTS + ' · ' + runTS) + ''; + timelineContainer.appendChild(itemS); + timelineContainer.classList.add('has-items'); + einoSubReplyStreams.set(rdS.streamId, { el: itemS, buf: '' }); + if (!streamingTarget) assistantDiv.textContent = '…'; + } else if (eventData.type === 'eino_agent_reply_stream_delta' && eventData.data && eventData.data.streamId) { + var stD = einoSubReplyStreams.get(eventData.data.streamId); + if (stD) { + stD.buf += (eventData.message || ''); + var preD = stD.el.querySelector('.webshell-eino-reply-stream-body'); + if (!preD) { + preD = document.createElement('pre'); + preD.className = 'webshell-ai-timeline-msg webshell-eino-reply-stream-body'; + preD.style.whiteSpace = 'pre-wrap'; + stD.el.appendChild(preD); + } + preD.textContent = stD.buf; + } + if (!streamingTarget) assistantDiv.textContent = '…'; + } else if (eventData.type === 'eino_agent_reply_stream_end' && eventData.data && eventData.data.streamId) { + var stE = einoSubReplyStreams.get(eventData.data.streamId); + if (stE) { + var fullE = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : stE.buf; + stE.buf = fullE; + var repTE = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; + var titE = stE.el.querySelector('.webshell-ai-timeline-title'); + if (titE) titE.textContent = webshellAgentPx(eventData.data) + '💬 ' + repTE; + var preE = stE.el.querySelector('.webshell-eino-reply-stream-body'); + if (!preE) { + preE = document.createElement('pre'); + preE.className = 'webshell-ai-timeline-msg webshell-eino-reply-stream-body'; + preE.style.whiteSpace = 'pre-wrap'; + stE.el.appendChild(preE); + } + preE.textContent = fullE; + einoSubReplyStreams.delete(eventData.data.streamId); + } + if (!streamingTarget) assistantDiv.textContent = '…'; + } else if (eventData.type === 'eino_agent_reply' && eventData.message) { + var rd = eventData.data || {}; + var replyT = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; + appendTimelineItem('eino_agent_reply', webshellAgentPx(rd) + '💬 ' + replyT, eventData.message, rd); + if (!streamingTarget) assistantDiv.textContent = '…'; } } catch (e) { /* ignore parse error */ } } diff --git a/web/templates/index.html b/web/templates/index.html index 8c0ed92b..100b7961 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -220,6 +220,24 @@
+ +
@@ -1176,6 +1234,81 @@
+ +
+ +
+

子 Agent 仅在 agents 目录下 .md 维护。

+
+
+
加载中...
+
+
+
+ + + +
+
+ + 开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。 +
+
+ + +
+
+ + 需同时勾选「启用多代理」;调用量与成本更高。 +
+
+ + 开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。 +
@@ -2442,6 +2606,7 @@ version: 1.0.0
+