From 07439bce6e846b4a67ed319def31530b711befa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:54:18 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 414 +++++++++++++++++ web/static/js/chat.js | 45 +- web/static/js/monitor.js | 196 +++++++- web/static/js/roles.js | 23 +- web/static/js/router.js | 10 +- web/static/js/workflows.js | 891 +++++++++++++++++++++++++++++++++++++ web/templates/index.html | 114 +++++ 7 files changed, 1680 insertions(+), 13 deletions(-) create mode 100644 web/static/js/workflows.js diff --git a/web/static/css/style.css b/web/static/css/style.css index 8e82a781..f3f008d3 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -5108,6 +5108,146 @@ html[data-theme="dark"] .openapi-doc-btn:hover { color: var(--text-primary); } +.workflow-agent-io { + display: flex; + flex-direction: column; + gap: 10px; +} + +.workflow-agent-input { + border: 1px solid var(--border-color); + border-radius: 6px; + background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent); +} + +.workflow-agent-input summary { + display: grid; + grid-template-columns: auto auto auto minmax(0, 1fr); + gap: 8px; + align-items: center; + padding: 8px 10px; + cursor: pointer; + list-style: none; + min-width: 0; +} + +.workflow-agent-input summary::-webkit-details-marker { + display: none; +} + +.workflow-agent-input summary::before { + content: '▸'; + color: var(--text-muted); + font-size: 0.75rem; +} + +.workflow-agent-input[open] summary::before { + content: '▾'; +} + +.workflow-agent-io-label { + color: var(--text-primary); + font-size: 0.8125rem; + font-weight: 600; +} + +.workflow-agent-input code { + color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-color) 24%, transparent); + border-radius: 999px; + padding: 2px 8px; + font-size: 0.75rem; + white-space: nowrap; +} + +.workflow-agent-input-summary { + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.workflow-agent-input pre { + margin: 0 10px 10px; + max-height: 180px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-secondary); + padding: 10px; + white-space: pre-wrap; + word-break: break-word; +} + +.workflow-agent-empty { + margin: 0 10px 10px; + color: var(--text-muted); + font-size: 0.8125rem; +} + +.workflow-agent-output { + display: flex; + flex-direction: column; + gap: 6px; +} + +.workflow-agent-output-body { + color: var(--text-secondary); +} + +.workflow-condition-result, +.workflow-branch-detail { + display: flex; + flex-direction: column; + gap: 8px; +} + +.workflow-condition-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.workflow-condition-result code, +.workflow-branch-detail code { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-secondary, rgba(127, 127, 127, 0.12)); + word-break: break-all; +} + +.workflow-condition-branch { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.workflow-condition-branch.is-true { + color: #86efac; + background: rgba(34, 197, 94, 0.15); +} + +.workflow-condition-branch.is-false { + color: #fcd34d; + background: rgba(245, 158, 11, 0.15); +} + +.timeline-item-workflow_branch_taken .timeline-item-title { + color: #86efac; +} + +.timeline-item-workflow_branch_skipped .timeline-item-title { + color: var(--text-secondary); +} + /* 整页 HTML(如 HTTP 探测响应)在时间线中仅以转义文本展示 */ .timeline-item-content .sanitized-raw-html-fallback { max-height: 320px; @@ -30026,3 +30166,277 @@ html[data-theme="dark"] .form-group select { background-repeat: no-repeat !important; background-position: right 12px center !important; } + +/* Workflow drag editor */ +.workflow-page-content { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 320px; + gap: 14px; + min-height: calc(100vh - 150px); +} + +.workflow-sidebar, +.workflow-properties, +.workflow-main { + min-width: 0; +} + +.workflow-panel, +.workflow-properties, +.workflow-meta-bar, +.workflow-canvas-wrap { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; +} + +.workflow-panel { + margin-bottom: 14px; + overflow: hidden; +} + +.workflow-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); +} + +.workflow-panel-header h3 { + margin: 0; + font-size: 14px; + font-weight: 700; +} + +.workflow-list { + display: grid; + gap: 8px; + padding: 12px; + max-height: 330px; + overflow: auto; +} + +.workflow-list-item { + display: grid; + gap: 4px; + width: 100%; + text-align: left; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; +} + +.workflow-list-item:hover, +.workflow-list-item.is-active { + border-color: var(--accent-color); + background: rgba(59, 130, 246, 0.10); +} + +.workflow-list-title { + font-weight: 700; +} + +.workflow-list-meta { + color: var(--text-secondary); + font-size: 12px; +} + +.workflow-node-palette { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 12px; +} + +.workflow-node-palette button { + padding: 10px; + border: 1px dashed var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: grab; +} + +.workflow-node-palette button:active { + cursor: grabbing; +} + +.workflow-main { + display: grid; + grid-template-rows: auto minmax(520px, 1fr); + gap: 14px; +} + +.workflow-meta-bar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding: 12px; +} + +.workflow-meta-fields { + display: grid; + grid-template-columns: minmax(140px, 220px) minmax(180px, 260px) minmax(200px, 1fr) auto; + gap: 10px; + flex: 1; + align-items: end; +} + +.workflow-meta-fields label { + display: grid; + gap: 5px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} + +.workflow-meta-fields input[type="text"] { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-primary); + color: var(--text-primary); +} + +.workflow-enabled-toggle { + display: flex !important; + align-items: center; + gap: 8px !important; + white-space: nowrap; + padding-bottom: 8px; +} + +.workflow-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.workflow-toolbar .active { + background: var(--accent-color); + color: #fff; + border-color: var(--accent-color); +} + +.workflow-canvas-wrap { + position: relative; + min-height: 520px; + overflow: hidden; +} + +#workflow-canvas { + width: 100%; + height: 100%; + min-height: 520px; + background: + radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0); + background-size: 22px 22px; +} + +.workflow-canvas-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + pointer-events: none; + font-weight: 600; +} + +.workflow-properties { + padding-bottom: 12px; + overflow: hidden; +} + +.workflow-property-empty { + margin: 14px; + padding: 18px; + border: 1px dashed var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + text-align: center; +} + +.workflow-property-empty--compact { + margin: 0; + padding: 12px; +} + +.workflow-property-form { + padding: 14px; +} + +.workflow-config-hint { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} + +.workflow-config-hint code { + font-size: 11px; + padding: 1px 4px; + border-radius: 4px; + background: var(--bg-secondary, rgba(127, 127, 127, 0.12)); +} + +.workflow-custom-fields-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 14px 0 8px; + font-weight: 700; +} + +.workflow-custom-fields { + display: grid; + gap: 8px; +} + +.workflow-custom-field { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) 32px; + gap: 6px; +} + +.workflow-custom-field input { + min-width: 0; + padding: 8px 9px; + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-primary); + color: var(--text-primary); +} + +.workflow-custom-field button { + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; +} + +@media (max-width: 1200px) { + .workflow-page-content { + grid-template-columns: 240px minmax(0, 1fr); + } + + .workflow-properties { + grid-column: 1 / -1; + } + + .workflow-meta-bar, + .workflow-meta-fields { + display: grid; + grid-template-columns: 1fr; + } +} diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 85c9a03d..59a8efc9 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2411,6 +2411,15 @@ function processDetailRowFingerprint(d) { return et + '\0' + msg + '\0' + dataKey; } +function compactWorkflowProcessDetails(details) { + if (!Array.isArray(details) || details.length === 0) return details || []; + return details.filter((detail) => { + const eventType = detail && detail.eventType ? String(detail.eventType) : ''; + // workflow_node_start 已经表达了节点进入;这些事件只用于实时状态,落到详情里会让 Agent 节点看起来重复启动。 + return eventType !== 'workflow_agent_start'; + }); +} + // 渲染过程详情 // options.append=true 时分页追加;options.markLoaded=false 时保留 lazy 标记(分页加载中) function renderProcessDetails(messageId, processDetails, options) { @@ -2502,6 +2511,7 @@ function renderProcessDetails(messageId, processDetails, options) { if (typeof window.coalesceProcessDetailsToolPairs === 'function') { processDetails = window.coalesceProcessDetailsToolPairs(processDetails); } + processDetails = compactWorkflowProcessDetails(processDetails); // 如果没有processDetails或为空,显示空状态 if (!processDetails || processDetails.length === 0) { if (!appendMode) { @@ -2529,7 +2539,40 @@ function renderProcessDetails(messageId, processDetails, options) { const agPx = processDetailAgentPrefix(data); let itemTitle = title; - if (eventType === 'iteration') { + if (eventType === 'workflow_start') { + const name = data.workflowName || data.workflowId || ''; + itemTitle = '🧭 工作流开始' + (name ? (' · ' + name) : ''); + } else if (eventType === 'workflow_done') { + const name = data.workflowName || data.workflowId || ''; + itemTitle = '✅ 工作流完成' + (name ? (' · ' + name) : ''); + } else if (eventType === 'workflow_node_start') { + const label = data.label || title || data.nodeId || ''; + itemTitle = '▶ 节点开始' + (label ? (' · ' + label) : ''); + } else if (eventType === 'workflow_node_result') { + const label = data.label || data.nodeId || ''; + const status = data.status || ''; + const nodeType = data.nodeType != null ? String(data.nodeType).toLowerCase() : ''; + if (nodeType === 'condition') { + const matched = data.matched === true || data.matched === 'true' || (data.output && (data.output.matched === true || data.output.matched === 'true')); + itemTitle = (matched ? '✅' : '🔀') + ' 条件判断' + (label ? (' · ' + label) : '') + ' → ' + (matched ? '是' : '否'); + } else { + const icon = status === 'failed' ? '❌' : (status === 'skipped' ? '⏭️' : '✅'); + itemTitle = icon + ' 节点完成' + (label ? (' · ' + label) : '') + (status ? ('(' + status + ')') : ''); + } + } else if (eventType === 'workflow_branch_taken' || eventType === 'workflow_branch_skipped') { + const branch = data.branchLabel || ''; + const target = data.targetLabel || data.targetId || ''; + const taken = eventType === 'workflow_branch_taken'; + itemTitle = (taken ? '➡️' : '⏭️') + (taken ? ' 执行分支' : ' 跳过分支') + (branch ? (' · ' + branch) : '') + (target ? (' → ' + target) : ''); + } else if (eventType === 'workflow_tool_start') { + const tool = data.tool || data.toolName || ''; + itemTitle = '🔧 工具节点' + (tool ? (' · ' + tool) : ''); + } else if (eventType === 'workflow_agent_output') { + const label = data.label || data.nodeId || ''; + itemTitle = '🤖 Agent 输出' + (label ? (' · ' + label) : ''); + } else if (eventType === 'workflow_hitl_checkpoint') { + itemTitle = '🧑‍⚖️ 人工确认检查点'; + } else if (eventType === 'iteration') { const n = data.iteration || 1; if (data.orchestration === 'plan_execute' && data.einoScope === 'main') { const phase = typeof window.translatePlanExecuteAgentName === 'function' diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 543de508..61017639 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -333,6 +333,19 @@ const responseStreamStateByProgressId = new Map(); // 主通道当前迭代轮次缓存:progressId -> { iteration, orchestration } const mainIterationStateByProgressId = new Map(); +/** 图编排多 Agent 节点切换时清空流式聚合,避免推理/输出条目覆盖上一节点内容 */ +function clearTimelineStreamStates(progressId) { + responseStreamStateByProgressId.delete(progressId); + thinkingStreamStateByProgressId.delete(progressId); + einoAgentReplyStreamStateByProgressId.delete(progressId); + const prefix = String(progressId) + '::'; + for (const key of Array.from(toolResultStreamStateByKey.keys())) { + if (String(key).startsWith(prefix)) { + toolResultStreamStateByKey.delete(key); + } + } +} + /** 同一段主通道流式输出(Eino 可能重复 response_start) */ function sameMainResponseStreamMeta(a, b) { if (!a || !b) return false; @@ -341,7 +354,10 @@ function sameMainResponseStreamMeta(a, b) { if (!agentA || agentA !== agentB) return false; const orchA = String(a.orchestration != null ? a.orchestration : '').trim(); const orchB = String(b.orchestration != null ? b.orchestration : '').trim(); - return orchA === orchB; + if (orchA !== orchB) return false; + const nodeA = String(a.workflowNodeId != null ? a.workflowNodeId : '').trim(); + const nodeB = String(b.workflowNodeId != null ? b.workflowNodeId : '').trim(); + return nodeA === nodeB; } function resolveMainIterationTag(progressId, responseData) { @@ -366,7 +382,8 @@ function buildMainResponseStreamIdentity(progressId, responseData) { const agent = String(d.einoAgent != null ? d.einoAgent : '').trim(); const orch = String(d.orchestration != null ? d.orchestration : '').trim(); const iterTag = resolveMainIterationTag(progressId, d); - return agent + '|' + orch + '|iter=' + iterTag; + const nodeId = String(d.workflowNodeId != null ? d.workflowNodeId : '').trim(); + return agent + '|' + orch + '|iter=' + iterTag + '|wfNode=' + nodeId; } function extractIterationTagFromStreamIdentity(identity) { @@ -1747,13 +1764,18 @@ function handleStreamEvent(event, progressElement, progressId, if (scope !== 'sub') { const prevMainIter = mainIterationStateByProgressId.get(String(progressId)); const prevN = prevMainIter && prevMainIter.iteration != null ? prevMainIter.iteration : null; + const prevNode = prevMainIter && prevMainIter.workflowNodeId != null + ? String(prevMainIter.workflowNodeId).trim() + : ''; + const curNode = d.workflowNodeId != null ? String(d.workflowNodeId).trim() : ''; mainIterationStateByProgressId.set(String(progressId), { iteration: n, - orchestration: d.orchestration != null ? d.orchestration : '' + orchestration: d.orchestration != null ? d.orchestration : '', + workflowNodeId: curNode }); - // 主通道进入新轮次后不复用上一轮的「执行输出」时间线条目 - if (prevN != null && prevN !== n) { - responseStreamStateByProgressId.delete(progressId); + // 主通道进入新轮次或图编排切换到新 Agent 节点后,不复用上一段的流式时间线条目 + if (prevN != null && (n < prevN || prevN !== n || (curNode && prevNode && curNode !== prevNode))) { + clearTimelineStreamStates(progressId); } } let iterTitle; @@ -1785,6 +1807,109 @@ function handleStreamEvent(event, progressElement, progressId, break; } + case 'workflow_start': { + const d = event.data || {}; + const name = d.workflowName || d.workflowId || ''; + addTimelineItem(timeline, 'workflow_start', { + title: '🧭 工作流开始' + (name ? (' · ' + name) : ''), + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_done': { + const d = event.data || {}; + const name = d.workflowName || d.workflowId || ''; + addTimelineItem(timeline, 'workflow_done', { + title: '✅ 工作流完成' + (name ? (' · ' + name) : ''), + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_node_start': { + const d = event.data || {}; + const label = d.label || d.nodeId || ''; + const nodeType = d.nodeType != null ? String(d.nodeType).toLowerCase() : ''; + if (nodeType === 'agent') { + clearTimelineStreamStates(progressId); + } + addTimelineItem(timeline, 'workflow_node_start', { + title: '▶ 节点开始' + (label ? (' · ' + label) : ''), + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_node_result': { + const d = event.data || {}; + const label = d.label || d.nodeId || ''; + const status = d.status || ''; + const nodeType = d.nodeType != null ? String(d.nodeType).toLowerCase() : ''; + let title; + if (nodeType === 'condition') { + const matched = d.matched === true || d.matched === 'true' || (d.output && (d.output.matched === true || d.output.matched === 'true')); + title = (matched ? '✅' : '🔀') + ' 条件判断' + (label ? (' · ' + label) : '') + ' → ' + (matched ? '是' : '否'); + } else { + const icon = status === 'failed' ? '❌' : (status === 'skipped' ? '⏭️' : '✅'); + title = icon + ' 节点完成' + (label ? (' · ' + label) : '') + (status ? ('(' + status + ')') : ''); + } + addTimelineItem(timeline, 'workflow_node_result', { + title: title, + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_branch_taken': + case 'workflow_branch_skipped': { + const d = event.data || {}; + const branch = d.branchLabel || ''; + const target = d.targetLabel || d.targetId || ''; + const taken = event.type === 'workflow_branch_taken'; + addTimelineItem(timeline, event.type, { + title: (taken ? '➡️' : '⏭️') + (taken ? ' 执行分支' : ' 跳过分支') + (branch ? (' · ' + branch) : '') + (target ? (' → ' + target) : ''), + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_tool_start': { + const d = event.data || {}; + const tool = d.tool || d.toolName || ''; + addTimelineItem(timeline, 'workflow_tool_start', { + title: '🔧 工具节点' + (tool ? (' · ' + tool) : ''), + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_agent_output': { + const d = event.data || {}; + const label = d.label || d.nodeId || ''; + addTimelineItem(timeline, 'workflow_agent_output', { + title: '🤖 Agent 输出' + (label ? (' · ' + label) : ''), + message: event.message || '', + data: d + }); + break; + } + + case 'workflow_hitl_checkpoint': { + addTimelineItem(timeline, 'workflow_hitl_checkpoint', { + title: '🧑‍⚖️ 人工确认检查点', + message: event.message || '', + data: event.data || {} + }); + break; + } + case 'eino_trace_run': case 'eino_trace_start': case 'eino_trace_end': @@ -3262,6 +3387,34 @@ function updateToolCallStatus(progressId, toolCallId, status) { } // 添加时间线项目 +function buildWorkflowConditionResultHtml(data) { + const output = (data && data.output) || {}; + const expr = (data && data.expression) || output.condition || ''; + const matched = (data && (data.matched === true || data.matched === 'true')) + || output.matched === true || output.matched === 'true'; + const branchText = matched ? '是(true)' : '否(false)'; + const branchClass = matched ? 'is-true' : 'is-false'; + return `
+
+ 表达式 + ${escapeHtml(String(expr || '(空)'))} +
+
+ 结果 + ${escapeHtml(branchText)} +
+
`; +} + +function buildWorkflowBranchDetailHtml(data) { + const cond = (data && data.edgeCondition) || ''; + if (!cond) return ''; + return `
+ 连线条件 + ${escapeHtml(cond)} +
`; +} + function addTimelineItem(timeline, type, options) { const item = document.createElement('div'); // 生成唯一ID @@ -3382,8 +3535,35 @@ function addTimelineItem(timeline, type, options) { `; - } else if (type === 'eino_agent_reply' && options.message) { - content += `
${formatMarkdown(options.message, timelineMarkdownOpts)}
`; + } else if ((type === 'eino_agent_reply' || type === 'workflow_agent_output') && options.message) { + let prefix = ''; + if (type === 'workflow_agent_output' && options.data) { + const source = options.data.inputSource || ''; + const preview = options.data.inputPreview || ''; + if (source || preview) { + const previewText = String(preview || '').trim(); + const summaryPreview = previewText.length > 80 ? (previewText.slice(0, 80) + '...') : previewText; + prefix = `
+ + 输入 + ${source ? `${escapeHtml(source)}` : ''} + ${summaryPreview ? `${escapeHtml(summaryPreview)}` : ''} + + ${previewText ? `
${escapeHtml(previewText)}
` : '
暂无输入预览
'} +
`; + } + } + const body = type === 'workflow_agent_output' + ? `
+
输出
+
${formatMarkdown(options.message, timelineMarkdownOpts)}
+
` + : formatMarkdown(options.message, timelineMarkdownOpts); + content += `
${prefix}${body}
`; + } else if (type === 'workflow_node_result' && options.data && String(options.data.nodeType || '').toLowerCase() === 'condition') { + content += buildWorkflowConditionResultHtml(options.data); + } else if ((type === 'workflow_branch_taken' || type === 'workflow_branch_skipped') && options.data) { + content += buildWorkflowBranchDetailHtml(options.data); } else if (type === 'tool_result' && options.data) { const data = options.data; const isError = data.isError || !data.success; diff --git a/web/static/js/roles.js b/web/static/js/roles.js index f606a3cb..cbc0ad54 100644 --- a/web/static/js/roles.js +++ b/web/static/js/roles.js @@ -1058,6 +1058,13 @@ async function showAddRoleModal() { document.getElementById('role-icon').value = ''; document.getElementById('role-user-prompt').value = ''; document.getElementById('role-enabled').checked = true; + if (typeof loadWorkflowOptionsForRoleModal === 'function') { + await loadWorkflowOptionsForRoleModal(''); + } + const workflowPolicy = document.getElementById('role-workflow-policy'); + if (workflowPolicy) { + workflowPolicy.value = 'auto'; + } // 添加角色时:显示工具选择界面,隐藏默认角色提示 const toolsSection = document.getElementById('role-tools-section'); @@ -1144,6 +1151,13 @@ async function editRole(roleName) { document.getElementById('role-icon').value = iconValue; document.getElementById('role-user-prompt').value = role.user_prompt || ''; document.getElementById('role-enabled').checked = role.enabled !== false; + if (typeof loadWorkflowOptionsForRoleModal === 'function') { + await loadWorkflowOptionsForRoleModal(role.workflow_id || ''); + } + const workflowPolicy = document.getElementById('role-workflow-policy'); + if (workflowPolicy) { + workflowPolicy.value = role.workflow_policy || 'auto'; + } // 检查是否为默认角色 const isDefaultRole = roleName === '默认'; @@ -1398,6 +1412,10 @@ async function saveRole() { } const userPrompt = document.getElementById('role-user-prompt').value.trim(); const enabled = document.getElementById('role-enabled').checked; + const workflowIdEl = document.getElementById('role-workflow-id'); + const workflowPolicyEl = document.getElementById('role-workflow-policy'); + const workflowId = workflowIdEl ? workflowIdEl.value.trim() : ''; + const workflowPolicy = workflowPolicyEl ? workflowPolicyEl.value.trim() : 'auto'; const isEdit = document.getElementById('role-name').disabled; @@ -1504,7 +1522,10 @@ async function saveRole() { icon: icon || undefined, // 如果为空字符串,则不发送该字段 user_prompt: userPrompt, tools: tools, // 默认角色为空数组,表示使用所有工具 - enabled: enabled + enabled: enabled, + workflow_id: workflowId || undefined, + workflow_version: workflowId ? 'latest' : undefined, + workflow_policy: workflowId ? (workflowPolicy || 'auto') : undefined }; const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles'; const method = isEdit ? 'PUT' : 'POST'; diff --git a/web/static/js/router.js b/web/static/js/router.js index 7abef667..173ad844 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -58,7 +58,7 @@ function initRouter() { const hashParts = hash.split('?'); let pageId = hashParts[0]; if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(500); @@ -449,6 +449,11 @@ async function initPage(pageId) { }); } break; + case 'workflows': + if (typeof refreshWorkflows === 'function') { + refreshWorkflows(); + } + break; case 'skills-monitor': // 初始化Skills状态监控页面 if (typeof loadSkillsMonitor === 'function') { @@ -510,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() { let pageId = hashParts[0]; if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(200); @@ -569,4 +574,3 @@ function initConversationSidebarState() { // 导出函数供其他脚本使用(与上方尽早绑定保持一致,便于外部脚本探测) window.currentPage = function() { return currentPage; }; - diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js new file mode 100644 index 00000000..4b5667e3 --- /dev/null +++ b/web/static/js/workflows.js @@ -0,0 +1,891 @@ +(function () { + 'use strict'; + + let workflows = []; + let currentWorkflowId = ''; + let cy = null; + let nodeSeq = 1; + let edgeSeq = 1; + let connectMode = false; + let connectSourceId = ''; + let selectedElement = null; + let workflowToolOptions = []; + let workflowToolsLoaded = false; + + const NODE_LABELS = { + start: '开始', + tool: '工具', + agent: 'Agent', + condition: '条件', + hitl: '审批', + output: '输出', + end: '结束' + }; + + const AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor']; + + function esc(text) { + if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text)); + return String(text == null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function defaultGraph() { + return { nodes: [], edges: [], config: {} }; + } + + function defaultConfigForType(type) { + switch (type) { + case 'start': + return { input_keys: 'message, conversationId, projectId' }; + case 'tool': + return { tool_name: '', arguments: '{}', timeout_seconds: '' }; + case 'agent': + return { agent_mode: 'eino_single', input_source: '{{previous.output}}', instruction: '', output_key: 'agent_result' }; + case 'condition': + return { expression: '{{previous.output}} != ""' }; + case 'hitl': + return { prompt: '请审批该步骤是否继续执行', reviewer: 'human' }; + case 'output': + return { output_key: 'result', source: '{{previous.output}}' }; + case 'end': + return { result_template: '{{outputs.result}}' }; + default: + return {}; + } + } + + function configWithDefaults(type, config) { + return Object.assign(defaultConfigForType(type), config && typeof config === 'object' ? config : {}); + } + + function parseGraph(raw) { + if (!raw) return defaultGraph(); + let graph = raw; + if (typeof raw === 'string') { + try { + graph = JSON.parse(raw); + } catch (_) { + return defaultGraph(); + } + } + return { + nodes: Array.isArray(graph.nodes) ? graph.nodes : [], + edges: Array.isArray(graph.edges) ? graph.edges : [], + config: graph.config && typeof graph.config === 'object' ? graph.config : {} + }; + } + + function graphToElements(graph) { + const nodes = (graph.nodes || []).map((node, index) => ({ + group: 'nodes', + data: { + id: node.id || `node-${index + 1}`, + label: node.label || NODE_LABELS[node.type] || node.id || `节点 ${index + 1}`, + type: node.type || 'tool', + config: configWithDefaults(node.type || 'tool', node.config) + }, + position: node.position || { x: 120 + index * 80, y: 120 + index * 40 } + })); + const edges = (graph.edges || []).map((edge, index) => ({ + group: 'edges', + data: { + id: edge.id || `edge-${index + 1}`, + source: edge.source, + target: edge.target, + label: edge.label || '', + config: edge.config && typeof edge.config === 'object' ? edge.config : {} + } + })).filter(edge => edge.data.source && edge.data.target); + return nodes.concat(edges); + } + + function elementsToGraph() { + if (!cy) return defaultGraph(); + return { + nodes: cy.nodes().map(node => ({ + id: node.id(), + type: node.data('type') || 'tool', + label: node.data('label') || '', + position: node.position(), + config: node.data('config') || {} + })), + edges: cy.edges().map(edge => ({ + id: edge.id(), + source: edge.source().id(), + target: edge.target().id(), + label: edge.data('label') || '', + config: edge.data('config') || {} + })), + config: { schema_version: 1 } + }; + } + + function updateEmptyState() { + const empty = document.getElementById('workflow-canvas-empty'); + if (!empty || !cy) return; + empty.style.display = cy.nodes().length ? 'none' : 'flex'; + } + + function initCy() { + const container = document.getElementById('workflow-canvas'); + if (!container || typeof cytoscape !== 'function') return; + if (cy) { + cy.resize(); + return; + } + cy = cytoscape({ + container, + elements: [], + wheelSensitivity: 0.18, + style: [ + { + selector: 'node', + style: { + 'shape': 'round-rectangle', + 'width': 150, + 'height': 52, + 'background-color': '#1d4ed8', + 'border-width': 1, + 'border-color': '#60a5fa', + 'label': 'data(label)', + 'color': '#e5edff', + 'font-size': 13, + 'font-weight': 700, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': 132 + } + }, + { selector: 'node[type="start"]', style: { 'background-color': '#047857', 'border-color': '#34d399' } }, + { selector: 'node[type="tool"]', style: { 'background-color': '#1d4ed8', 'border-color': '#60a5fa' } }, + { selector: 'node[type="agent"]', style: { 'background-color': '#7c3aed', 'border-color': '#c4b5fd' } }, + { selector: 'node[type="condition"]', style: { 'shape': 'diamond', 'background-color': '#b45309', 'border-color': '#fbbf24', 'width': 118, 'height': 86 } }, + { selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } }, + { selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } }, + { selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } }, + { + selector: 'edge', + style: { + 'width': 2, + 'line-color': '#64748b', + 'target-arrow-color': '#64748b', + 'target-arrow-shape': 'triangle', + 'curve-style': 'bezier', + 'label': 'data(label)', + 'font-size': 11, + 'color': '#cbd5e1', + 'text-background-color': '#0f172a', + 'text-background-opacity': 0.8, + 'text-background-padding': 3 + } + }, + { + selector: ':selected', + style: { + 'border-width': 3, + 'border-color': '#93c5fd', + 'line-color': '#93c5fd', + 'target-arrow-color': '#93c5fd' + } + }, + { + selector: '.connect-source', + style: { + 'border-width': 4, + 'border-color': '#fbbf24' + } + } + ], + layout: { name: 'preset' } + }); + cy.on('tap', 'node', event => { + if (connectMode) { + handleConnectTap(event.target); + return; + } + selectWorkflowElement(event.target); + }); + cy.on('tap', 'edge', event => { + selectWorkflowElement(event.target); + }); + cy.on('tap', event => { + if (event.target === cy) { + if (connectMode) clearConnectSource(); + selectWorkflowElement(null); + } + }); + cy.on('add remove', updateEmptyState); + document.addEventListener('keydown', event => { + const active = document.activeElement; + const editing = active && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName); + if (editing) return; + if (typeof currentPage !== 'undefined' && currentPage !== 'workflows') return; + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + deleteWorkflowSelection(); + } + }); + } + + async function loadWorkflows(includeDisabled) { + const response = await apiFetch(`/api/workflows?includeDisabled=${includeDisabled ? 'true' : 'false'}`); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || '加载工作流失败'); + } + const data = await response.json(); + workflows = data.workflows || []; + return workflows; + } + + async function loadWorkflowTools() { + if (workflowToolsLoaded) return workflowToolOptions; + const collected = []; + const seen = new Set(); + let page = 1; + let totalPages = 1; + while (page <= totalPages && page <= 20) { + const response = await apiFetch(`/api/config/tools?page=${page}&page_size=100`); + if (!response.ok) break; + const data = await response.json(); + totalPages = data.total_pages || 1; + (data.tools || []).forEach(tool => { + if (!tool || !tool.name) return; + const key = tool.is_external && tool.external_mcp ? `${tool.external_mcp}::${tool.name}` : tool.name; + if (seen.has(key)) return; + seen.add(key); + collected.push({ key, name: tool.name, enabled: tool.enabled !== false }); + }); + page += 1; + } + workflowToolOptions = collected; + workflowToolsLoaded = true; + return workflowToolOptions; + } + + function renderWorkflowList() { + const list = document.getElementById('workflow-list'); + if (!list) return; + if (!workflows.length) { + list.innerHTML = '
暂无图编排流程
'; + return; + } + list.innerHTML = workflows.map(wf => ` + + `).join(''); + } + + function nextNodeId(type) { + while (cy && cy.getElementById(`node-${nodeSeq}`).length) nodeSeq += 1; + const id = `node-${nodeSeq}`; + nodeSeq += 1; + return id; + } + + function nextEdgeId() { + while (cy && cy.getElementById(`edge-${edgeSeq}`).length) edgeSeq += 1; + const id = `edge-${edgeSeq}`; + edgeSeq += 1; + return id; + } + + function resetSequences(graph) { + nodeSeq = 1; + edgeSeq = 1; + (graph.nodes || []).forEach(node => { + const m = String(node.id || '').match(/^node-(\d+)$/); + if (m) nodeSeq = Math.max(nodeSeq, Number(m[1]) + 1); + }); + (graph.edges || []).forEach(edge => { + const m = String(edge.id || '').match(/^edge-(\d+)$/); + if (m) edgeSeq = Math.max(edgeSeq, Number(m[1]) + 1); + }); + } + + function fillWorkflowForm(wf) { + initCy(); + const idEl = document.getElementById('workflow-id'); + const nameEl = document.getElementById('workflow-name'); + const descEl = document.getElementById('workflow-description'); + const enabledEl = document.getElementById('workflow-enabled'); + if (!idEl || !nameEl || !descEl || !enabledEl || !cy) return; + idEl.value = wf.id || ''; + idEl.disabled = !!wf.id; + nameEl.value = wf.name || ''; + descEl.value = wf.description || ''; + enabledEl.checked = wf.enabled !== false; + currentWorkflowId = wf.id || ''; + const graph = parseGraph(wf.graph_json || wf.graph || defaultGraph()); + resetSequences(graph); + cy.elements().remove(); + cy.add(graphToElements(graph)); + if (cy.nodes().length) { + layoutWorkflowGraph(false); + } + selectWorkflowElement(null); + updateEmptyState(); + renderWorkflowList(); + setTimeout(() => cy && cy.resize(), 0); + } + + function selectWorkflowElement(ele) { + selectedElement = ele && ele.length ? ele : null; + const empty = document.getElementById('workflow-property-empty'); + const form = document.getElementById('workflow-property-form'); + const title = document.getElementById('workflow-property-title'); + const deleteBtn = document.getElementById('workflow-property-delete-btn'); + if (!empty || !form) return; + if (!selectedElement) { + empty.hidden = false; + form.hidden = true; + if (title) title.textContent = '属性'; + if (deleteBtn) deleteBtn.hidden = true; + return; + } + cy.elements().unselect(); + selectedElement.select(); + empty.hidden = true; + form.hidden = false; + if (title) title.textContent = selectedElement.isNode() ? '节点属性' : '连线属性'; + if (deleteBtn) { + deleteBtn.hidden = false; + deleteBtn.textContent = selectedElement.isNode() ? '删除节点' : '删除连线'; + } + const typeWrap = document.getElementById('workflow-prop-type-wrap'); + const label = document.getElementById('workflow-prop-label'); + const type = document.getElementById('workflow-prop-type'); + label.value = selectedElement.data('label') || ''; + if (selectedElement.isNode()) { + typeWrap.style.display = ''; + type.value = selectedElement.data('type') || 'tool'; + } else { + typeWrap.style.display = 'none'; + } + renderTypedConfig(selectedElement); + renderCustomFields(stripTypedConfig(selectedElement)); + } + + function typedKeysForType(type) { + return new Set(Object.keys(defaultConfigForType(type))); + } + + function stripTypedConfig(ele) { + const cfg = Object.assign({}, ele.data('config') || {}); + const typed = ele.isNode() ? typedKeysForType(ele.data('type') || 'tool') : new Set(['condition']); + typed.forEach(key => delete cfg[key]); + return cfg; + } + + function typedField(id, label, value, placeholder) { + return ` +
+ + +
+ `; + } + + function typedTextarea(id, label, value, placeholder) { + return ` +
+ + +
+ `; + } + + function renderTypedConfig(ele) { + const wrap = document.getElementById('workflow-typed-config'); + if (!wrap || !ele) return; + const cfg = configWithDefaults(ele.isNode() ? ele.data('type') : 'edge', ele.data('config') || {}); + if (!ele.isNode()) { + const sourceType = ele.source().data('type') || ''; + const edgeHint = sourceType === 'condition' + ? '{{previous.matched}} == "true"(是)或 == "false"(否)' + : '例如: {{previous.output}} == "ok"'; + wrap.innerHTML = ` + ${typedField('workflow-edge-condition', '连线条件', cfg.condition || '', edgeHint)} + ${sourceType === 'condition' ? '

从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。

' : ''} + `; + return; + } + const type = ele.data('type') || 'tool'; + switch (type) { + case 'start': + wrap.innerHTML = typedField('workflow-start-input-keys', '输入变量', cfg.input_keys, 'message, projectId'); + break; + case 'tool': + wrap.innerHTML = ` +
+ + +
+ ${typedTextarea('workflow-tool-arguments', '参数模板', cfg.arguments, '{"target":"{{inputs.target}}"}')} + ${typedField('workflow-tool-timeout', '超时秒数', cfg.timeout_seconds, '可选')} + `; + if (!workflowToolsLoaded) { + loadWorkflowTools().then(() => { + if (selectedElement === ele) renderTypedConfig(ele); + }); + } + break; + case 'agent': + wrap.innerHTML = ` +
+ + +
+ ${typedField('workflow-agent-input-source', '输入来源', cfg.input_source, '{{previous.output}}')} + ${typedTextarea('workflow-agent-instruction', '节点指令', cfg.instruction, '描述该节点要完成的任务')} + ${typedField('workflow-agent-output-key', '输出变量名', cfg.output_key, 'agent_result')} + `; + break; + case 'condition': + wrap.innerHTML = ` + ${typedField('workflow-condition-expression', '条件表达式', cfg.expression, '{{previous.output}} != ""')} +

节点会计算 matched(true/false),由出边决定分支:第一条线为「是」,第二条为「否」;也可在连线上写 {{previous.matched}} == "true"

+ `; + break; + case 'hitl': + wrap.innerHTML = ` + ${typedTextarea('workflow-hitl-prompt', '审批提示', cfg.prompt, '请审批是否继续')} +
+ + +
+ `; + break; + case 'output': + wrap.innerHTML = ` + ${typedField('workflow-output-key', '输出变量名', cfg.output_key, 'result')} + ${typedField('workflow-output-source', '变量来源', cfg.source, '{{previous.output}}')} + `; + break; + case 'end': + wrap.innerHTML = typedTextarea('workflow-end-template', '结束摘要模板', cfg.result_template, '{{outputs.result}}'); + break; + default: + wrap.innerHTML = ''; + } + } + + function renderCustomFields(config) { + const wrap = document.getElementById('workflow-custom-fields'); + if (!wrap) return; + const entries = Object.entries(config || {}); + if (!entries.length) { + wrap.innerHTML = '
暂无自定义字段
'; + return; + } + wrap.innerHTML = entries.map(([key, value], index) => ` +
+ + + +
+ `).join(''); + } + + function readCustomFields() { + const out = {}; + document.querySelectorAll('#workflow-custom-fields .workflow-custom-field').forEach(row => { + const key = row.querySelector('[data-field-key]').value.trim(); + const value = row.querySelector('[data-field-value]').value; + if (key) out[key] = value; + }); + return out; + } + + function readTypedConfig(ele) { + if (!ele) return {}; + if (!ele.isNode()) { + return { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' }; + } + const type = ele.data('type') || 'tool'; + switch (type) { + case 'start': + return { input_keys: (document.getElementById('workflow-start-input-keys') || {}).value || '' }; + case 'tool': + return { + tool_name: (document.getElementById('workflow-tool-name') || {}).value || '', + arguments: (document.getElementById('workflow-tool-arguments') || {}).value || '{}', + timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || '' + }; + case 'agent': + return { + agent_mode: (document.getElementById('workflow-agent-mode') || {}).value || 'eino_single', + input_source: (document.getElementById('workflow-agent-input-source') || {}).value || '{{previous.output}}', + instruction: (document.getElementById('workflow-agent-instruction') || {}).value || '', + output_key: (document.getElementById('workflow-agent-output-key') || {}).value || 'agent_result' + }; + case 'condition': + return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '' }; + case 'hitl': + return { + prompt: (document.getElementById('workflow-hitl-prompt') || {}).value || '', + reviewer: (document.getElementById('workflow-hitl-reviewer') || {}).value || 'human' + }; + case 'output': + return { + output_key: (document.getElementById('workflow-output-key') || {}).value || 'result', + source: (document.getElementById('workflow-output-source') || {}).value || '' + }; + case 'end': + return { result_template: (document.getElementById('workflow-end-template') || {}).value || '' }; + default: + return {}; + } + } + + function mergeVisibleConfig() { + if (!selectedElement) return; + selectedElement.data('config', Object.assign({}, readCustomFields(), readTypedConfig(selectedElement))); + } + + function handleConnectTap(node) { + if (!connectSourceId) { + connectSourceId = node.id(); + node.addClass('connect-source'); + return; + } + if (connectSourceId === node.id()) { + clearConnectSource(); + return; + } + const duplicate = cy.edges().some(edge => edge.source().id() === connectSourceId && edge.target().id() === node.id()); + if (duplicate) { + if (typeof showNotification === 'function') { + showNotification('这两个节点之间已经有连线', 'warning'); + } + clearConnectSource(); + return; + } + const sourceNode = cy.getElementById(connectSourceId); + const sourceType = sourceNode.data('type') || ''; + let edgeLabel = ''; + let edgeConfig = {}; + if (sourceType === 'condition') { + const siblingCount = cy.edges().filter(edge => edge.source().id() === connectSourceId).length; + if (siblingCount === 0) { + edgeLabel = '是'; + edgeConfig = { condition: '{{previous.matched}} == "true"', branch: 'true' }; + } else if (siblingCount === 1) { + edgeLabel = '否'; + edgeConfig = { condition: '{{previous.matched}} == "false"', branch: 'false' }; + } else { + edgeConfig = { condition: '' }; + } + } + cy.add({ + group: 'edges', + data: { + id: nextEdgeId(), + source: connectSourceId, + target: node.id(), + label: edgeLabel, + config: edgeConfig + } + }); + clearConnectSource(); + } + + function clearConnectSource() { + if (cy) cy.nodes().removeClass('connect-source'); + connectSourceId = ''; + } + + function addNode(type, position) { + initCy(); + if (!cy) return; + const node = cy.add({ + group: 'nodes', + data: { + id: nextNodeId(type), + type, + label: NODE_LABELS[type] || '节点', + config: defaultConfigForType(type) + }, + position: position || { x: 180 + cy.nodes().length * 28, y: 160 + cy.nodes().length * 28 } + }); + selectWorkflowElement(node); + updateEmptyState(); + } + + window.refreshWorkflows = async function () { + initCy(); + const list = document.getElementById('workflow-list'); + if (list) list.innerHTML = '
加载中...
'; + try { + await loadWorkflows(true); + renderWorkflowList(); + if (!currentWorkflowId && workflows.length) { + fillWorkflowForm(workflows[0]); + } else if (!workflows.length) { + newWorkflowDraft(); + } + } catch (error) { + if (list) list.innerHTML = `
${esc(error.message)}
`; + if (typeof showNotification === 'function') showNotification(error.message, 'error'); + } + }; + + window.newWorkflowDraft = function () { + fillWorkflowForm({ + id: '', + name: '', + description: '', + enabled: true, + graph_json: defaultGraph() + }); + }; + + window.selectWorkflow = function (id) { + const wf = workflows.find(item => item.id === id); + if (wf) fillWorkflowForm(wf); + }; + + function validateWorkflowGraph(graph) { + const errors = []; + const nodes = graph.nodes || []; + const edges = graph.edges || []; + const ids = new Set(nodes.map(node => node.id)); + const starts = nodes.filter(node => node.type === 'start'); + const outputs = nodes.filter(node => node.type === 'output'); + if (!starts.length) errors.push('至少需要一个开始节点'); + if (!outputs.length) errors.push('至少需要一个输出节点'); + edges.forEach(edge => { + if (edge.source === edge.target) errors.push(`连线 ${edge.id} 不能指向自身`); + if (!ids.has(edge.source)) errors.push(`连线 ${edge.id} 的源节点不存在`); + if (!ids.has(edge.target)) errors.push(`连线 ${edge.id} 的目标节点不存在`); + }); + starts.forEach(node => { + if (edges.some(edge => edge.target === node.id)) errors.push(`开始节点 ${node.label || node.id} 不应有入边`); + }); + outputs.forEach(node => { + if (edges.some(edge => edge.source === node.id)) errors.push(`输出节点 ${node.label || node.id} 不应有出边`); + }); + nodes.filter(node => node.type === 'tool').forEach(node => { + if (!String((node.config || {}).tool_name || '').trim()) { + errors.push(`工具节点 ${node.label || node.id} 需要选择 MCP 工具`); + } + }); + nodes.filter(node => node.type === 'condition').forEach(node => { + if (!String((node.config || {}).expression || '').trim()) { + errors.push(`条件节点 ${node.label || node.id} 需要条件表达式`); + } + const outEdges = edges.filter(edge => edge.source === node.id); + if (outEdges.length === 0) { + errors.push(`条件节点 ${node.label || node.id} 至少需要一条出边(是/否分支)`); + } else if (outEdges.length > 2) { + errors.push(`条件节点 ${node.label || node.id} 建议最多两条出边(是/否);第三条及以后需配置连线条件`); + } + }); + nodes.filter(node => node.type === 'output').forEach(node => { + if (!String((node.config || {}).output_key || '').trim()) { + errors.push(`输出节点 ${node.label || node.id} 需要输出变量名`); + } + }); + return errors; + } + + window.saveWorkflowDraft = async function () { + initCy(); + const id = document.getElementById('workflow-id').value.trim(); + const name = document.getElementById('workflow-name').value.trim(); + const description = document.getElementById('workflow-description').value.trim(); + const enabled = document.getElementById('workflow-enabled').checked; + if (!id || !name) { + showNotification('工作流 ID 和名称不能为空', 'error'); + return; + } + const graph = elementsToGraph(); + const errors = validateWorkflowGraph(graph); + if (errors.length) { + showNotification(errors.slice(0, 4).join(';'), 'error'); + return; + } + const method = currentWorkflowId ? 'PUT' : 'POST'; + const url = currentWorkflowId ? `/api/workflows/${encodeURIComponent(currentWorkflowId)}` : '/api/workflows'; + const response = await apiFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name, description, enabled, graph }) + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + showNotification(err.error || '保存工作流失败', 'error'); + return; + } + const data = await response.json(); + currentWorkflowId = data.workflow && data.workflow.id ? data.workflow.id : id; + showNotification('工作流已保存', 'success'); + await refreshWorkflows(); + if (typeof loadWorkflowOptionsForRoleModal === 'function') { + await loadWorkflowOptionsForRoleModal(); + } + }; + + window.deleteCurrentWorkflow = async function () { + const id = currentWorkflowId || document.getElementById('workflow-id').value.trim(); + if (!id) { + showNotification('请选择要删除的工作流', 'warning'); + return; + } + if (!confirm(`确定删除工作流 ${id}?`)) return; + const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + showNotification(err.error || '删除工作流失败', 'error'); + return; + } + currentWorkflowId = ''; + showNotification('工作流已删除', 'success'); + newWorkflowDraft(); + await refreshWorkflows(); + }; + + window.workflowPaletteDragStart = function (event) { + const type = event.currentTarget.dataset.nodeType || 'tool'; + event.dataTransfer.setData('application/x-workflow-node', type); + event.dataTransfer.setData('text/plain', type); + event.dataTransfer.effectAllowed = 'copy'; + }; + + window.workflowCanvasDragOver = function (event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + + window.workflowCanvasDrop = function (event) { + event.preventDefault(); + const type = event.dataTransfer.getData('application/x-workflow-node') || event.dataTransfer.getData('text/plain') || 'tool'; + const rect = document.getElementById('workflow-canvas').getBoundingClientRect(); + const pan = cy.pan(); + const zoom = cy.zoom(); + addNode(type, { + x: (event.clientX - rect.left - pan.x) / zoom, + y: (event.clientY - rect.top - pan.y) / zoom + }); + }; + + window.addWorkflowNodeFromPalette = function (type) { + addNode(type || 'tool'); + }; + + window.toggleWorkflowConnectMode = function () { + connectMode = !connectMode; + clearConnectSource(); + const btn = document.getElementById('workflow-connect-btn'); + if (btn) { + btn.classList.toggle('active', connectMode); + btn.textContent = connectMode ? '连线中' : '连线'; + } + if (typeof showNotification === 'function') { + showNotification(connectMode ? '连线模式:依次点击源节点和目标节点' : '已退出连线模式', 'info'); + } + }; + + window.deleteWorkflowSelection = function () { + if (!cy) return; + const selected = selectedElement && selectedElement.length ? selectedElement : cy.$(':selected'); + if (!selected.length) return; + selected.remove(); + selectWorkflowElement(null); + updateEmptyState(); + }; + + window.layoutWorkflowGraph = function (animate) { + if (!cy || !cy.nodes().length) return; + cy.layout({ + name: 'breadthfirst', + directed: true, + padding: 40, + spacingFactor: 1.25, + animate: animate !== false, + animationDuration: 250 + }).run(); + cy.fit(undefined, 40); + }; + + window.updateWorkflowSelectedProperty = function () { + if (!selectedElement) return; + const label = document.getElementById('workflow-prop-label').value.trim(); + selectedElement.data('label', label); + if (selectedElement.isNode()) { + const type = document.getElementById('workflow-prop-type').value || 'tool'; + const prevType = selectedElement.data('type') || 'tool'; + selectedElement.data('type', type); + if (type !== prevType) { + selectedElement.data('config', defaultConfigForType(type)); + selectedElement.data('label', label || NODE_LABELS[type] || '节点'); + document.getElementById('workflow-prop-label').value = selectedElement.data('label') || ''; + renderTypedConfig(selectedElement); + renderCustomFields({}); + } + } + }; + + window.addWorkflowCustomField = function () { + if (!selectedElement) return; + const cfg = Object.assign({}, selectedElement.data('config') || {}); + let i = 1; + while (Object.prototype.hasOwnProperty.call(cfg, `field_${i}`)) i += 1; + cfg[`field_${i}`] = ''; + selectedElement.data('config', cfg); + renderCustomFields(cfg); + }; + + window.updateWorkflowCustomFields = function () { + if (!selectedElement) return; + mergeVisibleConfig(); + }; + + window.updateWorkflowTypedConfig = function () { + if (!selectedElement) return; + mergeVisibleConfig(); + }; + + window.removeWorkflowCustomField = function (index) { + if (!selectedElement) return; + const entries = Object.entries(stripTypedConfig(selectedElement)); + entries.splice(index, 1); + const next = {}; + entries.forEach(([key, value]) => { + if (key) next[key] = value; + }); + selectedElement.data('config', Object.assign({}, next, readTypedConfig(selectedElement))); + renderCustomFields(next); + }; + + window.loadWorkflowOptionsForRoleModal = async function (selectedId) { + try { + await loadWorkflows(true); + } catch (_) { + workflows = []; + } + const select = document.getElementById('role-workflow-id'); + if (!select) return; + const current = selectedId !== undefined ? selectedId : select.value; + select.innerHTML = '' + workflows.map(wf => ( + `` + )).join(''); + select.value = current || ''; + }; +})(); diff --git a/web/templates/index.html b/web/templates/index.html index eacc1dc7..cb1fb464 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -201,6 +201,16 @@ 任务管理 + + +
+ +
+ +
+
+
+ + + + +
+
+ + + + + +
+
+
+
+
从左侧拖拽节点到画布,或点击节点按钮快速添加
+
+
+ +
+
+
+
+ + + 选中流程后,对话页使用该角色会自动触发绑定图;流程字段由图定义 JSON 自由配置。 +
+
+ + +