From b9854192c6f69766b4ea2289ccdd0b2b831c8ebd 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, 31 Jul 2026 21:04:11 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 262 ++++++++++++++++ web/static/i18n/en-US.json | 77 ++++- web/static/i18n/zh-CN.json | 77 ++++- web/static/js/chat.js | 74 ++++- web/static/js/monitor.js | 2 +- web/static/js/workflows.js | 590 ++++++++++++++++++++++++++++++++++++- web/templates/index.html | 42 ++- 7 files changed, 1109 insertions(+), 15 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index b13b449d..17e8ecb6 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -40124,6 +40124,247 @@ html[data-theme="dark"] .workflow-status-toggle.is-disabled { padding: 12px 18px; } +.workflow-ai-modal-content { + width: min(760px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + overflow: hidden; +} + +.workflow-ai-modal-header { + align-items: flex-start; +} + +.workflow-ai-modal-header h2 { + margin-bottom: 4px; +} + +.workflow-ai-subtitle { + margin: 0; + color: var(--text-secondary); + font-size: 13px; +} + +.workflow-ai-modal-body { + display: grid; + gap: 14px; + max-height: min(66vh, 640px); + overflow-y: auto; +} + +.workflow-ai-prompt-field textarea { + resize: vertical; + min-height: 116px; +} + +.workflow-ai-examples, +.workflow-ai-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.workflow-ai-options label { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid var(--border-color); + border-radius: 7px; + color: var(--text-secondary); + font-size: 13px; +} + +.workflow-ai-result { + display: grid; + gap: 10px; +} + +.workflow-ai-result:empty { + display: none; +} + +.workflow-ai-result-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.workflow-ai-progress { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.workflow-ai-progress[hidden] { + display: none; +} + +.workflow-ai-progress span { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.workflow-ai-progress b { + display: inline-grid; + flex: 0 0 22px; + width: 22px; + height: 22px; + place-items: center; + border-radius: 50%; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 12px; +} + +.workflow-ai-progress small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-ai-progress .is-active { + border-color: color-mix(in srgb, var(--accent-color) 45%, var(--border-color)); + color: var(--text-primary); +} + +.workflow-ai-progress .is-active b { + background: var(--accent-color); + color: white; +} + +.workflow-ai-progress .is-complete b { + background: #16a34a; + color: white; +} + +.workflow-ai-preview { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.workflow-ai-preview-title { + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} + +.workflow-ai-preview-flow { + display: flex; + align-items: center; + gap: 7px; + overflow-x: auto; + padding-bottom: 2px; +} + +.workflow-ai-preview-flow i { + flex: 0 0 auto; + color: var(--text-secondary); + font-style: normal; +} + +.workflow-ai-preview-node { + display: grid; + flex: 0 0 auto; + gap: 2px; + width: 128px; + min-height: 48px; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--accent-color) 22%, var(--border-color)); + border-radius: 8px; + background: var(--bg-primary); +} + +.workflow-ai-preview-node small { + color: var(--text-secondary); + font-size: 11px; +} + +.workflow-ai-preview-node strong { + overflow: hidden; + color: var(--text-primary); + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-ai-preview-node.is-risky { + border-color: rgba(249, 115, 22, 0.62); + background: rgba(249, 115, 22, 0.08); +} + +.workflow-ai-result-grid span, +.workflow-ai-result-section { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.workflow-ai-result-grid span { + display: grid; + gap: 4px; + padding: 10px; + color: var(--text-secondary); + font-size: 12px; +} + +.workflow-ai-result-grid strong { + color: var(--text-primary); + font-size: 16px; +} + +.workflow-ai-result-section { + padding: 10px 12px; + font-size: 13px; +} + +.workflow-ai-result-section p { + margin: 4px 0 0; + color: var(--text-secondary); +} + +.workflow-ai-result-section ul { + margin: 8px 0 0; + padding-left: 18px; + color: var(--text-secondary); +} + +.workflow-ai-result-section.is-warning { + border-color: rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.08); +} + +.workflow-ai-result-section.is-attention { + border-color: rgba(14, 165, 233, 0.42); + background: rgba(14, 165, 233, 0.08); +} + +.workflow-ai-modal-footer { + gap: 8px; +} + +@media (max-width: 640px) { + .workflow-ai-progress { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workflow-ai-result-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + html[data-theme="dark"] .workflow-dry-run-modal-icon { color: #bfdbfe; background: rgba(59, 130, 246, 0.14); @@ -40364,6 +40605,27 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr font-weight: 600; } +.workflow-ai-canvas-status { + position: absolute; + top: 14px; + left: 50%; + z-index: 2; + transform: translateX(-50%); + padding: 9px 14px; + border: 1px solid color-mix(in srgb, var(--accent-color) 35%, var(--border-color)); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 92%, var(--accent-color)); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); + color: var(--text-primary); + font-size: 13px; + font-weight: 700; + pointer-events: none; +} + +.workflow-ai-canvas-status[hidden] { + display: none; +} + .workflow-properties { display: flex; flex-direction: column; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 4913c5f8..9c9bf687 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3532,6 +3532,81 @@ "canvasTools": "Canvas tools", "moreActions": "More", "deleteWorkflow": "Delete workflow", + "ai": { + "open": "Create from natural language", + "title": "Create Workflow from Natural Language", + "subtitle": "AI generates an editable draft and will not save or run it automatically.", + "promptLabel": "Workflow request", + "promptPlaceholder": "Example: continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear", + "examplesLabel": "Example requests", + "exampleDomain": "Domain monitor", + "exampleReport": "Report approval", + "exampleTriage": "Asset triage", + "includeObjective": "Generate objective configuration too", + "allowSchedule": "Allow scheduled trigger suggestions", + "allowHighRisk": "Allow high-risk node drafts", + "allowFallback": "Allow deterministic fallback if AI fails", + "promptRequired": "Describe the workflow request first.", + "generateFailed": "Generation failed", + "generatedWithIssues": "The draft still has validation issues. Adjust the request and try again.", + "generate": "Generate draft", + "generating": "Generating…", + "apply": "Render to canvas", + "rendering": "Rendering…", + "applied": "Workflow draft generated. Review it before saving.", + "canvasRendering": "AI is composing the canvas…", + "generatorAI": "AI channel", + "generatorLLM": "Model generated", + "generatorServer": "Server draft generator", + "generatorFallback": "Deterministic fallback", + "generatorUnknown": "Unknown generator", + "llmTitle": "Generated with the platform default AI channel and structurally validated", + "serverTitle": "Generated on the server and structurally audited", + "fallbackTitle": "Deterministic fallback was used", + "serverFallbackNotice": "Model generation is unavailable: {{reason}}", + "preview": "Flow preview", + "resultNodes": "Nodes", + "resultEdges": "Edges", + "resultRepairs": "Repair rounds", + "resultCapabilities": "Node capabilities", + "resultTools": "Tool capabilities", + "resultRisk": "Risk", + "resultSaveState": "Savable", + "yes": "Yes", + "no": "No", + "missingFields": "Missing configuration", + "riskWarnings": "Risk warnings", + "assumptions": "Assumptions", + "capabilityTrace": "Capability toolchain", + "nodeGenerated": "AI generated", + "nodeNeedsInput": "Needs input", + "riskLow": "Low", + "riskMedium": "Medium", + "riskHigh": "High", + "steps": { + "understand": "Understand request", + "match": "Match tools", + "draft": "Generate nodes", + "audit": "Safety audit" + }, + "examples": { + "domainMonitor": "Continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear", + "reportReview": "Collect threat intelligence every day, generate a report, and send it to the security owner for approval", + "assetTriage": "Extract high-risk assets from scan results, deduplicate them, and output remediation suggestions" + } + }, + "audit": { + "title": "Draft Audit", + "ready": "No blocking items found in this draft", + "review": "Review before saving or running", + "aiNodes": "AI nodes", + "needsInput": "Needs input", + "highRisk": "High risk", + "validation": "Structure issues", + "nodeIssues": "Node notes", + "validationIssues": "Structure checks", + "saveConfirm": "This draft contains missing configuration or high-risk nodes. Save as draft anyway?" + }, "package": { "importLocal": "Import local package", "export": "Export", @@ -3641,7 +3716,7 @@ "deleteSelected": "Delete selected", "autoLayout": "Auto layout", "dryRun": "Dry run", - "canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly", + "canvasEmpty": "Drag nodes from the left onto the canvas, or generate a workflow from natural language", "properties": "Properties", "nodeProperties": "Node properties", "edgeProperties": "Edge properties", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 4176005a..4e7b5de8 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3520,6 +3520,81 @@ "canvasTools": "画布工具", "moreActions": "更多", "deleteWorkflow": "删除工作流", + "ai": { + "open": "用自然语言创建", + "title": "用自然语言创建工作流", + "subtitle": "AI 会生成可编辑草稿,不会自动保存或运行。", + "promptLabel": "工作流需求", + "promptPlaceholder": "例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告", + "examplesLabel": "示例需求", + "exampleDomain": "域名监控", + "exampleReport": "报告审批", + "exampleTriage": "资产研判", + "includeObjective": "同时生成 objective 配置", + "allowSchedule": "允许生成定时触发建议", + "allowHighRisk": "允许包含高风险节点草稿", + "allowFallback": "AI 失败时允许确定性兜底", + "promptRequired": "请先描述工作流需求。", + "generateFailed": "生成失败", + "generatedWithIssues": "草稿仍有校验问题,请调整需求后重试。", + "generate": "生成草稿", + "generating": "正在生成…", + "apply": "渲染到画布", + "rendering": "正在渲染…", + "applied": "已生成工作流草稿,请检查后保存。", + "canvasRendering": "AI 正在编排画布…", + "generatorAI": "AI 通道", + "generatorLLM": "大模型生成", + "generatorServer": "服务端草稿生成器", + "generatorFallback": "确定性兜底", + "generatorUnknown": "未知生成器", + "llmTitle": "已调用平台默认 AI 通道生成,并完成结构校验", + "serverTitle": "已通过服务端生成并完成结构审计", + "fallbackTitle": "已使用确定性兜底", + "serverFallbackNotice": "大模型生成不可用:{{reason}}", + "preview": "流程预览", + "resultNodes": "节点", + "resultEdges": "连线", + "resultRepairs": "修复轮次", + "resultCapabilities": "节点能力", + "resultTools": "工具能力", + "resultRisk": "风险", + "resultSaveState": "可保存", + "yes": "是", + "no": "否", + "missingFields": "缺失配置", + "riskWarnings": "风险提示", + "assumptions": "生成假设", + "capabilityTrace": "能力工具链", + "nodeGenerated": "AI 生成", + "nodeNeedsInput": "需补配置", + "riskLow": "低", + "riskMedium": "中", + "riskHigh": "高", + "steps": { + "understand": "理解需求", + "match": "匹配工具", + "draft": "生成节点", + "audit": "安全审计" + }, + "examples": { + "domainMonitor": "持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告", + "reportReview": "每天收集威胁情报,生成报告后交给安全负责人审批", + "assetTriage": "从扫描结果中提取高风险资产,去重后输出处置建议" + } + }, + "audit": { + "title": "草稿审计", + "ready": "当前草稿未发现阻断项", + "review": "保存或运行前建议检查", + "aiNodes": "AI 节点", + "needsInput": "需补配置", + "highRisk": "高风险", + "validation": "结构问题", + "nodeIssues": "节点提示", + "validationIssues": "结构校验", + "saveConfirm": "当前草稿包含需补配置或高风险节点,确认仅保存为草稿?" + }, "package": { "importLocal": "导入本地包", "export": "导出", @@ -3629,7 +3704,7 @@ "deleteSelected": "删除选中", "autoLayout": "自动布局", "dryRun": "试运行", - "canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加", + "canvasEmpty": "从左侧拖拽节点到画布,或用自然语言生成工作流", "properties": "属性", "nodeProperties": "节点属性", "edgeProperties": "连线属性", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index c96d28f4..44bc337e 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -3584,13 +3584,14 @@ function setPendingMcpExecutionIds(messageElement, executionIds) { function normalizeToolExecutionSummaryForButton(raw) { const data = raw && typeof raw === 'object' ? raw : {}; - return { - toolName: data.toolName || data.name || '', - status: data.status || '', - executionId: data.executionId || '', - toolCallId: data.toolCallId || '', - processDetailId: data.processDetailId || '' - }; + return { + toolName: data.toolName || data.name || '', + status: data.status || '', + executionId: data.executionId || '', + toolCallId: data.toolCallId || '', + processDetailId: data.processDetailId || '', + resultDetailId: data.resultDetailId || '' + }; } function cacheToolExecutionSummaries(messageElement, summaries) { @@ -3924,6 +3925,15 @@ async function fetchProcessDetailDataForModal(detailId) { return detail && detail.data ? detail.data : null; } +async function fetchProcessDetailForModal(detailId) { + const id = detailId != null ? String(detailId).trim() : ''; + if (!id || typeof apiFetch !== 'function') return null; + const res = await apiFetch('/api/process-details/' + encodeURIComponent(id)); + const j = await res.json().catch(() => ({})); + if (!res.ok) return null; + return j && j.processDetail ? j.processDetail : null; +} + function processToolResultTextFromData(resultData) { if (!resultData) return ''; const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果'; @@ -3950,6 +3960,56 @@ function processToolResultToMCPResult(resultData, rawText) { } async function showMCPDetailFromProcessToolItem(messageElement, summary, index) { + const item = normalizeToolExecutionSummaryForButton(summary); + if (item.processDetailId) { + const callDetail = await fetchProcessDetailForModal(item.processDetailId); + const callData = callDetail && callDetail.data ? callDetail.data : null; + if (callData) { + const args = typeof window.parseToolCallArgsFromData === 'function' + ? window.parseToolCallArgsFromData(callData) + : (callData.argumentsObj || {}); + let resultData = callData._mergedResult || null; + let resultDetailId = item.resultDetailId || callData._mergedResultDetailId || ''; + let rawText = resultData ? processToolResultTextFromData(resultData) : ''; + const resultPayloadDeferred = resultData && resultData._payloadDeferred === true; + const resultOnlyHasPreview = resultData && resultData.result == null && resultData.error == null && resultData.resultPreview != null; + if (resultDetailId && (!resultData || resultPayloadDeferred || resultOnlyHasPreview)) { + const resultDetail = await fetchProcessDetailForModal(resultDetailId); + const fullResult = resultDetail && resultDetail.data ? resultDetail.data : null; + if (fullResult) { + resultData = fullResult; + rawText = processToolResultTextFromData(fullResult); + } + } + const displayState = resultData && typeof window.getToolResultDisplayState === 'function' + ? window.getToolResultDisplayState(resultData, { rawText: rawText }) + : null; + const backgroundRunning = (displayState && displayState.kind === 'background_running') || String(item.status || '').toLowerCase() === 'background_running'; + const success = resultData + ? !(displayState ? displayState.isError : (resultData.isError || resultData.success === false)) + : String(item.status || '').toLowerCase() !== 'failed'; + const status = backgroundRunning + ? 'background_running' + : (resultData || item.status + ? (success ? 'completed' : 'failed') + : 'running'); + const exec = { + id: (resultData && resultData.executionId) || item.executionId || callData.executionId || '', + toolName: item.toolName || callData.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'), + status: status, + startTime: callDetail.createdAt || '', + arguments: args || {}, + result: processToolResultToMCPResult(resultData, rawText), + error: resultData && resultData.error ? String(resultData.error) : '' + }; + openAppModal('mcp-detail-modal', { focus: false }); + deferModalContent(function () { + renderMCPDetailModal(exec); + }); + return; + } + } + const target = await findToolExecutionTimelineItem(messageElement, summary, index); if (!target) { alert(typeof window.t === 'function' diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index b5791fee..44fae0da 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -5681,7 +5681,7 @@ function updateMonitorTimelineSection() { } -const MCP_STATS_TOP_N = 3; +const MCP_STATS_TOP_N = 6; const MCP_TIMELINE_RANGES = ['24h', '7d', '30d']; function getMcpMonitorTimelineRange() { diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index fe4aa43f..6cd3ae3e 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -37,6 +37,12 @@ requestSignature: '', dragDepth: 0 }; + const workflowAiState = { + draft: null, + result: null, + activeStep: '', + animating: false + }; const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id'; const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id'; @@ -67,6 +73,18 @@ const NODE_PLACEMENT_PADDING = 20; const WORKFLOW_EDIT_ICON = ''; + const WORKFLOW_AI_TOOL_HINTS = [ + { keywords: ['子域名', 'subdomain', 'subfinder', 'amass'], tools: ['subfinder', 'amass'], label: '子域名发现' }, + { keywords: ['端口', 'port', 'nmap', 'rustscan', 'masscan'], tools: ['nmap', 'rustscan', 'masscan'], label: '端口扫描' }, + { keywords: ['漏洞', 'vuln', '漏洞扫描', 'nuclei', 'nikto', 'zap'], tools: ['nuclei', 'nikto', 'zap'], label: '漏洞扫描' }, + { keywords: ['目录', '路径', '暴露页面', 'dir', 'ffuf', 'gobuster', 'feroxbuster'], tools: ['ffuf', 'gobuster', 'feroxbuster', 'dirsearch'], label: '暴露面探测' }, + { keywords: ['证书', 'certificate', 'crt'], tools: ['subfinder'], label: '证书与域名线索收集' }, + { keywords: ['云', 'cloud', '配置审计', 'prowler', 'scout'], tools: ['prowler', 'scout-suite'], label: '云配置审计' }, + { keywords: ['容器', '镜像', 'k8s', 'kubernetes', 'trivy', 'kube'], tools: ['trivy', 'kube-bench', 'kube-hunter'], label: '容器安全检查' }, + { keywords: ['情报', '威胁情报', 'threat', 'ioc', 'virustotal', 'shodan', 'fofa'], tools: ['virustotal_search', 'shodan_search', 'fofa_search'], label: '威胁情报收集' } + ]; + const WORKFLOW_AI_HIGH_RISK_RE = /(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)/i; + const WORKFLOW_AI_PROGRESS_STEPS = ['understand', 'match', 'draft', 'audit']; function esc(text) { if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text)); @@ -166,6 +184,10 @@ function graphToElements(graph) { const nodes = (graph.nodes || []).map((node, index) => ({ group: 'nodes', + classes: [ + node.config && node.config.generated_by === 'natural_language' ? 'ai-generated' : '', + node.config && (node.config.risk_level === 'high' || node.config.requires_human_confirmation === 'true') ? 'high-risk' : '' + ].filter(Boolean).join(' '), data: { id: node.id || `node-${index + 1}`, label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }), @@ -265,6 +287,8 @@ { 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: 'node.ai-generated', style: { 'border-width': 2, 'border-color': '#38bdf8' } }, + { selector: 'node.high-risk', style: { 'border-width': 3, 'border-color': '#f97316' } }, { selector: 'edge', style: { @@ -1350,6 +1374,559 @@ } } + function workflowAiOption(id) { + const el = document.getElementById(id); + return !!(el && el.checked); + } + + function workflowAiSleep(ms) { + return new Promise(function (resolve) { setTimeout(resolve, ms); }); + } + + function workflowAiStepLabel(step) { + return _t('workflows.ai.steps.' + step); + } + + function workflowAiSetProgress(activeStep, done) { + workflowAiState.activeStep = activeStep || ''; + const wrap = document.getElementById('workflow-ai-progress'); + if (!wrap) return; + if (!activeStep && !done) { + wrap.hidden = true; + wrap.innerHTML = ''; + return; + } + wrap.hidden = false; + const activeIndex = WORKFLOW_AI_PROGRESS_STEPS.indexOf(activeStep); + wrap.innerHTML = WORKFLOW_AI_PROGRESS_STEPS.map(function (step, index) { + const complete = done || (activeIndex >= 0 && index < activeIndex); + const active = !done && step === activeStep; + return ` + ${complete ? '✓' : index + 1} + ${esc(workflowAiStepLabel(step))} + `; + }).join(''); + } + + function workflowAiPreviewItems(graph) { + const nodes = (graph.nodes || []).slice().sort(function (a, b) { + const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0; + const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0; + const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0; + const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0; + return ax === bx ? ay - by : ax - bx; + }); + return nodes.slice(0, 9); + } + + function workflowAiPreviewHtml(graph) { + const items = workflowAiPreviewItems(graph); + if (!items.length) return ''; + return `
+
${esc(_t('workflows.ai.preview'))}
+
+ ${items.map(function (node, index) { + const type = node.type || 'tool'; + const cfg = node.config || {}; + const risky = cfg.risk_level === 'high' || cfg.requires_human_confirmation === 'true'; + return ` + ${esc(wfNodeLabel(type))} + ${esc(node.label || wfNodeLabel(type))} + ${index < items.length - 1 ? '' : ''}`; + }).join('')} +
+
`; + } + + function workflowAiSlug(text) { + const raw = String(text || '').trim().toLowerCase(); + const ascii = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (ascii) return ascii.slice(0, 48); + let hash = 0; + for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0; + return 'ai-workflow-' + Math.abs(hash || Date.now()).toString(36); + } + + function workflowAiMatchTool(toolNames) { + if (!workflowToolOptions.length) return ''; + const enabled = workflowToolOptions.filter(tool => tool.enabled !== false); + const all = enabled.length ? enabled : workflowToolOptions; + for (const wanted of toolNames || []) { + const lower = String(wanted || '').toLowerCase(); + const found = all.find(tool => String(tool.key || '').toLowerCase().includes(lower)); + if (found) return found.key; + } + return ''; + } + + function workflowAiDetectCapabilities(prompt) { + const lower = String(prompt || '').toLowerCase(); + const capabilities = []; + WORKFLOW_AI_TOOL_HINTS.forEach(function (hint) { + if (hint.keywords.some(keyword => lower.indexOf(String(keyword).toLowerCase()) !== -1)) { + capabilities.push({ + label: hint.label, + tool_name: workflowAiMatchTool(hint.tools), + tool_candidates: hint.tools.slice() + }); + } + }); + if (!capabilities.length) { + capabilities.push({ label: _t('workflows.ai.resultCapabilities'), tool_name: '', tool_candidates: [] }); + } + return capabilities; + } + + function workflowAiNode(id, type, label, x, y, config) { + return { + id, + type, + label, + position: { x, y }, + config: Object.assign(configWithDefaults(type, {}), config || {}, { + generated_by: 'natural_language', + needs_review: 'true' + }) + }; + } + + function workflowAiEdge(id, source, target, label, config) { + return { + id, + source, + target, + label: label || '', + config: config || {} + }; + } + + function workflowAiBuildDraft(prompt, options) { + const capabilities = workflowAiDetectCapabilities(prompt); + const wantsApproval = /(审批|确认|审核|负责人|人工|review|approve|approval|human)/i.test(prompt); + const wantsReport = /(报告|汇总|输出|通知|任务|工单|report|summary|notify|ticket)/i.test(prompt); + const wantsCondition = /(如果|发现|存在|高危|新增|失败|通过|否则|if|when|high|critical|new|fail)/i.test(prompt); + const highRisk = WORKFLOW_AI_HIGH_RISK_RE.test(prompt); + const nodes = []; + const edges = []; + const assumptions = []; + const riskWarnings = []; + let x = 120; + const y = 150; + let nodeIndex = 1; + let edgeIndex = 1; + let openConditionId = ''; + + function nextId(prefix) { + const id = prefix + '-' + nodeIndex; + nodeIndex += 1; + return id; + } + function add(type, label, config, yy) { + const id = nextId(type); + nodes.push(workflowAiNode(id, type, label, x, yy || y, config)); + x += 210; + return id; + } + function connect(source, target, label, config) { + edges.push(workflowAiEdge('edge-ai-' + edgeIndex, source, target, label, config)); + edgeIndex += 1; + } + + const start = add('start', wfNodeLabel('start'), { input_keys: 'message, conversationId, projectId, target' }); + let previous = start; + capabilities.forEach(function (capability) { + const hasTool = !!capability.tool_name; + const id = add(hasTool ? 'tool' : 'agent', capability.label, hasTool ? { + tool_name: capability.tool_name, + arguments: '{"target":"{{inputs.target}}","message":"{{inputs.message}}"}', + timeout_seconds: '120', + join_strategy: 'all_merge' + } : { + agent_mode: 'eino_single', + input_binding: { from: 'previous', field: 'output' }, + instruction: capability.label + '。根据用户需求执行安全流程步骤,并输出结构化结果:' + prompt, + output_key: 'agent_result', + join_strategy: 'all_merge', + missing_tool_candidates: capability.tool_candidates.join(', ') + }); + connect(previous, id); + previous = id; + if (!hasTool && capability.tool_candidates.length) { + assumptions.push(capability.label + ' 未匹配到已启用工具,已生成 Agent 草稿节点。'); + } + }); + + if (wantsCondition) { + const condition = add('condition', highRisk ? '是否需要高风险处置' : '是否满足触发条件', { + expression: highRisk ? '{{previous.output}} contains "高危"' : '{{previous.output}} != ""', + join_strategy: 'all_merge' + }); + connect(previous, condition); + openConditionId = condition; + const report = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), { + output_key: 'result', + source_binding: { from: 'previous', field: 'output' }, + static_value: '', + join_strategy: 'all_merge' + }, y + 130); + connect(condition, report, _t('workflows.edges.no'), { condition: '{{previous.matched}} == "false"', branch: 'false' }); + previous = condition; + } + + if (highRisk) { + let insertedApproval = false; + if (!options.allowHighRisk || wantsApproval) { + const approval = add('hitl', '人工审批', { + prompt: '请确认是否允许继续执行高风险处置:' + prompt, + prompt_binding: { from: 'previous', field: 'output' }, + reviewer: 'human', + join_strategy: 'all_merge', + risk_level: 'high' + }); + connect(previous, approval, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {}); + if (previous === openConditionId) openConditionId = ''; + previous = approval; + insertedApproval = true; + } + const action = add('agent', '执行受控处置', { + agent_mode: 'eino_single', + input_binding: { from: 'previous', field: 'output' }, + instruction: '仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:' + prompt, + output_key: 'remediation_plan', + join_strategy: 'all_merge', + risk_level: 'high', + requires_human_confirmation: 'true' + }); + connect(previous, action, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {}); + if (previous === openConditionId) openConditionId = ''; + previous = action; + riskWarnings.push(insertedApproval + ? '检测到高风险动作,已加入人工确认与 requires_human_confirmation 标记。' + : '检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。'); + } else if (wantsApproval && !nodes.some(node => node.type === 'hitl')) { + const approval = add('hitl', '人工审批', { + prompt: '请审核工作流阶段结果:' + prompt, + prompt_binding: { from: 'previous', field: 'output' }, + reviewer: 'human', + join_strategy: 'all_merge' + }); + connect(previous, approval); + previous = approval; + } + + const output = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), { + output_key: 'result', + source_binding: { from: 'previous', field: 'output' }, + static_value: '', + join_strategy: 'all_merge' + }); + connect(previous, output, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {}); + + const graph = { + nodes, + edges, + config: { + schema_version: 1, + generated_by: 'natural_language', + source_prompt: prompt, + objective: options.includeObjective ? prompt : '' + } + }; + if (options.allowSchedule && /(每天|每周|定时|周期|持续|daily|weekly|schedule|monitor)/i.test(prompt)) { + graph.config.trigger_suggestion = /(每天|daily)/i.test(prompt) ? 'daily' : 'scheduled'; + assumptions.push('已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。'); + } + return { + graph, + meta: { + id: workflowAiSlug(prompt), + name: prompt.length > 22 ? prompt.slice(0, 22) + '...' : prompt, + description: prompt, + enabled: true + }, + generator: 'deterministic-fallback', + audit: { + risk_warnings: riskWarnings, + assumptions: assumptions, + high_risk: highRisk, + needs_hitl: nodes.some(node => node.type === 'hitl'), + savable: validateWorkflowGraph(graph).length === 0 + }, + assumptions, + riskWarnings, + capabilities, + stats: { nodes: nodes.length, edges: edges.length } + }; + } + + function workflowAiAvailableToolsPayload() { + return (workflowToolOptions || []).map(function (tool) { + return { + key: tool.key || tool.name || '', + name: tool.name || '', + enabled: tool.enabled !== false + }; + }).filter(function (tool) { + return tool.key || tool.name; + }); + } + + async function workflowAiGenerateDraftOnServer(prompt, options) { + const response = await apiFetch('/api/workflows/generate-draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: prompt, + options: { + include_objective: !!options.includeObjective, + allow_schedule: !!options.allowSchedule, + allow_high_risk: !!options.allowHighRisk + }, + available_tools: workflowAiAvailableToolsPayload() + }) + }); + const data = await response.json().catch(function () { return {}; }); + if (!response.ok) { + throw new Error(data.error || _t('workflows.ai.generateFailed')); + } + const result = data.result || data; + if (!result || !result.graph) { + throw new Error(_t('workflows.ai.generateFailed')); + } + result.generator = result.generator || 'server'; + return result; + } + + function workflowAiRenderResult(result) { + const target = document.getElementById('workflow-ai-result'); + const applyBtn = document.getElementById('workflow-ai-apply-btn'); + if (!target) return; + if (!result) { + target.innerHTML = ''; + if (applyBtn) applyBtn.disabled = true; + return; + } + const graph = result.graph || defaultGraph(); + const errors = validateWorkflowGraph(graph); + const audit = result.audit || {}; + const risks = audit.risk_warnings || result.riskWarnings || []; + const assumptions = audit.assumptions || result.assumptions || []; + const missing = audit.missing_fields || []; + const stats = result.stats || {}; + const generator = String(result.generator || ''); + const generatorKey = generator === 'llm' + ? 'workflows.ai.generatorLLM' + : (generator === 'deterministic' + ? 'workflows.ai.generatorServer' + : (generator.indexOf('fallback') !== -1 ? 'workflows.ai.generatorFallback' : 'workflows.ai.generatorUnknown')); + const capabilityText = (result.capabilities || []).map(function (cap) { + return cap.label + (cap.tool_name ? ' -> ' + cap.tool_name : (cap.toolName ? ' -> ' + cap.toolName : '')); + }); + target.innerHTML = ` + ${workflowAiPreviewHtml(graph)} +
+ ${esc(_t('workflows.ai.resultNodes'))}${stats.nodes || (graph.nodes || []).length} + ${esc(_t('workflows.ai.resultEdges'))}${stats.edges || (graph.edges || []).length} + ${esc(_t('workflows.ai.resultRisk'))}${risks.length ? esc(_t('workflows.ai.riskHigh')) : esc(_t('workflows.ai.riskLow'))} + ${esc(_t('workflows.ai.resultSaveState'))}${errors.length ? esc(_t('workflows.ai.no')) : esc(_t('workflows.ai.yes'))} +
+
${esc(_t(generatorKey))}

${esc(generator === 'llm' ? _t('workflows.ai.llmTitle') : (generator === 'deterministic' ? _t('workflows.ai.serverTitle') : _t('workflows.ai.fallbackTitle')))}

+ ${capabilityText.length ? '
' + esc(_t('workflows.ai.capabilityTrace')) + '
' : ''} + ${missing.length ? '
' + esc(_t('workflows.ai.missingFields')) + '
' : ''} + ${assumptions.length ? '
' + esc(_t('workflows.ai.assumptions')) + '
' : ''} + ${risks.length ? '
' + esc(_t('workflows.ai.riskWarnings')) + '
' : ''} + ${errors.length ? '
' + esc(_t('workflows.audit.validationIssues')) + '
' : ''} + `; + if (applyBtn) applyBtn.disabled = !!errors.length; + } + + function workflowAiApplyGraph(result) { + if (!result || !result.graph) return; + fillWorkflowForm({ + id: '', + name: result.meta.name, + description: result.meta.description, + enabled: true, + graph_json: result.graph + }); + syncWorkflowMetaIdField(false, result.meta.id); + updateWorkflowCanvasTitle(); + if (cy && cy.nodes().length) { + cy.fit(cy.elements(), 60); + const generated = cy.nodes('.ai-generated'); + if (generated.length) { + generated.addClass('just-added'); + const risky = generated.filter('.high-risk'); + if (risky.length) risky.select(); + setTimeout(function () { + if (cy) cy.nodes('.ai-generated').removeClass('just-added'); + }, 1200); + } + } + } + + function workflowAiSortedNodes(graph) { + return (graph.nodes || []).slice().sort(function (a, b) { + const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0; + const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0; + const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0; + const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0; + return ax === bx ? ay - by : ax - bx; + }); + } + + async function workflowAiAnimateGraph(result) { + if (!result || !result.graph || workflowAiState.animating) return; + workflowAiState.animating = true; + const status = document.getElementById('workflow-ai-canvas-status'); + if (status) status.hidden = false; + try { + initCy(); + if (!cy) { + workflowAiApplyGraph(result); + return; + } + const graph = parseGraph(result.graph); + syncWorkflowMetaForm({ + id: '', + name: result.meta && result.meta.name ? result.meta.name : '', + description: result.meta && result.meta.description ? result.meta.description : '', + enabled: true + }); + currentWorkflowId = ''; + syncWorkflowMetaIdField(false, result.meta && result.meta.id ? result.meta.id : ''); + updateWorkflowCanvasTitle(); + resetSequences(graph); + cy.elements().remove(); + updateEmptyState(); + closeWorkflowDryRunPanel(); + renderWorkflowList(); + const nodeElements = graphToElements({ nodes: workflowAiSortedNodes(graph), edges: [] }); + const edgeElements = graphToElements({ nodes: [], edges: graph.edges || [] }); + for (const ele of nodeElements) { + const added = cy.add(ele); + selectWorkflowElement(added); + added.addClass('just-added'); + cy.animate({ center: { eles: added }, duration: 180 }); + await workflowAiSleep(120); + added.removeClass('just-added'); + } + for (const edge of edgeElements) { + const added = cy.add(edge); + added.select(); + await workflowAiSleep(80); + added.unselect(); + } + if (cy.nodes().length) { + layoutWorkflowGraph(true); + await workflowAiSleep(320); + const risky = cy.nodes('.high-risk'); + if (risky.length) { + selectWorkflowElement(risky.first()); + } else { + selectWorkflowElement(null); + } + } + updateEmptyState(); + } finally { + workflowAiState.animating = false; + if (status) status.hidden = true; + } + } + + window.openWorkflowAiModal = function () { + if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return; + workflowAiState.draft = null; + workflowAiState.result = null; + workflowAiSetProgress('', false); + workflowAiRenderResult(null); + const prompt = document.getElementById('workflow-ai-prompt'); + const generateBtn = document.getElementById('workflow-ai-generate-btn'); + if (generateBtn) generateBtn.disabled = false; + if (typeof openAppModal === 'function') { + openAppModal('workflow-ai-modal', { focusEl: prompt }); + } + }; + + window.closeWorkflowAiModal = function () { + if (typeof closeAppModal === 'function') closeAppModal('workflow-ai-modal'); + }; + + window.useWorkflowAiExample = function (key) { + const prompt = document.getElementById('workflow-ai-prompt'); + if (!prompt) return; + prompt.value = _t('workflows.ai.examples.' + key); + prompt.focus(); + }; + + window.generateWorkflowFromNaturalLanguage = async function () { + const promptEl = document.getElementById('workflow-ai-prompt'); + const generateBtn = document.getElementById('workflow-ai-generate-btn'); + const prompt = promptEl ? promptEl.value.trim() : ''; + if (!prompt) { + if (typeof showNotification === 'function') showNotification(_t('workflows.ai.promptRequired'), 'warning'); + return; + } + if (generateBtn) { + generateBtn.disabled = true; + generateBtn.textContent = _t('workflows.ai.generating'); + } + try { + workflowAiRenderResult(null); + workflowAiSetProgress('understand'); + await workflowAiSleep(140); + await loadWorkflowTools(); + workflowAiSetProgress('match'); + await workflowAiSleep(140); + const options = { + includeObjective: workflowAiOption('workflow-ai-include-objective'), + allowSchedule: workflowAiOption('workflow-ai-allow-schedule'), + allowHighRisk: workflowAiOption('workflow-ai-allow-high-risk') + }; + workflowAiSetProgress('draft'); + const result = await workflowAiGenerateDraftOnServer(prompt, options); + workflowAiSetProgress('audit'); + await workflowAiSleep(120); + workflowAiState.draft = result.graph; + workflowAiState.result = result; + workflowAiRenderResult(result); + workflowAiSetProgress('', true); + if (validateWorkflowGraph(result.graph).length && typeof showNotification === 'function') { + showNotification(_t('workflows.ai.generatedWithIssues'), 'warning'); + } + } catch (error) { + workflowAiState.draft = null; + workflowAiState.result = null; + workflowAiSetProgress('', false); + workflowAiRenderResult(null); + if (typeof showNotification === 'function') showNotification(error.message || _t('workflows.ai.generateFailed'), 'error'); + } finally { + if (generateBtn) { + generateBtn.disabled = false; + generateBtn.textContent = _t('workflows.ai.generate'); + } + } + }; + + window.applyWorkflowAiDraft = async function () { + if (!workflowAiState.result) return; + const applyBtn = document.getElementById('workflow-ai-apply-btn'); + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = _t('workflows.ai.rendering'); + } + closeWorkflowAiModal(); + try { + await workflowAiAnimateGraph(workflowAiState.result); + if (typeof showNotification === 'function') showNotification(_t('workflows.ai.applied'), 'success'); + } finally { + if (applyBtn) { + applyBtn.disabled = false; + applyBtn.textContent = _t('workflows.ai.apply'); + } + } + }; + window.refreshWorkflows = async function () { initCy(); const list = document.getElementById('workflow-list'); @@ -1818,15 +2395,20 @@ window.layoutWorkflowGraph = function (animate) { if (!cy || !cy.nodes().length) return; - cy.layout({ + const layout = cy.layout({ name: 'breadthfirst', directed: true, padding: 40, spacingFactor: 1.25, animate: animate !== false, animationDuration: 250 - }).run(); - cy.fit(undefined, 40); + }); + layout.one('layoutstop', function () { + if (cy && cy.elements().length) cy.fit(cy.elements(), 40); + }); + layout.run(); + if (animate === false && cy.elements().length) cy.fit(cy.elements(), 40); + return layout; }; window.updateWorkflowSelectedProperty = function () { @@ -2458,7 +3040,7 @@ if (page && typeof window.applyTranslations === 'function') { window.applyTranslations(page); } - ['workflow-dry-run-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) { + ['workflow-dry-run-modal', 'workflow-ai-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) { const modal = document.getElementById(id); if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal); }); diff --git a/web/templates/index.html b/web/templates/index.html index 47edd83a..f65ebde3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3088,6 +3088,7 @@
+
@@ -3139,6 +3140,10 @@ 试运行 +
@@ -3164,7 +3169,8 @@
-
从左侧拖拽节点到画布,或点击节点按钮快速添加
+
从左侧拖拽节点到画布,或用自然语言生成工作流
+