diff --git a/web/static/css/style.css b/web/static/css/style.css index 072ee1aa..d95cadf3 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3990,6 +3990,17 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { border-top-left-radius: 2px; } +.message.assistant.assistant-not-finalized .message-bubble { + border-color: rgba(245, 124, 0, 0.28); + border-left: 4px solid #f57c00; + background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035)); + box-shadow: 0 6px 18px rgba(245, 124, 0, 0.08); +} + +.message.assistant.assistant-not-finalized .message-bubble strong:first-child { + color: #b45309; +} + .message.assistant .message-bubble pre { margin: 0; white-space: pre-wrap; @@ -7381,6 +7392,16 @@ html[data-theme="dark"] .login-card .login-submit:disabled { background: rgba(245, 124, 0, 0.09); } +.timeline-item-finalization_check { + border-left-color: #f57c00; + background: linear-gradient(90deg, rgba(245, 124, 0, 0.1), rgba(96, 125, 139, 0.045)); +} + +.timeline-item-finalization_check .timeline-item-title { + color: #9a5200; + font-weight: 600; +} + .timeline-item-tool_calls_detected { border-left-color: #0277bd; background: rgba(2, 119, 189, 0.06); @@ -22967,6 +22988,18 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { font-weight: 500; color: var(--text-secondary); } + +.webshell-ai-timeline-finalization_check { + border-left: 3px solid #f57c00; + padding-left: 10px; + background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), transparent); +} + +.webshell-ai-timeline-finalization_check .webshell-ai-timeline-title { + color: #9a5200; + font-weight: 600; +} + .webshell-ai-timeline-msg { margin-top: 4px; padding-left: 0; @@ -23144,6 +23177,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { background: var(--bg-secondary); border: 1px solid var(--border-color); } +.webshell-ai-msg.assistant.webshell-ai-candidate-output { + border-color: rgba(245, 124, 0, 0.28); + background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035)); + box-shadow: 0 6px 16px rgba(245, 124, 0, 0.08); +} .webshell-ai-msg.assistant.webshell-ai-msg-error { max-width: 72%; border-color: rgba(220, 53, 69, 0.35); @@ -36232,12 +36270,42 @@ html[data-theme="dark"] .timeline-item-user_interrupt_continue { background: rgba(251, 191, 36, 0.08); } +html[data-theme="dark"] .timeline-item-finalization_check { + background: linear-gradient(90deg, rgba(251, 191, 36, 0.1), rgba(30, 41, 59, 0.18)); + border-left-color: #fbbf24; +} + +html[data-theme="dark"] .timeline-item-finalization_check .timeline-item-title, +html[data-theme="dark"] .webshell-ai-timeline-finalization_check .webshell-ai-timeline-title { + color: #fbbf24; +} + html[data-theme="dark"] .message.assistant .message-bubble { background: #111827; color: var(--text-primary); border-color: var(--border-color); } +html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble { + border-color: rgba(251, 191, 36, 0.24); + border-left-color: #fbbf24; + background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.9)); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22); +} + +html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble strong:first-child { + color: #fbbf24; +} + +html[data-theme="dark"] .webshell-ai-timeline-finalization_check { + border-left-color: #fbbf24; + background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12)); +} + +html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output { + background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12)); +} + html[data-theme="dark"] .message-copy-btn { background: #1f2937; border-color: #334155; diff --git a/web/static/js/assets.js b/web/static/js/assets.js index a41db34d..c96ab62c 100644 --- a/web/static/js/assets.js +++ b/web/static/js/assets.js @@ -1147,6 +1147,7 @@ async function sendAssetsToChat(assets, template) { input.value = message; if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input); // 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。 + window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true }; void sendMessage(); } diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 1cb59561..c96d28f4 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -80,6 +80,7 @@ let chatAttachmentSeq = 0; // 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration) const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode'; +const AGENT_MODE_CONVERSATION_STORAGE_PREFIX = 'cyberstrike-chat-agent-mode:conversation'; const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel'; const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode'; const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort'; @@ -733,6 +734,44 @@ function chatAgentModeNormalizeStored(stored, cfg) { return CHAT_AGENT_MODE_EINO_SINGLE; } +function normalizeConversationAgentModeForUI(mode) { + const v = String(mode || '').trim().toLowerCase().replace(/-/g, '_'); + if (chatAgentModeIsEinoSingle(v)) return v; + if (chatAgentModeIsEino(v)) { + return multiAgentAPIEnabled ? v : CHAT_AGENT_MODE_EINO_SINGLE; + } + return ''; +} + +function conversationAgentModeStorageKey(conversationId) { + return `${AGENT_MODE_CONVERSATION_STORAGE_PREFIX}:${String(conversationId || '').trim()}`; +} + +function readConversationAgentModePreference(conversationId) { + if (!conversationId) return ''; + try { + return normalizeConversationAgentModeForUI(localStorage.getItem(conversationAgentModeStorageKey(conversationId)) || ''); + } catch (e) { + return ''; + } +} + +function saveConversationAgentModePreference(conversationId, mode) { + const normalized = normalizeConversationAgentModeForUI(mode); + if (!conversationId || !normalized) return; + try { + localStorage.setItem(conversationAgentModeStorageKey(conversationId), normalized); + } catch (e) { /* ignore */ } +} + +function applyConversationAgentMode(conversationId, conversation) { + const saved = readConversationAgentModePreference(conversationId); + const fromServer = normalizeConversationAgentModeForUI(conversation && (conversation.agentMode || conversation.agent_mode)); + const mode = saved || fromServer; + if (!mode) return; + syncAgentModeFromValue(mode); +} + if (typeof window !== 'undefined') { window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || []; window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human'; @@ -1098,6 +1137,7 @@ function toggleAgentModePanel() { function selectAgentMode(mode) { const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode); if (!ok) return; + saveConversationAgentModePreference(currentConversationId, mode); try { localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode); } catch (e) { /* ignore */ } @@ -1351,6 +1391,10 @@ async function sendMessage() { conversationId: currentConversationId, role: typeof getCurrentRole === 'function' ? getCurrentRole() : '' }; + if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') { + body.finalization = window.__csNextChatFinalizationPolicy; + window.__csNextChatFinalizationPolicy = null; + } let streamConversationId = body.conversationId ? String(body.conversationId) : null; const isStreamStillVisibleForRequest = function () { if (!document.getElementById(progressId)) return false; @@ -1405,6 +1449,7 @@ async function sendMessage() { try { const modeSel = document.getElementById('agent-mode-select'); let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE; + saveConversationAgentModePreference(streamConversationId || currentConversationId, modeVal); const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal); const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream'; if (useMulti && modeVal) { @@ -4845,6 +4890,7 @@ async function loadConversation(conversationId) { if (typeof window.setCurrentRole === 'function') { window.setCurrentRole(conversationRoleName || '默认'); } + applyConversationAgentMode(conversationId, conversation); try { window.currentConversationId = conversationId; } catch (e) { /* ignore */ } diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index 755b9ee0..490fd0d9 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -1334,6 +1334,7 @@ function scanFofaRow(encodedRowJson, clickEvent) { } if (autoSend) { if (typeof sendMessage === 'function') { + window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true }; sendMessage(); } else { alert(_t('infoCollect.noSendMessage')); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 06d57157..b5791fee 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -198,6 +198,92 @@ function resolveFinalAssistantResponseText(finalMessage, streamState) { return finalMessage; } +function isFinalizedResponseData(data) { + return !!(data && data.finalized === true); +} + +function hasFinalizationContract(data) { + if (!data || typeof data !== 'object') return false; + return Object.prototype.hasOwnProperty.call(data, 'finalized') + || Object.prototype.hasOwnProperty.call(data, 'finalizable') + || Object.prototype.hasOwnProperty.call(data, 'completionReason') + || Object.prototype.hasOwnProperty.call(data, 'evidenceVerified') + || Object.prototype.hasOwnProperty.call(data, 'missingChecks'); +} + +function finalizationCheckTitle(data) { + return isFinalizedResponseData(data) ? '最终回复检查通过' : '最终回复检查未通过'; +} + +function finalizationReasonLabel(reason, status) { + const key = String(reason || status || '').trim(); + const labels = { + pending_tool_executions: '等待工具执行完成', + missing_execution_evidence: '缺少完成态证据', + awaiting_hitl: '等待人工确认', + empty_response: '未捕获到有效回复', + missing_finalization_contract: '缺少最终化证明', + in_progress: '仍在验证', + blocked: '检查未通过', + failed: '任务失败', + cancelled: '任务已取消', + verified: '已验证' + }; + return labels[key] || key || '检查未通过'; +} + +function finalizationMissingCheckLabel(check) { + const s = String(check || '').trim(); + if (!s) return ''; + if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束'; + if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录'; + if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认'; + if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本'; + if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', ''); + return s; +} + +function compactStringList(values, limit) { + const arr = Array.isArray(values) ? values.filter(Boolean).map(String) : []; + const max = limit || 3; + if (arr.length <= max) return arr; + return arr.slice(0, max).concat('另 ' + (arr.length - max) + ' 项'); +} + +function finalizationNoticeMarkdown(responseData, eventMessage) { + const hasContract = hasFinalizationContract(responseData); + const reason = hasContract + ? finalizationReasonLabel(responseData && responseData.completionReason, responseData && responseData.status) + : finalizationReasonLabel('missing_finalization_contract'); + const lines = ['**仍在验证,暂不生成最终结论**', '', '状态:' + reason]; + const pending = compactStringList(responseData && responseData.pendingExecutionIds, 3); + if (pending.length) { + lines.push('待完成工具:`' + pending.join('`, `') + '`'); + } + const rawMissingChecks = responseData && responseData.missingChecks; + const missingChecks = Array.isArray(rawMissingChecks) + ? rawMissingChecks + : (rawMissingChecks ? [rawMissingChecks] : []); + const missing = compactStringList(missingChecks.map(finalizationMissingCheckLabel).filter(Boolean), 3); + if (missing.length) { + lines.push('待完成检查:' + missing.join(';')); + } + if (!hasContract && eventMessage != null && String(eventMessage).trim() !== '') { + lines.push('', '候选输出已移入过程详情,避免误判为最终结论。'); + } + return lines.join('\n'); +} + +function markAssistantFinalizationState(assistantMessageId, responseData) { + const assistantElement = document.getElementById(assistantMessageId); + if (!assistantElement) return; + const finalized = isFinalizedResponseData(responseData); + assistantElement.dataset.finalized = finalized ? 'true' : 'false'; + assistantElement.dataset.finalizationStatus = responseData && responseData.status ? String(responseData.status) : ''; + assistantElement.classList.toggle('assistant-finalized', finalized); + assistantElement.classList.toggle('assistant-not-finalized', !finalized); +} + /** * 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致), * 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。 @@ -2440,6 +2526,26 @@ function handleStreamEvent(event, progressElement, progressId, }); break; + case 'finalization_check': + const finalizationCheckData = event.data || {}; + const finalizationCheckPassed = isFinalizedResponseData(finalizationCheckData); + addTimelineItem(timeline, 'finalization_check', { + title: finalizationCheckTitle(finalizationCheckData), + message: finalizationCheckPassed ? (event.message || '最终回复检查通过。') : finalizationNoticeMarkdown(finalizationCheckData, event.message), + data: event.data, + expanded: !finalizationCheckPassed + }); + break; + + case 'finalization_auto_continue': + addTimelineItem(timeline, 'progress', { + title: '继续验证', + message: event.message, + data: event.data, + expanded: false + }); + break; + case 'hitl_interrupt': const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {}); if (hitlTargetItem && hitlTargetItem.id) { @@ -2958,7 +3064,12 @@ function handleStreamEvent(event, progressElement, progressId, const streamState = responseStreamStateByProgressId.get(progressId); const existingAssistantId = streamState?.assistantId || getAssistantId(); let assistantIdFinal = existingAssistantId; - const bubbleText = resolveFinalAssistantResponseText(event.message, streamState); + const responseFinalized = isFinalizedResponseData(responseData); + const responseHasFinalizationContract = hasFinalizationContract(responseData); + const resolvedResponseText = resolveFinalAssistantResponseText(event.message, streamState); + const bubbleText = responseFinalized + ? resolvedResponseText + : finalizationNoticeMarkdown(responseData, event.message); if (!assistantIdFinal) { assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId); @@ -2967,11 +3078,12 @@ function handleStreamEvent(event, progressElement, progressId, setAssistantId(assistantIdFinal); updateAssistantBubbleContent(assistantIdFinal, bubbleText, true); } + markAssistantFinalizationState(assistantIdFinal, responseData); // 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情 if (streamState && streamState.itemId) { - finalizeMainResponseStreamItem(streamState, event.message, responseData); - } else if (timeline && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) { + finalizeMainResponseStreamItem(streamState, responseFinalized ? event.message : '', responseData); + } else if (timeline && responseFinalized && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) { addTimelineItem(timeline, 'planning', { title: typeof einoMainStreamPlanningTitle === 'function' ? einoMainStreamPlanningTitle(responseData) @@ -2980,6 +3092,13 @@ function handleStreamEvent(event, progressElement, progressId, data: responseData, expanded: false }); + } else if (timeline && !responseFinalized && !responseHasFinalizationContract && resolvedResponseText && String(resolvedResponseText).trim()) { + addTimelineItem(timeline, 'finalization_check', { + title: '候选输出缺少最终化证明', + message: resolvedResponseText, + data: Object.assign({}, responseData, { missingFinalizationContract: true }), + expanded: true + }); } // 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示) diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 5bb69cb9..7f308abd 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -1338,6 +1338,55 @@ function escapeHtmlAttr(s) { return escapeHtml(s).replace(/"/g, '"').replace(/'/g, '''); } +function webshellFinalizationReasonLabel(reason, status) { + var key = String(reason || status || '').trim(); + var labels = { + pending_tool_executions: '等待工具执行完成', + missing_execution_evidence: '缺少完成态证据', + awaiting_hitl: '等待人工确认', + empty_response: '未捕获到有效回复', + missing_finalization_contract: '缺少最终化证明', + in_progress: '仍在验证', + blocked: '检查未通过', + failed: '任务失败', + cancelled: '任务已取消', + verified: '已验证' + }; + return labels[key] || key || '检查未通过'; +} + +function webshellFinalizationMissingCheckLabel(check) { + var s = String(check || '').trim(); + if (!s) return ''; + if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束'; + if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录'; + if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认'; + if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本'; + if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', ''); + return s; +} + +function webshellFinalizationNotice(data, eventMessage, hasContract) { + var reason = hasContract + ? webshellFinalizationReasonLabel(data && data.completionReason, data && data.status) + : webshellFinalizationReasonLabel('missing_finalization_contract'); + var lines = ['仍在验证,暂不生成最终结论', '状态:' + reason]; + var pending = Array.isArray(data && data.pendingExecutionIds) ? data.pendingExecutionIds.filter(Boolean).map(String) : []; + if (pending.length) { + lines.push('待完成工具:' + pending.slice(0, 3).join(', ') + (pending.length > 3 ? ' 等' : '')); + } + var missing = Array.isArray(data && data.missingChecks) + ? data.missingChecks.map(webshellFinalizationMissingCheckLabel).filter(Boolean) + : []; + if (missing.length) { + lines.push('待完成检查:' + missing.slice(0, 2).join(';') + (missing.length > 2 ? ' 等' : '')); + } + if (!hasContract && eventMessage) { + lines.push('候选输出已移入过程详情。'); + } + return lines.join('\n'); +} + function escapeSingleQuotedShellArg(value) { var s = value == null ? '' : String(value); return "'" + s.replace(/'/g, "'\\''") + "'"; @@ -3393,7 +3442,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { message: message, webshellConnectionId: conn.id, conversationId: convId, - role: wsRole + role: wsRole, + finalization: { + requireExecutionEvidence: true + } }; if (!convId) { var wsPid = getWebshellAiProjectSelection(conn); @@ -3465,6 +3517,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { streamingTarget = ''; webshellStreamingTypingId += 1; streamingTypingId = webshellStreamingTypingId; + assistantDiv.dataset.finalized = 'false'; + assistantDiv.classList.add('webshell-ai-candidate-output'); assistantDiv.textContent = '…'; messagesContainer.scrollTop = messagesContainer.scrollHeight; } else if (_et === 'response_delta') { @@ -3485,6 +3539,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { } } else if (_et === 'response') { var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : ''); + var finalized = !!(_ed && _ed.finalized === true); + var hasFinalizationContract = !!(_ed && ( + Object.prototype.hasOwnProperty.call(_ed, 'finalized') || + Object.prototype.hasOwnProperty.call(_ed, 'finalizable') || + Object.prototype.hasOwnProperty.call(_ed, 'completionReason') || + Object.prototype.hasOwnProperty.call(_ed, 'evidenceVerified') || + Object.prototype.hasOwnProperty.call(_ed, 'missingChecks') + )); + assistantDiv.dataset.finalized = finalized ? 'true' : 'false'; + assistantDiv.classList.toggle('webshell-ai-candidate-output', !finalized); + assistantDiv.classList.toggle('webshell-ai-finalized-output', finalized); + if (!finalized && !hasFinalizationContract && text) { + appendTimelineItem('finalization_check', '候选输出缺少最终化证明', text, Object.assign({}, _ed || {}, { missingFinalizationContract: true })); + text = webshellFinalizationNotice(_ed || {}, text, false); + } else if (!finalized && hasFinalizationContract) { + text = webshellFinalizationNotice(_ed || {}, text, true); + } if (text) { streamingTarget = String(text); webshellStreamingTypingId += 1; @@ -3493,6 +3564,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { } // ─── Terminal events ─── + } else if (_et === 'finalization_check') { + var finalizationOk = !!(_ed && _ed.finalized === true); + appendTimelineItem('finalization_check', finalizationOk ? '最终回复检查通过' : '最终回复检查未通过', finalizationOk ? (_em || '最终回复检查通过。') : webshellFinalizationNotice(_ed || {}, _em, true), _ed); + } else if (_et === 'finalization_auto_continue') { + appendTimelineItem('progress', '继续验证', _em, _ed); } else if (_et === 'error' && _em) { streamingTypingId += 1; var errLabel = wsTOr('chat.error', '错误');