mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-01 23:35:18 +02:00
Add files via upload
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
@@ -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 不会自动挂到 window,hitl 等脚本依赖 window.loadConversation
|
||||
if (typeof window !== 'undefined') {
|
||||
window.loadConversation = loadConversation;
|
||||
window.startNewConversation = startNewConversation;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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秒刷新一次
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user