Add files via upload

This commit is contained in:
公明
2026-04-24 01:50:55 +08:00
committed by GitHub
parent 38169abc4b
commit 943a3b2646
9 changed files with 1777 additions and 231 deletions
+272
View File
@@ -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);
}
+28 -2
View File
@@ -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...",
+28 -2
View File
@@ -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模型...",
+500 -164
View File
@@ -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 = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
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 = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
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 不会自动挂到 windowhitl 等脚本依赖 window.loadConversation
if (typeof window !== 'undefined') {
window.loadConversation = loadConversation;
window.startNewConversation = startNewConversation;
}
+390
View File
@@ -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 = '<div class="loading-spinner">Loading...</div>';
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 = '<div class="empty-state">暂无待审批项</div>';
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 (
'<div class="hitl-pending-item">' +
'<div class="hitl-pending-item-header">' +
'<strong>' + escapeHtml(item.toolName || '-') + '</strong>' +
'<span>' + escapeHtml(item.mode || '-') + '</span>' +
'</div>' +
'<div><small>conversation: ' + escapeHtml(item.conversationId || '-') + '</small></div>' +
'<pre style="white-space:pre-wrap;max-height:160px;overflow:auto;">' + escapeHtml(preview) + '</pre>' +
(allowEdit
? ('<div class="hitl-input-help">审查编辑模式:可填写 JSON 对象覆盖参数,示例:{"command":"ls -la"}</div>' +
'<textarea id="hitl-edit-' + escapeHtml(String(item.id || '')) + '" class="hitl-edit-args" placeholder=\'{"command":"ls -la"}\'></textarea>')
: '<div class="hitl-input-help">审批模式:仅通过/拒绝,不支持改参。</div>') +
'<div class="hitl-pending-actions">' +
'<button class="btn-primary" onclick="submitHitlDecision(' + JSON.stringify(String(item.id || '')) + ',\'approve\',' + JSON.stringify(String(item.conversationId || '')) + ')">通过</button>' +
'<button class="btn-secondary" onclick="submitHitlDecision(' + JSON.stringify(String(item.id || '')) + ',\'reject\',' + JSON.stringify(String(item.conversationId || '')) + ')">拒绝</button>' +
'</div>' +
'</div>'
);
}).join('');
} catch (e) {
container.innerHTML = '<div class="empty-state">加载失败</div>';
}
}
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;
+423 -4
View File
@@ -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 = `
<div class="hitl-input-help"><strong>${escapeHtml(toolName)}</strong> 待人工审批。模式:${escapeHtml(mode || '-')}。</div>
${allowEdit
? `<div class="hitl-input-help">审查编辑参数(JSON,可选):留空表示沿用原参数。</div>
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>`
: '<div class="hitl-input-help">当前模式不支持改参,仅可通过/拒绝。</div>'
}
<div class="hitl-input-help">备注(可选):建议写审批依据。</div>
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
<div class="hitl-pending-actions">
<button class="btn-secondary hitl-inline-reject">拒绝</button>
<button class="btn-primary hitl-inline-approve">通过</button>
</div>
<div class="hitl-input-help hitl-inline-status"></div>
`;
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 = '<span>' + collapseT + '</span>';
});
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 || {};
+57 -54
View File
@@ -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);
}
}
}
});
// 切换侧边栏折叠/展开
+17 -5
View File
@@ -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秒刷新一次
}
+62
View File
@@ -114,6 +114,17 @@
<span data-i18n="nav.chat">对话</span>
</div>
</div>
<div class="nav-item" data-page="hitl">
<div class="nav-item-content" data-title="人机协同" onclick="switchPage('hitl')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="7" r="3"></circle>
<circle cx="16" cy="7" r="3"></circle>
<path d="M2 20c0-3 2.5-5 6-5s6 2 6 5"></path>
<path d="M10 20h12"></path>
</svg>
<span data-i18n="nav.hitl">人机协同</span>
</div>
</div>
<div class="nav-item" data-page="info-collect">
<div class="nav-item-content" data-title="信息收集" onclick="switchPage('info-collect')" data-i18n="nav.infoCollect" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -500,6 +511,41 @@
<div id="conversations-list" class="conversations-list"></div>
</div>
</div>
<div class="hitl-sidebar-card" id="hitl-sidebar-card">
<div class="hitl-sidebar-card-header">
<div class="hitl-sidebar-heading">
<span class="hitl-sidebar-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/>
<path d="M9.5 12.5l2 2 3-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<div class="hitl-sidebar-heading-text">
<span class="hitl-sidebar-title" data-i18n="chat.hitlTitle">人机协同</span>
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
</div>
</div>
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
<span data-i18n="chat.hitlApply">应用</span>
</button>
</div>
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
<div class="hitl-sidebar-config">
<div class="hitl-config-field">
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.hitlModeLabel">模式</label>
<select id="hitl-mode-select" class="hitl-config-select">
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
</select>
</div>
<div class="hitl-config-field hitl-config-field--tools">
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
</div>
</div>
</div>
</aside>
<!-- 分组详情页面 -->
@@ -676,6 +722,21 @@
</div>
</div>
<div id="page-hitl" class="page">
<div class="page-header">
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="refreshHitlPending()" data-i18n="common.refresh">刷新</button>
</div>
</div>
<div class="page-content">
<div class="settings-section">
<h3 data-i18n="hitl.pendingTitle">待处理中断</h3>
<div id="hitl-pending-list" class="hitl-pending-list"></div>
</div>
</div>
</div>
<!-- MCP状态监控页面 -->
<div id="page-mcp-monitor" class="page">
<div class="page-header">
@@ -2746,6 +2807,7 @@
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/monitor.js"></script>
<script src="/static/js/chat.js"></script>
<script src="/static/js/hitl.js"></script>
<script src="/static/js/settings.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>