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 = '' + escapeHtml(preview) + '' + + (allowEdit + ? ('