From 943a3b26461c4bc330e5f145827dae2277e6db3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:50:55 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 272 +++++++++++++++ web/static/i18n/en-US.json | 30 +- web/static/i18n/zh-CN.json | 30 +- web/static/js/chat.js | 664 ++++++++++++++++++++++++++++--------- web/static/js/hitl.js | 390 ++++++++++++++++++++++ web/static/js/monitor.js | 427 +++++++++++++++++++++++- web/static/js/router.js | 111 ++++--- web/static/js/tasks.js | 22 +- web/templates/index.html | 62 ++++ 9 files changed, 1777 insertions(+), 231 deletions(-) create mode 100644 web/static/js/hitl.js diff --git a/web/static/css/style.css b/web/static/css/style.css index 8f578954..648b8275 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -532,6 +532,10 @@ body { display: none; } +.conversation-sidebar.collapsed .hitl-sidebar-card { + display: none; +} + .conversation-sidebar.collapsed .conversation-sidebar-header { flex-direction: column; align-items: center; @@ -948,6 +952,273 @@ header { min-height: 0; } +.hitl-sidebar-card { + border-top: 1px solid var(--border-color); + background: linear-gradient(165deg, #f8fafc 0%, #f1f5f9 55%, #eef2f7 100%); + padding: 14px 12px 16px; + flex-shrink: 0; +} + +.hitl-sidebar-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.hitl-sidebar-heading { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.hitl-sidebar-icon { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: linear-gradient(145deg, rgba(0, 102, 255, 0.12), rgba(0, 102, 255, 0.06)); + color: var(--accent-color); + border: 1px solid rgba(0, 102, 255, 0.18); +} + +.hitl-sidebar-heading-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.hitl-sidebar-title { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); + line-height: 1.25; +} + +.hitl-sidebar-subtitle { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + line-height: 1.3; +} + +.hitl-apply-btn { + padding: 8px 14px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + background: linear-gradient(180deg, var(--accent-color) 0%, var(--accent-hover) 100%); + color: #fff; + border: none; + cursor: pointer; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(0, 102, 255, 0.25), 0 2px 8px rgba(0, 102, 255, 0.12); + transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; +} + +.hitl-apply-btn:hover { + filter: brightness(1.05); + box-shadow: 0 2px 4px rgba(0, 102, 255, 0.3), 0 4px 12px rgba(0, 102, 255, 0.18); +} + +.hitl-apply-btn:active { + transform: translateY(1px); +} + +.hitl-apply-btn:disabled { + opacity: 0.65; + cursor: not-allowed; + transform: none; + filter: none; +} + +.hitl-apply-feedback { + display: none; + font-size: 12px; + line-height: 1.4; + margin: 0 0 10px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(16, 185, 129, 0.12); + color: #047857; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.hitl-apply-feedback.hitl-apply-feedback--error { + background: rgba(239, 68, 68, 0.1); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.22); +} + +/* 仅本机保存、未请求服务端:避免与「已全部同步」同款绿色造成误解 */ +.hitl-apply-feedback.hitl-apply-feedback--partial { + background: rgba(245, 158, 11, 0.12); + color: #b45309; + border-color: rgba(245, 158, 11, 0.25); +} + +.hitl-sidebar-config { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 14px; + padding: 12px; + background: #fff; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.04); +} + +.hitl-config-field { + margin-bottom: 14px; +} + +.hitl-config-field:last-child { + margin-bottom: 0; +} + +.hitl-config-field--tools { + margin-bottom: 0; +} + +.hitl-config-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; + letter-spacing: -0.01em; +} + +.hitl-config-hint { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-secondary); +} + +.hitl-config-select { + width: 100%; + height: 40px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-primary); + padding: 0 36px 0 12px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.hitl-config-textarea { + display: block; + width: 100%; + min-height: 72px; + max-height: 200px; + resize: vertical; + border: 1px solid var(--border-color); + border-radius: 10px; + background: #fafbfc; + padding: 10px 12px; + font-size: 12px; + line-height: 1.5; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--text-primary); + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.hitl-config-textarea::placeholder { + color: var(--text-muted); + font-family: inherit; +} + +/* 其它页面内联 HITL 评论框等仍用 input 类名 */ +.hitl-config-input { + width: 100%; + height: 38px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: #fff; + padding: 0 10px; + font-size: 14px; + color: var(--text-primary); +} + +.hitl-pending-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hitl-pending-item { + border: 1px solid rgba(99, 102, 241, 0.25); + border-radius: 10px; + padding: 12px; + background: rgba(15, 23, 42, 0.45); +} + +.hitl-pending-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.hitl-pending-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.hitl-edit-args { + width: 100%; + min-height: 76px; + margin-top: 8px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 8px; + background: #ffffff; + color: #1f2937; + padding: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.hitl-input-help { + margin-top: 6px; + margin-bottom: 6px; + font-size: 12px; + color: #64748b; + line-height: 1.4; +} + +.hitl-inline-approval { + margin-top: 8px; + padding: 10px; + border: 1px solid #dbeafe; + background: #f8fbff; + border-radius: 8px; +} + +.hitl-inline-approval.hitl-inline-done { + opacity: 0.8; +} + +.hitl-config-select:focus, +.hitl-config-textarea:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.14); + background: #fff; +} + .sidebar-title { font-size: 0.8125rem; font-weight: 600; @@ -5672,6 +5943,7 @@ header { transition: all 0.2s ease; } + .legend-item:hover { transform: translateX(2px); } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index aaa623eb..c7a2b850 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -61,7 +61,8 @@ "agentsManagement": "Agent management", "roles": "Roles", "rolesManagement": "Roles Management", - "settings": "System settings" + "settings": "System settings", + "hitl": "Human-in-the-loop" }, "dashboard": { "title": "Dashboard", @@ -191,6 +192,9 @@ "executionFailed": "Execution failed", "penetrationTestComplete": "Penetration test complete", "yesterday": "Yesterday", + "historyGroupToday": "Today", + "historyGroupLast7Days": "Past 7 days", + "historyGroupEarlier": "Older", "agentModeSelectAria": "Choose conversation execution mode", "agentModePanelTitle": "Conversation mode", "agentModeReactNative": "Native ReAct", @@ -208,7 +212,29 @@ "agentModeSingleHint": "Single-model ReAct loop for chat and tool use", "agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks", "agentModeOrchPlanExecute": "Plan-Exec", - "agentModeOrchSupervisor": "Supervisor" + "agentModeOrchSupervisor": "Supervisor", + "hitlTitle": "Human-in-the-loop", + "hitlCardSubtitle": "Approvals & allowlist", + "hitlReviewer": "Review", + "hitlConfigTitle": "Collaboration mode config", + "hitlModeLabel": "Mode", + "hitlModeOff": "Off", + "hitlModeApproval": "Approval", + "hitlModeReviewEdit": "Review & Edit", + "hitlSensitiveTools": "Sensitive tools (comma-separated)", + "hitlWhitelistTools": "Whitelisted tools (skip approval, comma-separated)", + "hitlWhitelistPlaceholder": "e.g. read_file, grep or one tool per line (merged with global allowlist in config)", + "hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.", + "hitlApply": "Apply", + "hitlApplyOkSync": "HITL settings saved and synced to the server.", + "hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Mode and timeout still require selecting a conversation and clicking Apply to sync session settings to the server.", + "hitlApplyOkLocal": "Saved in this browser.", + "hitlApplyFail": "Failed to sync to server", + "hitlStatusOff": "Human-in-the-loop: Off" + }, + "hitl": { + "pageTitle": "HITL approvals", + "pendingTitle": "Pending interrupts" }, "progress": { "callingAI": "Calling AI model...", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index a0ed4ba9..c8326af1 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -61,7 +61,8 @@ "agentsManagement": "Agent管理", "roles": "角色", "rolesManagement": "角色管理", - "settings": "系统设置" + "settings": "系统设置", + "hitl": "人机协同" }, "dashboard": { "title": "仪表盘", @@ -191,6 +192,9 @@ "executionFailed": "执行失败", "penetrationTestComplete": "渗透测试完成", "yesterday": "昨天", + "historyGroupToday": "今天", + "historyGroupLast7Days": "过去七天", + "historyGroupEarlier": "更早", "agentModeSelectAria": "选择对话执行模式", "agentModePanelTitle": "对话模式", "agentModeReactNative": "原生 ReAct 模式", @@ -208,7 +212,29 @@ "agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用", "agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务", "agentModeOrchPlanExecute": "Plan-Exec", - "agentModeOrchSupervisor": "Supervisor" + "agentModeOrchSupervisor": "Supervisor", + "hitlTitle": "人机协同", + "hitlCardSubtitle": "审批与白名单", + "hitlReviewer": "Review", + "hitlConfigTitle": "协同模式配置", + "hitlModeLabel": "模式", + "hitlModeOff": "关闭", + "hitlModeApproval": "审批模式", + "hitlModeReviewEdit": "审查编辑", + "hitlSensitiveTools": "敏感工具(逗号分隔)", + "hitlWhitelistTools": "白名单工具(免审批,逗号分隔)", + "hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)", + "hitlWhitelistHint": "每行一个或逗号分隔;与 config 中全局白名单合并展示。", + "hitlApply": "应用", + "hitlApplyOkSync": "人机协同配置已保存并同步到服务器。", + "hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。", + "hitlApplyOkLocal": "已保存到本浏览器。", + "hitlApplyFail": "同步到服务器失败", + "hitlStatusOff": "人机协同:关闭" + }, + "hitl": { + "pageTitle": "人机协同审批", + "pendingTitle": "待处理中断" }, "progress": { "callingAI": "正在调用AI模型...", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 000f1eec..6d05de41 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1,4 +1,5 @@ let currentConversationId = null; +let loadConversationRequestSeq = 0; // @ 提及相关状态 let mentionTools = []; @@ -39,6 +40,17 @@ const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single'; const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor']; let multiAgentAPIEnabled = false; +// 人机协同(HITL)会话级配置 +const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl'; +const HITL_DRAFT_KEY = 'cyberstrike-chat-hitl-draft'; +/** 跨会话记忆:用户最近一次在侧栏选择的 HITL 偏好(与 hitl.js 中 readHitlGlobalLast 使用同一 key) */ +const HITL_GLOBAL_LAST_KEY = `${HITL_STORAGE_PREFIX}:__last__`; +const HITL_MODE_OFF = 'off'; +const HITL_MODE_APPROVAL = 'approval'; +const HITL_MODE_REVIEW_EDIT = 'review_edit'; +const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT]; +let hitlApplyFeedbackTimer = null; + function normalizeOrchestrationClient(s) { const v = String(s || '').trim().toLowerCase().replace(/-/g, '_'); if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute'; @@ -54,6 +66,302 @@ function chatAgentModeIsEinoSingle(mode) { return mode === CHAT_AGENT_MODE_EINO_SINGLE; } +function normalizeHitlMode(mode) { + let v = String(mode || '').trim().toLowerCase().replace(/-/g, '_'); + if (v === 'feedback' || v === 'followup') { + v = HITL_MODE_APPROVAL; + } + if (HITL_MODE_OPTIONS.includes(v)) return v; + return HITL_MODE_OFF; +} + +function defaultHitlConfig() { + return { + mode: HITL_MODE_OFF, + sensitiveTools: '', + updatedAt: '' + }; +} + +/** 白名单字符串拆成数组(逗号或换行分隔,与 textarea 一致) */ +function hitlToolsSplitToArray(s) { + return String(s || '') + .split(/[,\n\r]+/) + .map(function (x) { return x.trim(); }) + .filter(Boolean); +} + +/** 与 config.yaml hitl.tool_whitelist 合并为输入框展示(全局项在前,去重不区分大小写) */ +function hitlMergeToolsForDisplay(globalArr, sessionToolsArr) { + const seen = Object.create(null); + const out = []; + function addOne(t) { + const n = String(t || '').trim(); + if (!n) return; + const k = n.toLowerCase(); + if (seen[k]) return; + seen[k] = true; + out.push(n); + } + if (Array.isArray(globalArr)) { + globalArr.forEach(addOne); + } + if (Array.isArray(sessionToolsArr)) { + sessionToolsArr.forEach(addOne); + } + return out.join(', '); +} + +/** 保存/发请求前去掉全局白名单工具,避免会话里重复存 config 已有项 */ +function hitlStripGlobalToolsFromFormString(globalArr, commaStr) { + if (!Array.isArray(globalArr) || globalArr.length === 0) { + return typeof commaStr === 'string' ? commaStr.trim() : ''; + } + const g = Object.create(null); + globalArr.forEach(function (t) { + const k = String(t || '').trim().toLowerCase(); + if (k) g[k] = true; + }); + return hitlToolsSplitToArray(commaStr) + .filter(function (p) { + return p && !g[p.toLowerCase()]; + }) + .join(', '); +} + +function getHitlStorageKeyByConversation(conversationId) { + return `${HITL_STORAGE_PREFIX}:${String(conversationId || '').trim()}`; +} + +function getHitlModeLabel(mode) { + const safeMode = normalizeHitlMode(mode); + if (typeof window.t === 'function') { + switch (safeMode) { + case HITL_MODE_APPROVAL: + return window.t('chat.hitlModeApproval'); + case HITL_MODE_REVIEW_EDIT: + return window.t('chat.hitlModeReviewEdit'); + default: + return window.t('chat.hitlModeOff'); + } + } + return safeMode; +} + +function getHitlLastGlobalConfig() { + const fallback = defaultHitlConfig(); + try { + const raw = localStorage.getItem(HITL_GLOBAL_LAST_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return { + mode: normalizeHitlMode(parsed.mode), + sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' + }; + } catch (e) { + return null; + } +} + +function saveHitlLastGlobalConfig(payload) { + if (!payload || typeof payload !== 'object') return; + try { + localStorage.setItem(HITL_GLOBAL_LAST_KEY, JSON.stringify(payload)); + } catch (e) { + console.warn('saveHitlLastGlobalConfig failed', e); + } +} + +function getHitlConfigForConversation(conversationId) { + const fallback = defaultHitlConfig(); + const cid = conversationId ? String(conversationId).trim() : ''; + if (!cid) { + const globalLast = getHitlLastGlobalConfig(); + let draftCfg = null; + try { + const raw = localStorage.getItem(HITL_DRAFT_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + draftCfg = { + mode: normalizeHitlMode(parsed.mode), + sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' + }; + } + } + } catch (e) { + draftCfg = null; + } + const g = globalLast ? { + mode: normalizeHitlMode(globalLast.mode), + sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools, + updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : '' + } : null; + if (!draftCfg && !g) return fallback; + if (!draftCfg) return g; + if (!g) return draftCfg; + const tg = Date.parse(g.updatedAt) || 0; + const td = Date.parse(draftCfg.updatedAt) || 0; + return tg > td ? g : draftCfg; + } + const key = getHitlStorageKeyByConversation(cid); + try { + const raw = localStorage.getItem(key); + if (!raw) { + return getHitlLastGlobalConfig() || fallback; + } + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return getHitlLastGlobalConfig() || fallback; + } + return { + mode: normalizeHitlMode(parsed.mode), + sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' + }; + } catch (e) { + return getHitlLastGlobalConfig() || fallback; + } +} + +function saveHitlConfigForConversation(conversationId, cfg, opts) { + const syncGlobalLast = !!(opts && opts.syncGlobalLast); + const payload = { + mode: normalizeHitlMode(cfg && cfg.mode), + sensitiveTools: typeof (cfg && cfg.sensitiveTools) === 'string' ? cfg.sensitiveTools : '', + updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : '' + }; + const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY; + try { + localStorage.setItem(key, JSON.stringify(payload)); + if (syncGlobalLast) { + saveHitlLastGlobalConfig(payload); + } + } catch (e) { + console.warn('saveHitlConfigForConversation failed', e); + } +} + +function readHitlConfigFromForm() { + const modeEl = document.getElementById('hitl-mode-select'); + const toolsEl = document.getElementById('hitl-sensitive-tools'); + const mode = normalizeHitlMode(modeEl ? modeEl.value : HITL_MODE_OFF); + let sensitiveTools = toolsEl ? String(toolsEl.value || '').trim() : ''; + const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null; + if (Array.isArray(g) && g.length > 0) { + sensitiveTools = hitlStripGlobalToolsFromFormString(g, sensitiveTools); + } + return { + mode, + sensitiveTools, + updatedAt: new Date().toISOString() + }; +} + +function updateHitlStatusUI(_cfg) { + /* 侧栏已改为「应用」按钮生效,不再用角标展示模式 */ +} + +function applyHitlConfigToUI(cfg) { + const conf = cfg || defaultHitlConfig(); + const modeEl = document.getElementById('hitl-mode-select'); + const toolsEl = document.getElementById('hitl-sensitive-tools'); + if (modeEl) modeEl.value = normalizeHitlMode(conf.mode); + let toolsVal = conf.sensitiveTools || ''; + const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null; + if (Array.isArray(g) && g.length > 0) { + const sessionArr = hitlToolsSplitToArray(toolsVal); + toolsVal = hitlMergeToolsForDisplay(g, sessionArr); + } + if (toolsEl) toolsEl.value = toolsVal; + updateHitlStatusUI(conf); +} + +function refreshHitlConfigByCurrentConversation() { + const cfg = getHitlConfigForConversation(currentConversationId || ''); + applyHitlConfigToUI(cfg); +} + +function showHitlApplyFeedback(text, isError, partial) { + const el = document.getElementById('hitl-apply-feedback'); + if (hitlApplyFeedbackTimer) { + clearTimeout(hitlApplyFeedbackTimer); + hitlApplyFeedbackTimer = null; + } + if (!el) { + if (text && isError) { + alert(text); + } + return; + } + el.classList.toggle('hitl-apply-feedback--error', !!isError); + el.classList.toggle('hitl-apply-feedback--partial', !!partial && !isError); + if (!text) { + el.textContent = ''; + el.style.display = 'none'; + el.classList.remove('hitl-apply-feedback--error', 'hitl-apply-feedback--partial'); + return; + } + el.textContent = text; + el.style.display = 'block'; + if (!isError) { + hitlApplyFeedbackTimer = setTimeout(function () { + el.textContent = ''; + el.style.display = 'none'; + el.classList.remove('hitl-apply-feedback--error'); + el.classList.remove('hitl-apply-feedback--partial'); + hitlApplyFeedbackTimer = null; + }, 3200); + } +} + +/** 侧栏人机协同:修改模式/白名单后点此写入本地、合并展示并同步服务端 */ +async function applyHitlSidebarConfig() { + const btn = document.getElementById('hitl-apply-btn'); + showHitlApplyFeedback('', false); + if (btn) btn.disabled = true; + try { + const cfg = readHitlConfigFromForm(); + const cid = typeof currentConversationId === 'string' ? currentConversationId.trim() : ''; + saveHitlConfigForConversation(cid, cfg, { syncGlobalLast: true }); + + const toolsArr = hitlToolsSplitToArray(cfg.sensitiveTools || ''); + + let yamlMerged = false; + if (!cid && toolsArr.length > 0 && typeof window.mergeHitlGlobalToolWhitelist === 'function') { + const newGlobal = await window.mergeHitlGlobalToolWhitelist(toolsArr); + if (Array.isArray(newGlobal)) { + window.csaiHitlGlobalToolWhitelist = newGlobal; + } + yamlMerged = true; + } + + applyHitlConfigToUI(cfg); + + if (cid && typeof window.saveHitlConversationConfig === 'function') { + await window.saveHitlConversationConfig(cid, cfg); + const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。'; + showHitlApplyFeedback(ok, false); + } else if (yamlMerged) { + const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。'; + showHitlApplyFeedback(okYaml, false); + } else { + const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。'; + showHitlApplyFeedback(localOnly, false); + } + } catch (e) { + console.warn('applyHitlSidebarConfig', e); + const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败'; + const detail = (e && e.message) ? e.message : String(e); + showHitlApplyFeedback(prefix + (detail ? ':' + detail : ''), true); + } finally { + if (btn) btn.disabled = false; + } +} + /** 将 localStorage / 历史值规范为 react | eino_single | deep | plan_execute | supervisor */ function chatAgentModeNormalizeStored(stored, cfg) { const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null; @@ -70,6 +378,7 @@ function chatAgentModeNormalizeStored(stored, cfg) { } if (typeof window !== 'undefined') { + window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || []; window.csaiChatAgentMode = { EINO_MODES: CHAT_AGENT_EINO_MODES, EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE, @@ -79,6 +388,15 @@ if (typeof window !== 'undefined') { normalizeStored: chatAgentModeNormalizeStored, normalizeOrchestration: normalizeOrchestrationClient }; + window.applyHitlSidebarConfig = applyHitlSidebarConfig; + window.readHitlConfigFromForm = readHitlConfigFromForm; + window.applyHitlConfigToUI = applyHitlConfigToUI; + window.saveHitlConfigForConversation = saveHitlConfigForConversation; + window.getHitlLastGlobalConfig = getHitlLastGlobalConfig; + window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay; + window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString; + window.hitlToolsSplitToArray = hitlToolsSplitToArray; + window.updateHitlStatusUI = updateHitlStatusUI; } function getAgentModeLabelForValue(mode) { @@ -177,6 +495,10 @@ async function initChatAgentModeFromConfig() { multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled); if (typeof window !== 'undefined') { window.__csaiMultiAgentPublic = cfg.multi_agent || null; + const tw = cfg.hitl && cfg.hitl.tool_whitelist; + if (Array.isArray(tw)) { + window.csaiHitlGlobalToolWhitelist = tw.slice(); + } } const wrap = document.getElementById('agent-mode-wrapper'); const sel = document.getElementById('agent-mode-select'); @@ -378,6 +700,15 @@ async function sendMessage() { conversationId: currentConversationId, role: typeof getCurrentRole === 'function' ? getCurrentRole() : '' }; + const hitlCfg = readHitlConfigFromForm(); + if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) { + const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || ''); + body.hitl = { + enabled: true, + mode: normalizeHitlMode(hitlCfg.mode), + sensitiveTools: sensitiveTools + }; + } if (hasAttachments) { body.attachments = chatAttachments.map((a) => ({ fileName: a.fileName, @@ -1879,6 +2210,9 @@ function renderProcessDetails(messageId, processDetails) { itemTitle = '❌ ' + (typeof window.t === 'function' ? window.t('chat.error') : '错误'); } else if (eventType === 'cancelled') { itemTitle = '⛔ ' + (typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消'); + } else if (eventType === 'hitl_interrupt') { + const hitlMsg = (detail.message && String(detail.message).trim()) ? String(detail.message).trim() : (typeof window.t === 'function' ? window.t('hitl.pendingTitle') : '待审批'); + itemTitle = agPx + '🧑‍⚖️ HITL · ' + hitlMsg; } else if (eventType === 'progress') { itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); } @@ -1891,11 +2225,12 @@ function renderProcessDetails(messageId, processDetails) { }); }); - // 检查是否有错误或取消事件,如果有,确保详情默认折叠 + // 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理) + const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt'); const hasErrorOrCancelled = processDetails.some(d => d.eventType === 'error' || d.eventType === 'cancelled' ); - if (hasErrorOrCancelled) { + if (hasErrorOrCancelled && !hasPendingHitlInDetails) { // 确保时间线是折叠的 timeline.classList.remove('expanded'); // 更新按钮文本为"展开详情" @@ -2191,6 +2526,9 @@ async function startNewConversation() { } currentConversationId = null; + try { + window.currentConversationId = ''; + } catch (e) { /* ignore */ } currentConversationGroupId = null; // 新对话不属于任何分组 document.getElementById('chat-messages').innerHTML = ''; const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; @@ -2214,130 +2552,19 @@ async function startNewConversation() { chatInput.value = ''; adjustTextareaHeight(chatInput); } + // 把当前侧栏人机协同选项写入草稿与「最近应用」记忆,避免刷新时被旧草稿里的「关闭」覆盖 + try { + if (typeof readHitlConfigFromForm === 'function' && typeof saveHitlConfigForConversation === 'function') { + const snap = readHitlConfigFromForm(); + saveHitlConfigForConversation('', snap, { syncGlobalLast: true }); + } + } catch (e) { /* ignore */ } + refreshHitlConfigByCurrentConversation(); } -// 加载对话列表(按时间分组) +// 与 loadConversationsWithGroups 合并实现,避免并发加载时重复追加列表项 async function loadConversations(searchQuery = '') { - try { - let url = '/api/conversations?limit=50'; - if (searchQuery && searchQuery.trim()) { - url += '&search=' + encodeURIComponent(searchQuery.trim()); - } - const response = await apiFetch(url); - - const listContainer = document.getElementById('conversations-list'); - if (!listContainer) { - return; - } - - // 保存滚动位置 - const sidebarContent = listContainer.closest('.sidebar-content'); - const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0; - - const emptyStateHtml = '
'; - listContainer.innerHTML = ''; - - // 如果响应不是200,显示空状态(友好处理,不显示错误) - if (!response.ok) { - listContainer.innerHTML = emptyStateHtml; - if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); - return; - } - - const conversations = await response.json(); - - if (!Array.isArray(conversations) || conversations.length === 0) { - listContainer.innerHTML = emptyStateHtml; - if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); - return; - } - - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const weekday = todayStart.getDay() === 0 ? 7 : todayStart.getDay(); - const startOfWeek = new Date(todayStart); - startOfWeek.setDate(todayStart.getDate() - (weekday - 1)); - const yesterdayStart = new Date(todayStart); - yesterdayStart.setDate(todayStart.getDate() - 1); - - const groups = { - today: [], - yesterday: [], - thisWeek: [], - earlier: [], - }; - - conversations.forEach(conv => { - const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); - const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj; - const groupKey = getConversationGroup(validDate, todayStart, startOfWeek, yesterdayStart); - groups[groupKey].push({ - ...conv, - _time: validDate, - _timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart), - }); - }); - - const groupOrder = [ - { key: 'today', label: '今天' }, - { key: 'yesterday', label: '昨天' }, - { key: 'thisWeek', label: '本周' }, - { key: 'earlier', label: '更早' }, - ]; - - const fragment = document.createDocumentFragment(); - let rendered = false; - - groupOrder.forEach(({ key, label }) => { - const items = groups[key]; - if (!items || items.length === 0) { - return; - } - rendered = true; - - const section = document.createElement('div'); - section.className = 'conversation-group'; - - const title = document.createElement('div'); - title.className = 'conversation-group-title'; - title.textContent = label; - section.appendChild(title); - - items.forEach(itemData => { - // 判断是否置顶 - const isPinned = itemData.pinned || false; - section.appendChild(createConversationListItemWithMenu(itemData, isPinned)); - }); - - fragment.appendChild(section); - }); - - if (!rendered) { - listContainer.innerHTML = emptyStateHtml; - if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); - return; - } - - listContainer.appendChild(fragment); - updateActiveConversation(); - - // 恢复滚动位置 - if (sidebarContent) { - // 使用 requestAnimationFrame 确保 DOM 已经更新 - requestAnimationFrame(() => { - sidebarContent.scrollTop = savedScrollTop; - }); - } - } catch (error) { - console.error('加载对话列表失败:', error); - // 错误时显示空状态,而不是错误提示(更友好的用户体验) - const listContainer = document.getElementById('conversations-list'); - if (listContainer) { - const emptyStateHtml = '
'; - listContainer.innerHTML = emptyStateHtml; - if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); - } - } + return loadConversationsWithGroups(searchQuery); } function createConversationListItem(conversation) { @@ -2459,7 +2686,7 @@ function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) { return dateObj.toLocaleString(fmtLocale, fullDateOpts); } -function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart) { +function getConversationGroup(dateObj, todayStart, sevenDaysCutoff, yesterdayStart) { if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) { return 'earlier'; } @@ -2473,23 +2700,31 @@ function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart) if (messageDay.getTime() === yesterday.getTime()) { return 'yesterday'; } - if (messageDay >= startOfWeek && messageDay < today) { - return 'thisWeek'; + const cutoff = new Date(sevenDaysCutoff.getFullYear(), sevenDaysCutoff.getMonth(), sevenDaysCutoff.getDate()); + if (messageDay >= cutoff && messageDay < yesterday) { + return 'last7Days'; } return 'earlier'; } // 加载对话 async function loadConversation(conversationId) { + const seq = ++loadConversationRequestSeq; try { // 轻量加载:不带 processDetails,避免历史会话切换卡顿;展开详情时再按需拉取 const response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`); + if (seq !== loadConversationRequestSeq) { + return; + } const conversation = await response.json(); if (!response.ok) { alert('加载对话失败: ' + (conversation.error || '未知错误')); return; } + if (seq !== loadConversationRequestSeq) { + return; + } // 如果当前在分组详情页面,切换到对话界面 // 退出分组详情模式,显示所有最近对话,提供更好的用户体验 @@ -2518,6 +2753,9 @@ async function loadConversation(conversationId) { if (Object.keys(conversationGroupMappingCache).length === 0) { await loadConversationGroupMapping(); } + if (seq !== loadConversationRequestSeq) { + return; + } currentConversationGroupId = conversationGroupMappingCache[conversationId] || null; // 异步刷新分组列表高亮状态(不阻塞消息渲染) @@ -2525,6 +2763,14 @@ async function loadConversation(conversationId) { // 更新当前对话ID currentConversationId = conversationId; + try { + window.currentConversationId = conversationId; + } catch (e) { /* ignore */ } + if (typeof window.syncHitlConfigFromServer === 'function') { + await window.syncHitlConfigFromServer(conversationId); + } else { + refreshHitlConfigByCurrentConversation(); + } updateActiveConversation(); // 如果攻击链模态框打开且显示的不是当前对话,关闭它 @@ -2537,6 +2783,9 @@ async function loadConversation(conversationId) { // 清空消息区域 const messagesDiv = document.getElementById('chat-messages'); + if (seq !== loadConversationRequestSeq) { + return; + } messagesDiv.innerHTML = ''; // 检查对话中是否有最近的消息,如果有,清除草稿(避免恢复已发送的消息) @@ -2605,38 +2854,57 @@ async function loadConversation(conversationId) { const firstBatch = msgs.slice(0, FIRST_BATCH); const rest = msgs.slice(FIRST_BATCH); + let pendingMessageBatches = Promise.resolve(); + // 首批同步渲染 firstBatch.forEach(renderOneMessage); // 剩余消息通过 requestAnimationFrame 分批渲染,避免阻塞 UI if (rest.length > 0) { const savedConvId = conversationId; - let offset = 0; - const renderNextBatch = () => { - // 如果用户已经切换到其他对话,停止渲染 - if (currentConversationId !== savedConvId) return; - const batch = rest.slice(offset, offset + BATCH_SIZE); - batch.forEach(renderOneMessage); - offset += BATCH_SIZE; - if (offset < rest.length) { - requestAnimationFrame(renderNextBatch); - } else { - // 所有消息渲染完毕,滚动到底部 - messagesDiv.scrollTop = messagesDiv.scrollHeight; - } - }; - requestAnimationFrame(renderNextBatch); + const savedSeq = seq; + pendingMessageBatches = new Promise((resolve) => { + let offset = 0; + const renderNextBatch = () => { + if (savedSeq !== loadConversationRequestSeq || currentConversationId !== savedConvId) { + resolve(); + return; + } + const batch = rest.slice(offset, offset + BATCH_SIZE); + batch.forEach(renderOneMessage); + offset += BATCH_SIZE; + if (offset < rest.length) { + requestAnimationFrame(renderNextBatch); + } else { + messagesDiv.scrollTop = messagesDiv.scrollHeight; + resolve(); + } + }; + requestAnimationFrame(renderNextBatch); + }); + } + + messagesDiv.scrollTop = messagesDiv.scrollHeight; + addAttackChainButton(conversationId); + await pendingMessageBatches; + if (seq !== loadConversationRequestSeq) { + return; + } + if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') { + await window.restoreHitlInlineForConversation(conversationId); } } else { const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true }); + messagesDiv.scrollTop = messagesDiv.scrollHeight; + addAttackChainButton(conversationId); + if (seq !== loadConversationRequestSeq) { + return; + } + if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') { + await window.restoreHitlInlineForConversation(conversationId); + } } - - // 滚动到底部(首批渲染后立即滚动,剩余批次渲染后会再次滚动) - messagesDiv.scrollTop = messagesDiv.scrollHeight; - - // 添加攻击链按钮 - addAttackChainButton(conversationId); } catch (error) { console.error('加载对话失败:', error); alert('加载对话失败: ' + error.message); @@ -2695,8 +2963,11 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) { throw new Error(data.error || data.message || 'delete failed'); } await loadConversation(currentConversationId); - if (typeof loadConversations === 'function') loadConversations(); - if (typeof loadConversationsWithGroups === 'function') loadConversationsWithGroups(); + if (typeof loadConversationsWithGroups === 'function') { + loadConversationsWithGroups(); + } else if (typeof loadConversations === 'function') { + loadConversations(); + } } catch (error) { console.error('delete turn failed:', error); const failed = typeof window.t === 'function' ? window.t('chat.deleteTurnFailed') : '删除本轮失败'; @@ -2726,6 +2997,9 @@ async function deleteConversation(conversationId, skipConfirm = false) { // 如果删除的是当前对话,清空对话界面 if (conversationId === currentConversationId) { currentConversationId = null; + try { + window.currentConversationId = ''; + } catch (e) { /* ignore */ } document.getElementById('chat-messages').innerHTML = ''; const readyMsgLoad = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; addMessage('assistant', readyMsgLoad, null, null, null, { systemReadyMessage: true }); @@ -4666,18 +4940,69 @@ async function loadConversationsWithGroups(searchQuery = '') { pinnedConvs.sort(sortByTime); normalConvs.sort(sortByTime); + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterdayStart = new Date(todayStart); + yesterdayStart.setDate(todayStart.getDate() - 1); + const sevenDaysCutoff = new Date(todayStart); + sevenDaysCutoff.setDate(todayStart.getDate() - 7); + + const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; + const groupOrder = [ + { key: 'today', label: tFn ? tFn('chat.historyGroupToday') : '今天' }, + { key: 'yesterday', label: tFn ? tFn('chat.yesterday') : '昨天' }, + { key: 'last7Days', label: tFn ? tFn('chat.historyGroupLast7Days') : '过去七天' }, + { key: 'earlier', label: tFn ? tFn('chat.historyGroupEarlier') : '更早' }, + ]; + + const groups = { + today: [], + yesterday: [], + last7Days: [], + earlier: [], + }; + + normalConvs.forEach(conv => { + const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); + const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj; + const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart); + groups[groupKey].push({ + ...conv, + _timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart), + }); + }); + const fragment = document.createDocumentFragment(); - // 添加置顶对话 if (pinnedConvs.length > 0) { pinnedConvs.forEach(conv => { - fragment.appendChild(createConversationListItemWithMenu(conv, true)); + const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); + const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj; + fragment.appendChild(createConversationListItemWithMenu({ + ...conv, + _timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart), + }, true)); }); } - // 添加普通对话 - normalConvs.forEach(conv => { - fragment.appendChild(createConversationListItemWithMenu(conv, false)); + groupOrder.forEach(({ key, label }) => { + const items = groups[key]; + if (!items || items.length === 0) { + return; + } + const section = document.createElement('div'); + section.className = 'conversation-group'; + + const title = document.createElement('div'); + title.className = 'conversation-group-title'; + title.textContent = label; + section.appendChild(title); + + items.forEach(itemData => { + section.appendChild(createConversationListItemWithMenu(itemData, false)); + }); + + fragment.appendChild(section); }); if (fragment.children.length === 0) { @@ -4749,7 +5074,7 @@ function createConversationListItemWithMenu(conversation, isPinned) { const time = document.createElement('div'); time.className = 'conversation-time'; const dateObj = conversation.updatedAt ? new Date(conversation.updatedAt) : new Date(); - time.textContent = formatConversationTimestamp(dateObj); + time.textContent = conversation._timeText || formatConversationTimestamp(dateObj); contentWrapper.appendChild(time); // 如果对话属于某个分组,显示分组标签 @@ -6203,7 +6528,17 @@ document.addEventListener('DOMContentLoaded', function() { } }); } - initChatAgentModeFromConfig(); + initChatAgentModeFromConfig() + .then(function () { + refreshHitlConfigByCurrentConversation(); + }) + .catch(function () { + refreshHitlConfigByCurrentConversation(); + }); +}); + +document.addEventListener('languagechange', function () { + refreshHitlConfigByCurrentConversation(); }); // 点击外部关闭图标选择器、对话模式面板 @@ -6968,14 +7303,6 @@ function clearGroupSearch() { // 初始化时加载分组 document.addEventListener('DOMContentLoaded', async () => { await loadGroups(); - // 替换原来的loadConversations调用 - if (typeof loadConversations === 'function') { - // 保留原函数,但使用新函数 - const originalLoad = loadConversations; - loadConversations = function(...args) { - loadConversationsWithGroups(...args); - }; - } await loadConversationsWithGroups(); // 添加页面焦点时自动刷新对话列表的功能 @@ -7014,6 +7341,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (!id) return; if (id === currentConversationId) { currentConversationId = null; + try { + window.currentConversationId = ''; + } catch (e) { /* ignore */ } const messagesDiv = document.getElementById('chat-messages'); if (messagesDiv) messagesDiv.innerHTML = ''; const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; @@ -7027,3 +7357,9 @@ document.addEventListener('DOMContentLoaded', async () => { } }); }); + +// 顶层 async function 不会自动挂到 window,hitl 等脚本依赖 window.loadConversation +if (typeof window !== 'undefined') { + window.loadConversation = loadConversation; + window.startNewConversation = startNewConversation; +} diff --git a/web/static/js/hitl.js b/web/static/js/hitl.js new file mode 100644 index 00000000..d7c9c97e --- /dev/null +++ b/web/static/js/hitl.js @@ -0,0 +1,390 @@ +function hitlModeNormalize(m) { + let v = String(m || '').trim().toLowerCase().replace(/-/g, '_'); + if (v === 'feedback' || v === 'followup') { + v = 'approval'; + } + const allowed = ['off', 'approval', 'review_edit']; + return allowed.indexOf(v) >= 0 ? v : 'off'; +} + +function hitlEffectiveEnabled(cfg) { + if (!cfg) return false; + if (cfg.enabled === true) return true; + return hitlModeNormalize(cfg.mode) !== 'off'; +} + +function readHitlLocalStorageConv(conversationId) { + if (!conversationId) return null; + try { + const key = 'cyberstrike-chat-hitl:' + String(conversationId).trim(); + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch (e) { + return null; + } +} + +function hitlSensitiveToolsToArray(config) { + if (Array.isArray(config && config.sensitiveTools)) return config.sensitiveTools; + const s = config && config.sensitiveTools; + if (typeof s === 'string') { + return s.split(/[,\n\r]+/).map(function (x) { return x.trim(); }).filter(Boolean); + } + return []; +} + +function getCurrentConversationIdForHitl() { + if (typeof window.currentConversationId === 'string' && window.currentConversationId) { + return window.currentConversationId; + } + const active = document.querySelector('.conversation-item.active'); + if (active && active.dataset && active.dataset.conversationId) { + return active.dataset.conversationId; + } + return ''; +} + +async function fetchHitlConversationConfig(conversationId) { + if (!conversationId) return null; + const resp = await hitlApiFetch('/api/hitl/config/' + encodeURIComponent(conversationId), { credentials: 'same-origin' }); + if (!resp.ok) return null; + const data = await resp.json(); + if (!data || !data.hitl) return null; + return { + hitl: data.hitl, + hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : [] + }; +} + +/** 无会话时:将免审批工具合并进服务端 config.yaml,返回更新后的全局白名单数组 */ +async function mergeHitlGlobalToolWhitelist(sensitiveTools) { + const list = Array.isArray(sensitiveTools) ? sensitiveTools : []; + const resp = await hitlApiFetch('/api/hitl/tool-whitelist', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sensitiveTools: list }) + }); + if (!resp.ok) { + const msg = await readHitlApiError(resp); + throw new Error(msg || ('HTTP ' + resp.status)); + } + const data = await resp.json(); + if (data && Array.isArray(data.hitlGlobalToolWhitelist)) { + return data.hitlGlobalToolWhitelist; + } + return []; +} + +async function saveHitlConversationConfig(conversationId, config) { + if (!conversationId || !config) return false; + const mode = hitlModeNormalize(config.mode || 'off'); + const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off'); + const sensitiveTools = hitlSensitiveToolsToArray(config); + const resp = await hitlApiFetch('/api/hitl/config', { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversationId: conversationId, + enabled: enabled, + mode: mode, + sensitiveTools: sensitiveTools, + timeoutSeconds: config.timeoutSeconds || 300 + }) + }); + if (!resp.ok) { + const msg = await readHitlApiError(resp); + throw new Error(msg || ('HTTP ' + resp.status)); + } + return true; +} + +async function syncHitlConfigFromServer(conversationId) { + const pack = await fetchHitlConversationConfig(conversationId); + if (!pack || !pack.hitl) return; + const cfg = pack.hitl; + const globalWL = pack.hitlGlobalToolWhitelist || []; + if (typeof window !== 'undefined') { + window.csaiHitlGlobalToolWhitelist = globalWL; + } + const strip = typeof window.hitlStripGlobalToolsFromFormString === 'function' + ? window.hitlStripGlobalToolsFromFormString + : function (_g, s) { return typeof s === 'string' ? s.trim() : ''; }; + + let merged = cfg; + if (!hitlEffectiveEnabled(cfg)) { + const local = readHitlLocalStorageConv(conversationId); + const localMode = local && local.mode ? hitlModeNormalize(local.mode) : 'off'; + if (localMode !== 'off') { + let localToolsStr = typeof local.sensitiveTools === 'string' ? local.sensitiveTools : ''; + localToolsStr = strip(globalWL, localToolsStr); + merged = { + enabled: true, + mode: localMode, + sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean), + timeoutSeconds: cfg.timeoutSeconds || 300 + }; + saveHitlConversationConfig(conversationId, { + mode: localMode, + sensitiveTools: localToolsStr, + enabled: true, + timeoutSeconds: merged.timeoutSeconds + }).catch(function (err) { + console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err); + }); + } else { + const gl = typeof window.getHitlLastGlobalConfig === 'function' ? window.getHitlLastGlobalConfig() : null; + const glMode = gl && gl.mode ? hitlModeNormalize(gl.mode) : 'off'; + if (glMode !== 'off') { + let glToolsStr = typeof gl.sensitiveTools === 'string' ? gl.sensitiveTools : ''; + glToolsStr = strip(globalWL, glToolsStr); + merged = { + enabled: true, + mode: glMode, + sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean), + timeoutSeconds: cfg.timeoutSeconds || 300 + }; + saveHitlConversationConfig(conversationId, { + mode: glMode, + sensitiveTools: glToolsStr, + enabled: true, + timeoutSeconds: merged.timeoutSeconds + }).catch(function (err) { + console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err); + }); + } + } + } + const uiMode = hitlEffectiveEnabled(merged) ? hitlModeNormalize(merged.mode) : 'off'; + const rawArr = Array.isArray(merged.sensitiveTools) + ? merged.sensitiveTools + : hitlSensitiveToolsToArray({ sensitiveTools: merged.sensitiveTools }); + const sessionOnlyStr = strip(globalWL, rawArr.join(', ')); + const normalizedCfg = Object.assign({}, merged, { + mode: uiMode, + sensitiveTools: sessionOnlyStr + }); + if (typeof window.saveHitlConfigForConversation === 'function') { + window.saveHitlConfigForConversation(conversationId, normalizedCfg); + } else { + try { + localStorage.setItem('chat_hitl_config_' + conversationId, JSON.stringify(normalizedCfg)); + } catch (e) {} + } + if (typeof window.applyHitlConfigToUI === 'function') { + window.applyHitlConfigToUI(normalizedCfg); + } + reconcileHitlUiState(); +} + +async function syncHitlConfigToServerByCurrentConversation() { + const conversationId = getCurrentConversationIdForHitl(); + if (!conversationId) return; + if (typeof window.readHitlConfigFromForm !== 'function') return; + const cfg = window.readHitlConfigFromForm(); + await saveHitlConversationConfig(conversationId, cfg); +} + +function reconcileHitlUiState() { + if (typeof window.readHitlConfigFromForm === 'function' && typeof window.updateHitlStatusUI === 'function') { + try { + const cfg = window.readHitlConfigFromForm(); + window.updateHitlStatusUI(cfg); + } catch (e) {} + } +} + +let hitlFollowRunSeq = 0; + +/** + * 审批提交后原 SSE 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。 + */ +async function followAgentRunAfterHitlDecision(conversationId) { + if (!conversationId || typeof apiFetch !== 'function') return; + if (typeof window.attachRunningTaskEventStream === 'function') { + try { + const attached = await window.attachRunningTaskEventStream(conversationId); + if (attached) return; + } catch (e) { + console.warn('attachRunningTaskEventStream', e); + } + } + var mySeq = ++hitlFollowRunSeq; + var intervalMs = 2000; + var firstDelayMs = 500; + var maxMs = 30 * 60 * 1000; + var deadline = Date.now() + maxMs; + + function taskStillActive(cid) { + return apiFetch('/api/agent-loop/tasks').then(function (r) { + if (!r.ok) return false; + return r.json().then(function (j) { + var tasks = (j && j.tasks) ? j.tasks : []; + return tasks.some(function (t) { + return t && t.conversationId === cid && (t.status === 'running' || t.status === 'cancelling'); + }); + }); + }).catch(function () { return false; }); + } + + await new Promise(function (r) { setTimeout(r, firstDelayMs); }); + + while (mySeq === hitlFollowRunSeq) { + if (Date.now() > deadline) { + if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) { + await window.loadConversation(conversationId); + } + if (typeof loadActiveTasks === 'function') loadActiveTasks(); + return; + } + try { + var active = await taskStillActive(conversationId); + var onThisConv = (typeof window.currentConversationId === 'string' && window.currentConversationId === conversationId); + if (onThisConv && typeof window.refreshLastAssistantProcessDetails === 'function') { + await window.refreshLastAssistantProcessDetails(conversationId); + } + if (!active) { + await new Promise(function (r) { setTimeout(r, 450); }); + if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) { + await window.loadConversation(conversationId); + } + if (typeof loadActiveTasks === 'function') loadActiveTasks(); + return; + } + } catch (e) { + console.warn('followAgentRunAfterHitlDecision', e); + } + await new Promise(function (r) { setTimeout(r, intervalMs); }); + } +} + +async function refreshHitlPending() { + const container = document.getElementById('hitl-pending-list'); + if (!container) return; + container.innerHTML = '
Loading...
'; + try { + const resp = await hitlApiFetch('/api/hitl/pending', { credentials: 'same-origin' }); + if (!resp.ok) { + throw new Error('request failed'); + } + const data = await resp.json(); + const items = Array.isArray(data.items) ? data.items : []; + if (!items.length) { + container.innerHTML = '
暂无待审批项
'; + return; + } + container.innerHTML = items.map(function (item) { + const payload = String(item.payload || ''); + const preview = payload.length > 280 ? (payload.slice(0, 280) + '...') : payload; + const mode = String(item.mode || '').trim().toLowerCase(); + const allowEdit = mode === 'review_edit'; + return ( + '
' + + '
' + + '' + escapeHtml(item.toolName || '-') + '' + + '' + escapeHtml(item.mode || '-') + '' + + '
' + + '
conversation: ' + escapeHtml(item.conversationId || '-') + '
' + + '
' + escapeHtml(preview) + '
' + + (allowEdit + ? ('
审查编辑模式:可填写 JSON 对象覆盖参数,示例:{"command":"ls -la"}
' + + '') + : '
审批模式:仅通过/拒绝,不支持改参。
') + + '
' + + '' + + '' + + '
' + + '
' + ); + }).join(''); + } catch (e) { + container.innerHTML = '
加载失败
'; + } +} + +async function submitHitlDecision(interruptId, decision, conversationIdOpt) { + const comment = prompt('审批备注(可选)') || ''; + let editedArguments = null; + const editBox = document.getElementById('hitl-edit-' + interruptId); + if (editBox && editBox.value && editBox.value.trim()) { + try { + editedArguments = JSON.parse(editBox.value.trim()); + } catch (e) { + alert('JSON 参数格式错误'); + return; + } + } + const convFollow = conversationIdOpt || getCurrentConversationIdForHitl(); + return submitHitlDecisionWithPayload(interruptId, decision, comment, editedArguments, convFollow); +} + +async function submitHitlDecisionWithPayload(interruptId, decision, comment, editedArguments, conversationIdForFollow) { + const resp = await hitlApiFetch('/api/hitl/decision', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interruptId: interruptId, decision: decision, comment: comment, editedArguments: editedArguments }) + }); + if (!resp.ok) { + const errText = await readHitlApiError(resp); + if (resp.status === 409 && (errText.indexOf('already resolved') >= 0 || errText.indexOf('not found') >= 0)) { + refreshHitlPending(); + return true; + } + alert('提交失败:' + errText); + return false; + } + refreshHitlPending(); + const cid = conversationIdForFollow || getCurrentConversationIdForHitl(); + if (cid) { + followAgentRunAfterHitlDecision(cid); + } + return true; +} + +async function hitlApiFetch(url, options) { + if (typeof apiFetch === 'function') { + return apiFetch(url, options || {}); + } + return fetch(url, options || {}); +} + +async function readHitlApiError(resp) { + try { + const data = await resp.json(); + if (data && typeof data.error === 'string' && data.error.trim()) return data.error.trim(); + return 'HTTP ' + resp.status; + } catch (e) { + return 'HTTP ' + resp.status; + } +} + +window.refreshHitlPending = refreshHitlPending; +window.submitHitlDecision = submitHitlDecision; +window.submitHitlDecisionWithPayload = submitHitlDecisionWithPayload; +window.followAgentRunAfterHitlDecision = followAgentRunAfterHitlDecision; + +window.addEventListener('hitl-interrupt', function () { + if (typeof window.currentPage === 'function' && window.currentPage() === 'hitl') { + refreshHitlPending(); + } +}); + +window.addEventListener('pageshow', function () { + setTimeout(reconcileHitlUiState, 0); +}); +document.addEventListener('DOMContentLoaded', function () { + setTimeout(reconcileHitlUiState, 0); +}); + +// 由 applyHitlSidebarConfig 调用,将侧栏配置同步到后端 +window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation; +window.saveHitlConversationConfig = saveHitlConversationConfig; +window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist; + +// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发 +window.syncHitlConfigFromServer = syncHitlConfigFromServer; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 74008513..a94db21d 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -843,6 +843,33 @@ function applyBackendMessageIdToLastUser(backendMessageId) { } } +function taskReplayProgressId(conversationId) { + return 'task-ev-' + String(conversationId || '').replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +function clearCsTaskReplay() { + window.csTaskReplay = null; +} + +function beginCsTaskReplay(progressId, assistantDomId, conversationId) { + window.csTaskReplay = { + progressId: progressId, + assistantDomId: assistantDomId, + conversationId: conversationId, + timelineHostId: 'process-details-' + assistantDomId + '-timeline' + }; + registerProgressTask(progressId, conversationId); +} + +function resolveStreamTimeline(progressId) { + let timeline = document.getElementById(progressId + '-timeline'); + const r = window.csTaskReplay; + if (!timeline && r && r.progressId === progressId && r.timelineHostId) { + timeline = document.getElementById(r.timelineHostId); + } + return timeline; +} + // 处理流式事件 function handleStreamEvent(event, progressElement, progressId, getAssistantId, setAssistantId, getMcpIds, setMcpIds) { @@ -858,7 +885,7 @@ function handleStreamEvent(event, progressElement, progressId, return; } - const timeline = document.getElementById(progressId + '-timeline'); + const timeline = resolveStreamTimeline(progressId); if (!timeline) return; // 终态事件(error/cancelled)优先复用现有助手消息,避免重复追加相同报错 @@ -1049,6 +1076,32 @@ function handleStreamEvent(event, progressElement, progressId, }); break; + case 'hitl_interrupt': + const hitlItemId = addTimelineItem(timeline, 'warning', { + title: '🧑‍⚖️ HITL', + message: event.message, + data: event.data + }); + renderInlineHitlApproval(hitlItemId, event.data || {}); + try { + window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} })); + } catch (e) {} + break; + case 'hitl_resumed': + addTimelineItem(timeline, 'progress', { + title: '✅ HITL', + message: event.message, + data: event.data + }); + break; + case 'hitl_rejected': + addTimelineItem(timeline, 'error', { + title: '⛔ HITL', + message: event.message, + data: event.data + }); + break; + case 'eino_recovery': { const d = event.data || {}; const runIdx = d.runIndex != null ? d.runIndex : (d.einoRetry != null ? d.einoRetry + 1 : 1); @@ -1492,8 +1545,12 @@ function handleStreamEvent(event, progressElement, progressId, // so the copied timeline HTML reflects the final status. finalizeOutstandingToolCallsForProgress(progressId, 'failed'); - // 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整) - integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds); + const replayCtx = window.csTaskReplay; + const directReplay = replayCtx && replayCtx.progressId === progressId; + if (!directReplay) { + // 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整) + integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds); + } responseStreamStateByProgressId.delete(progressId); const respMid = responseData.messageId; @@ -1502,7 +1559,7 @@ function handleStreamEvent(event, progressElement, progressId, } setTimeout(() => { - collapseAllProgressDetails(assistantIdFinal, progressId); + collapseAllProgressDetails(assistantIdFinal, directReplay ? null : progressId); }, 3000); setTimeout(() => { @@ -1571,6 +1628,9 @@ function handleStreamEvent(event, progressElement, progressId, toolResultStreamStateByKey.delete(key); } } + if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) { + clearCsTaskReplay(); + } // 完成,更新进度标题(如果进度消息还存在) const doneTitle = document.querySelector(`#${progressId} .progress-title`); if (doneTitle) { @@ -1625,6 +1685,349 @@ function handleStreamEvent(event, progressElement, progressId, scrollChatMessagesToBottomIfPinned(streamScrollWasPinned); } +function renderInlineHitlApproval(itemId, data) { + const item = document.getElementById(itemId); + if (!item || !data || !data.interruptId) return; + let contentEl = item.querySelector('.timeline-item-content'); + if (!contentEl) { + // warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器 + contentEl = document.createElement('div'); + contentEl.className = 'timeline-item-content'; + item.appendChild(contentEl); + } + const existingPanel = contentEl.querySelector('.hitl-inline-approval'); + if (existingPanel) { + existingPanel.remove(); + } + + const payload = data.payload && typeof data.payload === 'object' ? data.payload : {}; + const toolName = data.toolName || payload.toolName || '-'; + let mode = String(data.mode || '').trim().toLowerCase(); + if (mode === 'feedback' || mode === 'followup') { + mode = 'approval'; + } + const allowEdit = mode === 'review_edit'; + const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {}; + const argsJSON = JSON.stringify(argsObj, null, 2); + + const panel = document.createElement('div'); + panel.className = 'hitl-inline-approval'; + panel.innerHTML = ` +
${escapeHtml(toolName)} 待人工审批。模式:${escapeHtml(mode || '-')}。
+ ${allowEdit + ? `
审查编辑参数(JSON,可选):留空表示沿用原参数。
+ ` + : '
当前模式不支持改参,仅可通过/拒绝。
' + } +
备注(可选):建议写审批依据。
+ +
+ + +
+
+ `; + contentEl.appendChild(panel); + + const approveBtn = panel.querySelector('.hitl-inline-approve'); + const rejectBtn = panel.querySelector('.hitl-inline-reject'); + const commentInput = panel.querySelector('.hitl-inline-comment'); + const editInput = panel.querySelector('.hitl-inline-edit'); + const statusEl = panel.querySelector('.hitl-inline-status'); + + const setBusy = function (busy) { + approveBtn.disabled = busy; + rejectBtn.disabled = busy; + }; + + const submit = async function (decision) { + setBusy(true); + let editedArgs = null; + if (allowEdit && editInput) { + const raw = String(editInput.value || '').trim(); + if (raw) { + try { + editedArgs = JSON.parse(raw); + } catch (e) { + statusEl.textContent = 'JSON 参数格式错误'; + setBusy(false); + return; + } + } + } + const comment = String(commentInput.value || '').trim(); + try { + if (typeof window.submitHitlDecisionWithPayload === 'function') { + const convFollow = data.conversationId || (typeof window.currentConversationId === 'string' ? window.currentConversationId : ''); + const ok = await window.submitHitlDecisionWithPayload(data.interruptId, decision, comment, (decision === 'approve' && allowEdit) ? editedArgs : null, convFollow); + if (!ok) { + statusEl.textContent = '提交失败,请重试'; + setBusy(false); + return; + } + } else { + statusEl.textContent = '审批函数未加载'; + setBusy(false); + return; + } + statusEl.textContent = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...'; + panel.classList.add('hitl-inline-done'); + } catch (e) { + statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error'); + setBusy(false); + } + }; + + approveBtn.onclick = function () { submit('approve'); }; + rejectBtn.onclick = function () { submit('reject'); }; +} + +function hitlEscapeAttrSelector(val) { + const s = String(val); + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(s); + } + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function expandProcessDetailsTimeline(assistantMessageId) { + if (!assistantMessageId) return; + const detailsContainer = document.getElementById('process-details-' + assistantMessageId); + if (!detailsContainer) return; + const timeline = detailsContainer.querySelector('.progress-timeline'); + if (!timeline) return; + timeline.classList.add('expanded'); + const collapseT = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情'; + document.querySelectorAll('#' + hitlEscapeAttrSelector(assistantMessageId) + ' .process-detail-btn').forEach(function (btn) { + btn.innerHTML = '' + collapseT + ''; + }); + setTimeout(function () { + detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); +} + +function findLastAssistantMessageElInChat() { + const nodes = document.querySelectorAll('#chat-messages .message.assistant'); + for (let i = nodes.length - 1; i >= 0; i--) { + const el = nodes[i]; + if (el && el.dataset && el.dataset.backendMessageId) return el; + } + return null; +} + +/** + * 刷新或切换会话后:根据待审批记录恢复时间线里的内联审批入口,并展开详情区。 + */ +async function restoreHitlInlineForConversation(conversationId) { + if (!conversationId || typeof apiFetch !== 'function') return; + if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) { + return; + } + try { + const resp = await apiFetch('/api/hitl/pending?conversationId=' + encodeURIComponent(conversationId) + '&status=pending&pageSize=50'); + if (!resp.ok) return; + const data = await resp.json().catch(function () { return {}; }); + const items = Array.isArray(data.items) ? data.items : []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + let backendMsgId = item.messageId != null ? String(item.messageId).trim() : ''; + let msgEl = null; + if (backendMsgId) { + msgEl = document.querySelector('#chat-messages [data-backend-message-id="' + hitlEscapeAttrSelector(backendMsgId) + '"]'); + } + if (!msgEl) { + msgEl = findLastAssistantMessageElInChat(); + if (msgEl && msgEl.dataset && msgEl.dataset.backendMessageId) { + backendMsgId = String(msgEl.dataset.backendMessageId).trim(); + } + } + if (!msgEl || !msgEl.id || !backendMsgId) continue; + const clientMsgId = msgEl.id; + const detailsContainer = document.getElementById('process-details-' + clientMsgId); + if (!detailsContainer) continue; + if (detailsContainer.dataset.lazyNotLoaded === '1' && detailsContainer.dataset.loaded !== '1') { + try { + detailsContainer.dataset.loading = '1'; + const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); + const j = await res.json().catch(function () { return {}; }); + if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status)); + const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; + if (typeof renderProcessDetails === 'function') { + renderProcessDetails(clientMsgId, details); + } + } catch (e) { + console.error('加载过程详情失败(HITL 恢复):', e); + } finally { + detailsContainer.dataset.loading = '0'; + } + } + expandProcessDetailsTimeline(clientMsgId); + let payloadObj = {}; + try { + payloadObj = JSON.parse(String(item.payload || '{}')); + } catch (e) { + payloadObj = {}; + } + const hitlData = { + interruptId: item.id, + mode: item.mode, + toolName: item.toolName, + toolCallId: item.toolCallId, + payload: payloadObj, + conversationId: item.conversationId || conversationId + }; + let hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]'); + if (!hitlItemEl && item.toolCallId) { + hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]'); + } + if (!hitlItemEl && item.toolName) { + const want = String(item.toolName).trim().toLowerCase(); + const shortWant = want.indexOf('::') >= 0 ? want.split('::').pop() : want; + const calls = detailsContainer.querySelectorAll('.timeline-item-tool_call'); + for (let j = calls.length - 1; j >= 0; j--) { + const tn = String(calls[j].dataset.toolName || '').trim().toLowerCase(); + const shortTn = tn.indexOf('::') >= 0 ? tn.split('::').pop() : tn; + const match = want && (tn === want || tn.endsWith('::' + shortWant) || shortTn === shortWant); + if (match) { + hitlItemEl = calls[j]; + break; + } + } + } + if (!hitlItemEl) continue; + renderInlineHitlApproval(hitlItemEl.id, hitlData); + } + } catch (e) { + console.error('restoreHitlInlineForConversation failed', e); + } +} + +window.expandProcessDetailsTimeline = expandProcessDetailsTimeline; +window.restoreHitlInlineForConversation = restoreHitlInlineForConversation; + +/** + * 无 SSE 时(例如刷新页面后):从 DB 拉取最后一条助手消息的过程详情并重绘时间线,便于审批通过后仍能看到执行进展。 + */ +async function refreshLastAssistantProcessDetails(conversationId) { + if (!conversationId || typeof apiFetch !== 'function') return; + if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) return; + const msgEl = findLastAssistantMessageElInChat(); + if (!msgEl || !msgEl.dataset.backendMessageId || !msgEl.id) return; + const backendId = String(msgEl.dataset.backendMessageId).trim(); + const clientId = msgEl.id; + const detailsContainer = document.getElementById('process-details-' + clientId); + let wasExpanded = false; + if (detailsContainer) { + const tl = detailsContainer.querySelector('.progress-timeline'); + wasExpanded = !!(tl && tl.classList.contains('expanded')); + } + try { + const res = await apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details'); + const j = await res.json().catch(function () { return {}; }); + if (!res.ok) return; + const details = Array.isArray(j.processDetails) ? j.processDetails : []; + if (typeof renderProcessDetails === 'function') { + renderProcessDetails(clientId, details); + } + if (wasExpanded) { + expandProcessDetailsTimeline(clientId); + } + } catch (e) { + console.warn('refreshLastAssistantProcessDetails', e); + } +} + +window.refreshLastAssistantProcessDetails = refreshLastAssistantProcessDetails; + +/** + * 订阅运行中任务的 SSE 镜像(GET /api/agent-loop/task-events),用于 HITL 通过后主连接已断开时接续 UI。 + */ +async function attachRunningTaskEventStream(conversationId) { + if (!conversationId || typeof apiFetch !== 'function') return false; + try { + const check = await apiFetch('/api/agent-loop/tasks'); + if (!check.ok) return false; + const j = await check.json().catch(function () { return {}; }); + const active = (j.tasks || []).some(function (t) { + return t && t.conversationId === conversationId && (t.status === 'running' || t.status === 'cancelling'); + }); + if (!active) return false; + + const asEl = findLastAssistantMessageElInChat(); + if (!asEl || !asEl.id) return false; + const backendId = asEl.dataset && asEl.dataset.backendMessageId; + if (backendId && typeof renderProcessDetails === 'function') { + const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details'); + const jd = await res.json().catch(function () { return {}; }); + if (res.ok && Array.isArray(jd.processDetails)) { + renderProcessDetails(asEl.id, jd.processDetails); + } + } + expandProcessDetailsTimeline(asEl.id); + + const progressId = taskReplayProgressId(conversationId); + beginCsTaskReplay(progressId, asEl.id, conversationId); + + const url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId); + const response = await apiFetch(url, { + method: 'GET', + headers: { Accept: 'text/event-stream' } + }); + if (!response.ok) { + clearCsTaskReplay(); + if (progressTaskState.has(progressId)) { + progressTaskState.delete(progressId); + } + return false; + } + + let mcpIds = []; + const assistantDomId = asEl.id; + const getAssistantIdFn = function () { return assistantDomId; }; + const setAssistantIdFn = function () {}; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + if (line.indexOf('data: ') === 0) { + try { + const eventData = JSON.parse(line.slice(6)); + handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; }); + } catch (e) { + console.error('task-events parse', e); + } + } + } + } + if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) { + clearCsTaskReplay(); + } + if (progressTaskState.has(progressId)) { + finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成'); + } + if (typeof loadActiveTasks === 'function') loadActiveTasks(); + if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) { + await window.loadConversation(conversationId); + } + return true; + } catch (e) { + console.warn('attachRunningTaskEventStream', e); + clearCsTaskReplay(); + return false; + } +} + +window.attachRunningTaskEventStream = attachRunningTaskEventStream; +window.taskReplayProgressId = taskReplayProgressId; + // 更新工具调用状态 function updateToolCallStatus(toolCallId, status) { const mapping = toolCallStatusMap.get(toolCallId); @@ -1697,6 +2100,12 @@ function addTimelineItem(timeline, type, options) { item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : ''; item.dataset.toolIndex = (d.index != null) ? String(d.index) : '0'; item.dataset.toolTotal = (d.total != null) ? String(d.total) : '0'; + if (d.toolCallId != null && String(d.toolCallId).trim() !== '') { + item.dataset.toolCallId = String(d.toolCallId).trim(); + } + } + if (type === 'hitl_interrupt' && options.data && options.data.interruptId != null && String(options.data.interruptId).trim() !== '') { + item.dataset.hitlInterruptId = String(options.data.interruptId).trim(); } if (type === 'tool_result' && options.data) { const d = options.data; @@ -1934,6 +2343,8 @@ async function cancelActiveTask(conversationId, button) { } } +let monitorPanelFetchSeq = 0; + // 监控面板状态 const monitorState = { executions: [], @@ -2004,6 +2415,7 @@ async function refreshMonitorPanel(page = null) { const execContainer = document.getElementById('monitor-executions'); try { + const mySeq = ++monitorPanelFetchSeq; // 如果指定了页码,使用指定页码,否则使用当前页码 const currentPage = page !== null ? page : monitorState.pagination.page; const pageSize = monitorState.pagination.pageSize; @@ -2028,6 +2440,9 @@ async function refreshMonitorPanel(page = null) { if (!response.ok) { throw new Error(result.error || '获取监控数据失败'); } + if (mySeq !== monitorPanelFetchSeq) { + return; + } monitorState.executions = Array.isArray(result.executions) ? result.executions : []; monitorState.stats = result.stats || {}; @@ -2088,6 +2503,7 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter = const execContainer = document.getElementById('monitor-executions'); try { + const mySeq = ++monitorPanelFetchSeq; const currentPage = 1; // 筛选时重置到第一页 const pageSize = monitorState.pagination.pageSize; @@ -2105,6 +2521,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter = if (!response.ok) { throw new Error(result.error || '获取监控数据失败'); } + if (mySeq !== monitorPanelFetchSeq) { + return; + } monitorState.executions = Array.isArray(result.executions) ? result.executions : []; monitorState.stats = result.stats || {}; diff --git a/web/static/js/router.js b/web/static/js/router.js index aa1496d7..a983f9ff 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -1,6 +1,48 @@ // 页面路由管理 let currentPage = 'dashboard'; +/** 仅当停留在 chat 时保留 ?conversation= 等查询串,其它页面只使用 pageId */ +function buildHashForPage(pageId) { + if (pageId !== 'chat') { + return pageId; + } + const full = window.location.hash.slice(1); + const parts = full.split('?'); + const curPage = parts[0]; + const q = parts.length > 1 ? parts.slice(1).join('?') : ''; + if (curPage === 'chat' && q) { + return 'chat?' + q; + } + return 'chat'; +} + +let chatConversationFromHashSeq = 0; +function scheduleChatConversationFromHash(delayMs) { + const hash = window.location.hash.slice(1); + const hashParts = hash.split('?'); + if (hashParts[0] !== 'chat' || hashParts.length < 2) { + return; + } + const params = new URLSearchParams(hashParts.slice(1).join('?')); + const conversationId = params.get('conversation'); + if (!conversationId) { + return; + } + const token = ++chatConversationFromHashSeq; + setTimeout(() => { + if (token !== chatConversationFromHashSeq) { + return; + } + if (typeof loadConversation === 'function') { + loadConversation(conversationId); + } else if (typeof window.loadConversation === 'function') { + window.loadConversation(conversationId); + } else { + console.warn('loadConversation function not found'); + } + }, delayMs); +} + // 初始化路由 function initRouter() { // 从URL hash读取页面(如果有) @@ -8,25 +50,10 @@ 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', 'agents-management', 'settings', 'tasks'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', '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参数,加载对应对话 - if (pageId === 'chat' && hashParts.length > 1) { - const params = new URLSearchParams(hashParts[1]); - const conversationId = params.get('conversation'); - if (conversationId) { - setTimeout(() => { - // 尝试多种方式调用loadConversation - if (typeof loadConversation === 'function') { - loadConversation(conversationId); - } else if (typeof window.loadConversation === 'function') { - window.loadConversation(conversationId); - } else { - console.warn('loadConversation function not found'); - } - }, 500); - } + if (pageId === 'chat') { + scheduleChatConversationFromHash(500); } return; } @@ -49,8 +76,10 @@ function switchPage(pageId) { targetPage.classList.add('active'); currentPage = pageId; - // 更新URL hash - window.location.hash = pageId; + const newHash = buildHashForPage(pageId); + if (window.location.hash.slice(1) !== newHash) { + window.location.hash = newHash; + } // 更新导航状态 updateNavState(pageId); @@ -247,6 +276,11 @@ async function initPage(pageId) { // 恢复对话列表折叠状态(从其他页返回时保持用户选择) initConversationSidebarState(); break; + case 'hitl': + if (typeof refreshHitlPending === 'function') { + refreshHitlPending(); + } + break; case 'info-collect': // 信息收集页面 if (typeof initInfoCollectPage === 'function') { @@ -379,44 +413,13 @@ 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', 'agents-management', 'settings'].includes(pageId)) { + if (pageId && ['chat', 'hitl', '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参数,加载对应对话 - if (pageId === 'chat' && hashParts.length > 1) { - const params = new URLSearchParams(hashParts[1]); - const conversationId = params.get('conversation'); - if (conversationId) { - setTimeout(() => { - // 尝试多种方式调用loadConversation - if (typeof loadConversation === 'function') { - loadConversation(conversationId); - } else if (typeof window.loadConversation === 'function') { - window.loadConversation(conversationId); - } else { - console.warn('loadConversation function not found'); - } - }, 200); - } + if (pageId === 'chat') { + scheduleChatConversationFromHash(200); } } }); - - // 页面加载时也检查hash参数 - const hash = window.location.hash.slice(1); - if (hash) { - const hashParts = hash.split('?'); - const pageId = hashParts[0]; - if (pageId === 'chat' && hashParts.length > 1) { - const params = new URLSearchParams(hashParts[1]); - const conversationId = params.get('conversation'); - if (conversationId && typeof loadConversation === 'function') { - setTimeout(() => { - loadConversation(conversationId); - }, 500); - } - } - } }); // 切换侧边栏折叠/展开 diff --git a/web/static/js/tasks.js b/web/static/js/tasks.js index 79eec74d..0056be48 100644 --- a/web/static/js/tasks.js +++ b/web/static/js/tasks.js @@ -1666,12 +1666,24 @@ function startBatchQueueRefresh(queueId) { if ((addModal && addModal.style.display === 'block') || hasInlineEdit) { return; } - if (batchQueuesState.currentQueueId === queueId) { - showBatchQueueDetail(queueId); - refreshBatchQueues(); - } else { - stopBatchQueueRefresh(); + if (batchQueuesState._bqDetailRefreshing) { + return; } + if (batchQueuesState.currentQueueId !== queueId) { + stopBatchQueueRefresh(); + return; + } + batchQueuesState._bqDetailRefreshing = true; + (async () => { + try { + await showBatchQueueDetail(queueId); + await refreshBatchQueues(); + } catch (e) { + console.warn('批量队列定时刷新失败:', e); + } finally { + batchQueuesState._bqDetailRefreshing = false; + } + })(); }, 3000); // 每3秒刷新一次 } diff --git a/web/templates/index.html b/web/templates/index.html index aae630fc..069838d7 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -114,6 +114,17 @@ 对话 + +
+
+
+ +
+ 人机协同 + 审批与白名单 +
+
+ +
+
+
+
+ + +
+
+ + +

每行一个或逗号分隔;与 config 中全局白名单合并展示。

+
+
+
@@ -676,6 +722,21 @@ +
+ +
+
+

待处理中断

+
+
+
+
+