diff --git a/web/static/css/style.css b/web/static/css/style.css index 4637a817..655cc904 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -5569,6 +5569,227 @@ html[data-theme="dark"] .openapi-doc-btn:hover { margin-bottom: 16px; } +.robot-manager { + margin-bottom: 24px; +} + +.robot-manager-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.robot-manager-toolbar h4 { + margin: 0 0 4px; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.robot-manager-toolbar p { + margin: 0; + font-size: 0.8125rem; + color: var(--text-secondary); + line-height: 1.45; +} + +.robot-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.robot-card { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 14px; + width: 100%; + min-height: 104px; + padding: 16px; + text-align: left; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} + +.robot-card:hover, +.robot-card.is-active { + border-color: var(--accent-color); + background: var(--bg-primary); +} + +.robot-card:hover { + transform: translateY(-1px); +} + +.robot-card-main, +.robot-card-meta { + display: flex; + flex-direction: column; +} + +.robot-card-main { + min-width: 0; + gap: 8px; +} + +.robot-card-meta { + flex-shrink: 0; + align-items: flex-end; + justify-content: space-between; + gap: 12px; +} + +.robot-card-title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary); +} + +.robot-card-desc { + font-size: 0.8125rem; + line-height: 1.45; + color: var(--text-secondary); +} + +.robot-card-action { + font-size: 0.8125rem; + color: var(--accent-color); + font-weight: 500; +} + +.robot-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 58px; + padding: 4px 9px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid var(--border-color); + white-space: nowrap; +} + +.robot-status-pill--idle { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.robot-status-pill--ready { + color: var(--accent-color); + background: rgba(0, 102, 255, 0.1); + border-color: rgba(0, 102, 255, 0.25); +} + +.robot-status-pill--enabled { + color: var(--success-color); + background: rgba(40, 167, 69, 0.12); + border-color: rgba(40, 167, 69, 0.35); +} + +.robot-editor-empty { + margin: 8px 0 24px; + padding: 28px; + border: 1px dashed var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + text-align: center; +} + +.robot-editor-empty h4 { + margin: 0 0 8px; + font-size: 1rem; + color: var(--text-primary); +} + +.robot-editor-empty p { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.robot-editor-panel { + margin-top: 8px; + padding: 20px 22px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.robot-editor-panel[hidden], +.robot-editor-empty[hidden] { + display: none !important; +} + +.robot-create-modal-content { + max-width: 680px; +} + +.robot-type-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.robot-type-option { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 92px; + padding: 16px; + text-align: left; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} + +.robot-type-option:hover { + border-color: var(--accent-color); + background: var(--bg-primary); + transform: translateY(-1px); +} + +.robot-type-option span { + font-size: 0.9375rem; + font-weight: 600; +} + +.robot-type-option small { + color: var(--text-secondary); + line-height: 1.45; +} + +@media (max-width: 768px) { + .robot-manager-toolbar { + align-items: stretch; + flex-direction: column; + } + + .robot-manager-toolbar .btn-primary { + width: 100%; + } + + .robot-card { + flex-direction: column; + } + + .robot-card-meta { + flex-direction: row; + align-items: center; + } +} + .settings-subsection-header { margin-top: 24px; margin-bottom: 16px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index d196e988..51b867fd 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -1244,6 +1244,17 @@ "robots": { "title": "Bot settings", "description": "Configure WeChat (iLink), WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.", + "managerTitle": "Bot management", + "managerDesc": "Choose a bot type first, then configure only the fields that belong to that platform.", + "newBot": "New bot", + "configure": "Configure", + "statusNotConfigured": "Not configured", + "statusConfigured": "Configured", + "statusEnabled": "Enabled", + "emptyTitle": "Select a bot to configure", + "emptyDesc": "Pick a platform above, or use New bot to choose by type.", + "createTitle": "New bot", + "createDesc": "Choose a bot type to open its dedicated setup. This version supports one built-in configuration per platform.", "wechat": { "title": "WeChat / iLink", "subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone", @@ -1272,6 +1283,7 @@ }, "wecom": { "title": "WeCom", + "subtitle": "HTTP callback mode for enterprise internal app bots", "enabled": "Enable WeCom bot", "token": "Token", "tokenPlaceholder": "Token", @@ -1286,6 +1298,7 @@ }, "dingtalk": { "title": "DingTalk", + "subtitle": "Enterprise internal app bot using Stream connection", "enabled": "Enable DingTalk bot", "clientIdLabel": "Client ID (AppKey)", "clientIdPlaceholder": "DingTalk App Key", @@ -1295,6 +1308,7 @@ }, "lark": { "title": "Lark", + "subtitle": "Enterprise app bot for direct chat and group mentions", "enabled": "Enable Lark bot", "appIdLabel": "App ID", "appIdPlaceholder": "Lark/Feishu App ID", @@ -2952,6 +2966,7 @@ "connecting": "Connecting", "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", "properties": "Properties", "nodeProperties": "Node properties", @@ -2996,10 +3011,18 @@ "outputKey": "Output variable name", "conditionExpression": "Condition expression", "conditionHint": "The node computes matched (true/false); outgoing edges define branches: first edge is \"Yes\", second is \"No\". You can also write {{previous.matched}} == \"true\" on the edge.", + "conditionGuideTitle": "Available syntax", + "conditionGuideVars": "Variables: {{previous.output}}, {{previous.matched}}, {{inputs.message}}, {{outputs.name}}, {{nodeId.output}}", + "conditionGuideOps": "Operators: ==, !=, >, >=, <, <=, contains, matches, &&, ||", + "conditionGuideJson": "Nested fields: jsonpath(value, \"$.status\") or jq(value, \".severity\")", "edgeCondition": "Edge condition", + "edgeBranch": "Condition branch", + "selectBranch": "Select", "edgeConditionHintCondition": "{{previous.matched}} == \"true\" (Yes) or == \"false\" (No)", "edgeConditionHintExample": "e.g. {{previous.output}} == \"ok\"", "edgeBranchHint": "The first edge from a condition node defaults to the \"Yes\" branch, the second to \"No\"; you can customize conditions here.", + "joinStrategy": "Join strategy", + "joinStrategyHint": "When multiple upstream nodes enter this node, choose how to build previous: merge all, last by canvas order, first non-empty, or fail fast.", "hitlPrompt": "Approval prompt", "hitlPromptPlaceholder": "Approve to continue", "hitlReviewer": "Reviewer", @@ -3018,6 +3041,11 @@ "nodeFallback": "Node {{n}}", "loadFailed": "Failed to load workflows", "saveFailed": "Failed to save workflow", + "dryRunPrompt": "Input message for dry-run", + "dryRunFailed": "Dry-run failed", + "dryRunDone": "Dry-run completed", + "dryRunTrace": "Dry-run trace", + "dryRunNoTrace": "No trace", "deleteFailed": "Failed to delete workflow", "saved": "Workflow saved", "deleted": "Workflow deleted", @@ -3039,6 +3067,13 @@ "conditionNeedsExpr": "Condition node {{label}} requires a condition expression", "conditionNeedsOutEdge": "Condition node {{label}} needs at least one outgoing edge (Yes/No branch)", "conditionTooManyEdges": "Condition node {{label}} should have at most two outgoing edges (Yes/No); configure edge conditions for a third and beyond", + "conditionBranchLabel": "Condition node {{label}} outgoing edges must be marked Yes/No", + "conditionBranchDuplicate": "Condition node {{label}} has duplicate branches", + "nodeNeedsIncoming": "Node {{label}} needs an incoming edge", + "nodeNeedsOutgoing": "Node {{label}} needs an outgoing edge", + "nodeUnreachable": "Node {{label}} cannot be reached from Start", + "graphCycle": "Workflow contains a cycle; keep it as a DAG", + "serverFailed": "Server graph validation failed", "outputNeedsKey": "Output node {{label}} requires an output variable name" } }, diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c8b097a2..98d1f7bb 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1232,6 +1232,17 @@ "robots": { "title": "机器人设置", "description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。", + "managerTitle": "机器人管理", + "managerDesc": "先选择机器人类型,再进入对应配置;已配置的平台会显示连接状态。", + "newBot": "新建机器人", + "configure": "配置", + "statusNotConfigured": "未配置", + "statusConfigured": "已配置", + "statusEnabled": "已启用", + "emptyTitle": "选择一个机器人开始配置", + "emptyDesc": "从上方列表点击已有平台,或点击“新建机器人”按类型创建。", + "createTitle": "新建机器人", + "createDesc": "选择机器人类型后进入专属配置。当前版本每个平台支持一个内置机器人配置。", "wechat": { "title": "微信 / iLink", "subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话", @@ -1260,6 +1271,7 @@ }, "wecom": { "title": "企业微信", + "subtitle": "HTTP 回调模式,适合企业内部应用机器人", "enabled": "启用企业微信机器人", "token": "Token", "tokenPlaceholder": "Token", @@ -1274,6 +1286,7 @@ }, "dingtalk": { "title": "钉钉", + "subtitle": "企业内部应用机器人,使用 Stream 长连接", "enabled": "启用钉钉机器人", "clientIdLabel": "Client ID (AppKey)", "clientIdPlaceholder": "钉钉应用 AppKey", @@ -1283,6 +1296,7 @@ }, "lark": { "title": "飞书 (Lark)", + "subtitle": "飞书企业应用机器人,支持单聊与群聊 @", "enabled": "启用飞书机器人", "appIdLabel": "App ID", "appIdPlaceholder": "飞书应用 App ID", @@ -2940,6 +2954,7 @@ "connecting": "连线中", "deleteSelected": "删除选中", "autoLayout": "自动布局", + "dryRun": "试运行", "canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加", "properties": "属性", "nodeProperties": "节点属性", @@ -2984,10 +2999,18 @@ "outputKey": "输出变量名", "conditionExpression": "条件表达式", "conditionHint": "节点会计算 matched(true/false),由出边决定分支:第一条线为「是」,第二条为「否」;也可在连线上写 {{previous.matched}} == \"true\"。", + "conditionGuideTitle": "可用语法", + "conditionGuideVars": "变量:{{previous.output}}、{{previous.matched}}、{{inputs.message}}、{{outputs.变量名}}、{{节点ID.output}}", + "conditionGuideOps": "操作符:==、!=、>、>=、<、<=、contains、matches、&&、||", + "conditionGuideJson": "嵌套字段:jsonpath(value, \"$.status\") 或 jq(value, \".severity\")", "edgeCondition": "连线条件", + "edgeBranch": "条件分支", + "selectBranch": "请选择", "edgeConditionHintCondition": "{{previous.matched}} == \"true\"(是)或 == \"false\"(否)", "edgeConditionHintExample": "例如: {{previous.output}} == \"ok\"", "edgeBranchHint": "从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。", + "joinStrategy": "汇聚策略", + "joinStrategyHint": "当多个上游同时进入该节点时,决定如何生成 previous:合并全部、按画布顺序取最后、取第一个非空或上游失败即中止。", "hitlPrompt": "审批提示", "hitlPromptPlaceholder": "请审批是否继续", "hitlReviewer": "审批方", @@ -3006,6 +3029,11 @@ "nodeFallback": "节点 {{n}}", "loadFailed": "加载工作流失败", "saveFailed": "保存工作流失败", + "dryRunPrompt": "输入试运行消息", + "dryRunFailed": "试运行失败", + "dryRunDone": "试运行完成", + "dryRunTrace": "试运行轨迹", + "dryRunNoTrace": "暂无轨迹", "deleteFailed": "删除工作流失败", "saved": "工作流已保存", "deleted": "工作流已删除", @@ -3027,6 +3055,13 @@ "conditionNeedsExpr": "条件节点 {{label}} 需要条件表达式", "conditionNeedsOutEdge": "条件节点 {{label}} 至少需要一条出边(是/否分支)", "conditionTooManyEdges": "条件节点 {{label}} 建议最多两条出边(是/否);第三条及以后需配置连线条件", + "conditionBranchLabel": "条件节点 {{label}} 的出边必须标记为是/否", + "conditionBranchDuplicate": "条件节点 {{label}} 存在重复分支", + "nodeNeedsIncoming": "节点 {{label}} 需要入边", + "nodeNeedsOutgoing": "节点 {{label}} 需要出边", + "nodeUnreachable": "节点 {{label}} 无法从开始节点到达", + "graphCycle": "工作流存在环路,请保持 DAG", + "serverFailed": "后端图校验失败", "outputNeedsKey": "输出节点 {{label}} 需要输出变量名" } }, diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 654cbed8..dcd19b09 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -6,6 +6,114 @@ let alwaysVisibleBuiltinToolNames = new Set(); // 全局工具状态映射,用于保存用户在所有页面的修改 // key: 唯一工具标识符(toolKey),value: { enabled: boolean, is_external: boolean, external_mcp: string } let toolStateMap = new Map(); +let activeRobotEditor = ''; + +function settingsT(key, fallback) { + if (typeof window.t === 'function') { + const translated = window.t(key); + if (translated && translated !== key) return translated; + } + return fallback; +} + +function getRobotStatus(type) { + const value = (id) => document.getElementById(id)?.value?.trim() || ''; + const checked = (id) => document.getElementById(id)?.checked === true; + let configured = false; + let enabled = false; + + if (type === 'wechat') { + configured = !!value('robot-wechat-ilink-bot-id'); + enabled = checked('robot-wechat-enabled'); + } else if (type === 'wecom') { + const agentId = parseInt(value('robot-wecom-agent-id'), 10); + configured = !!(value('robot-wecom-token') && value('robot-wecom-corp-id') && value('robot-wecom-secret') && agentId > 0); + enabled = checked('robot-wecom-enabled'); + } else if (type === 'dingtalk') { + configured = !!(value('robot-dingtalk-client-id') && value('robot-dingtalk-client-secret')); + enabled = checked('robot-dingtalk-enabled'); + } else if (type === 'lark') { + configured = !!(value('robot-lark-app-id') && value('robot-lark-app-secret')); + enabled = checked('robot-lark-enabled'); + } + + if (enabled) { + return { state: 'enabled', text: settingsT('settings.robots.statusEnabled', '已启用') }; + } + if (configured) { + return { state: 'ready', text: settingsT('settings.robots.statusConfigured', '已配置') }; + } + return { state: 'idle', text: settingsT('settings.robots.statusNotConfigured', '未配置') }; +} + +function refreshRobotManager() { + ['wechat', 'wecom', 'dingtalk', 'lark'].forEach((type) => { + const status = getRobotStatus(type); + const pill = document.getElementById(`robot-card-${type}-status`); + if (pill) { + pill.className = `robot-status-pill robot-status-pill--${status.state}`; + pill.textContent = status.text; + } + const card = document.querySelector(`[data-robot-card="${type}"]`); + if (card) { + card.classList.toggle('is-active', activeRobotEditor === type); + } + }); +} + +function openRobotEditor(type) { + activeRobotEditor = type; + const empty = document.getElementById('robot-editor-empty'); + if (empty) empty.hidden = true; + document.querySelectorAll('[data-robot-editor]').forEach((panel) => { + panel.hidden = panel.dataset.robotEditor !== type; + }); + refreshRobotManager(); + const panel = document.querySelector(`[data-robot-editor="${type}"]`); + if (panel) { + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +} + +function openRobotCreateModal() { + const modal = document.getElementById('robot-create-modal'); + if (modal) modal.style.display = 'block'; +} + +function closeRobotCreateModal() { + const modal = document.getElementById('robot-create-modal'); + if (modal) modal.style.display = 'none'; +} + +function selectRobotType(type) { + closeRobotCreateModal(); + openRobotEditor(type); +} + +function bindRobotManagerEvents() { + const robotInputIds = [ + 'robot-wechat-enabled', 'robot-wechat-ilink-bot-id', + 'robot-wecom-enabled', 'robot-wecom-token', 'robot-wecom-corp-id', 'robot-wecom-secret', 'robot-wecom-agent-id', + 'robot-dingtalk-enabled', 'robot-dingtalk-client-id', 'robot-dingtalk-client-secret', + 'robot-lark-enabled', 'robot-lark-app-id', 'robot-lark-app-secret' + ]; + robotInputIds.forEach((id) => { + const el = document.getElementById(id); + if (el && !el.dataset.robotManagerBound) { + el.addEventListener('input', refreshRobotManager); + el.addEventListener('change', refreshRobotManager); + el.dataset.robotManagerBound = 'true'; + } + }); + + const modal = document.getElementById('robot-create-modal'); + if (modal && !modal.dataset.robotManagerBound) { + modal.addEventListener('click', (event) => { + if (event.target === modal) closeRobotCreateModal(); + }); + modal.dataset.robotManagerBound = 'true'; + } +} // 生成工具的唯一标识符,用于区分同名但来源不同的工具 function getToolKey(tool) { @@ -539,6 +647,8 @@ async function loadConfig(loadTools = true) { if (larkAppSecret) larkAppSecret.value = lark.app_secret || ''; const larkVerify = document.getElementById('robot-lark-verify-token'); if (larkVerify) larkVerify.value = lark.verify_token || ''; + bindRobotManagerEvents(); + refreshRobotManager(); // 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要) if (loadTools) { diff --git a/web/static/js/wechat-robot.js b/web/static/js/wechat-robot.js index d1ee42e1..64376d08 100644 --- a/web/static/js/wechat-robot.js +++ b/web/static/js/wechat-robot.js @@ -170,6 +170,9 @@ function showWechatBoundUI(wechat) { if (btn) { btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定'); } + if (typeof refreshRobotManager === 'function') { + refreshRobotManager(); + } } function escapeHtml(text) { @@ -342,6 +345,9 @@ async function pollWechatBindStatus() { bound: true }); } + if (typeof refreshRobotManager === 'function') { + refreshRobotManager(); + } return; case 'need_verifycode': updateWechatSteps('scan'); @@ -405,5 +411,8 @@ function refreshWechatRobotBoundUI(wechat) { if (btn) { btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定'); } + if (typeof refreshRobotManager === 'function') { + refreshRobotManager(); + } } } diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index 50324aec..87c6e553 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -44,6 +44,7 @@ } const AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor']; + const JOIN_STRATEGIES = ['all_merge', 'last_by_canvas', 'first_non_empty', 'fail_fast']; const WORKFLOW_EDIT_ICON = ''; @@ -105,17 +106,17 @@ case 'start': return { input_keys: 'message, conversationId, projectId' }; case 'tool': - return { tool_name: '', arguments: '{}', timeout_seconds: '' }; + return { tool_name: '', arguments: '{}', timeout_seconds: '', join_strategy: 'all_merge' }; case 'agent': - return { agent_mode: 'eino_single', input_binding: { from: 'previous', field: 'output' }, instruction: '', output_key: 'agent_result' }; + return { agent_mode: 'eino_single', input_binding: { from: 'previous', field: 'output' }, instruction: '', output_key: 'agent_result', join_strategy: 'all_merge' }; case 'condition': - return { expression: '{{previous.output}} != ""' }; + return { expression: '{{previous.output}} != ""', join_strategy: 'all_merge' }; case 'hitl': - return { prompt: _t('workflows.defaultHitlPrompt'), prompt_binding: { from: 'previous', field: 'output' }, reviewer: 'human' }; + return { prompt: _t('workflows.defaultHitlPrompt'), prompt_binding: { from: 'previous', field: 'output' }, reviewer: 'human', join_strategy: 'all_merge' }; case 'output': - return { output_key: 'result', source_binding: { from: 'previous', field: 'output' } }; + return { output_key: 'result', source_binding: { from: 'previous', field: 'output' }, join_strategy: 'all_merge' }; case 'end': - return { result_binding: { from: 'outputs', field: 'result' } }; + return { result_binding: { from: 'outputs', field: 'result' }, join_strategy: 'all_merge' }; default: return {}; } @@ -561,6 +562,41 @@ `; } + function joinStrategyHtml(cfg) { + const selected = cfg.join_strategy || 'all_merge'; + return ` +
+ + +

${esc(_t('workflows.config.joinStrategyHint') || '多个上游进入同一节点时如何生成 previous。')}

+
+ `; + } + + function conditionExpressionGuideHtml() { + const examples = [ + '{{previous.output}} != ""', + '{{outputs.risk_score}} >= 8', + '{{previous.output}} contains "success"', + '{{previous.output}} matches "^ok"', + 'jsonpath({{previous.output}}, "$.status") == "ok"', + 'jq({{outputs.scan}}, ".severity") == "high"' + ]; + return ` +
+
${esc(_t('workflows.config.conditionGuideTitle'))}
+
${esc(_t('workflows.config.conditionGuideVars'))}
+
${esc(_t('workflows.config.conditionGuideOps'))}
+
${esc(_t('workflows.config.conditionGuideJson'))}
+
+ ${examples.map(expr => ``).join('')} +
+
+ `; + } + function renderTypedConfig(ele) { const wrap = document.getElementById('workflow-typed-config'); if (!wrap || !ele) return; @@ -572,7 +608,17 @@ : _t('workflows.config.edgeConditionHintExample'); wrap.innerHTML = ` ${typedField('workflow-edge-condition', _t('workflows.config.edgeCondition'), cfg.condition || '', edgeHint)} - ${sourceType === 'condition' ? '

' + esc(_t('workflows.config.edgeBranchHint')) + '

' : ''} + ${sourceType === 'condition' ? ` +
+ + +
+

${esc(_t('workflows.config.edgeBranchHint'))}

+ ` : ''} `; return; } @@ -583,6 +629,7 @@ break; case 'tool': wrap.innerHTML = ` + ${joinStrategyHtml(cfg)}
@@ -614,12 +662,15 @@ break; case 'condition': wrap.innerHTML = ` + ${joinStrategyHtml(cfg)} ${typedField('workflow-condition-expression', _t('workflows.config.conditionExpression'), cfg.expression, '{{previous.output}} != ""')}

${_t('workflows.config.conditionHint')}

+ ${conditionExpressionGuideHtml()} `; break; case 'hitl': wrap.innerHTML = ` + ${joinStrategyHtml(cfg)} ${typedTextarea('workflow-hitl-prompt', _t('workflows.config.hitlPrompt'), cfg.prompt, _t('workflows.config.hitlPromptPlaceholder'))} ${bindingFieldHtml('workflow-hitl-prompt-binding', 'workflows.config.promptBinding', bindingFromConfig(cfg, 'prompt_binding', 'previous', 'output'), 'workflows.config.promptBindingHint')}

${_t('workflows.config.hitlInteractiveHint')}

@@ -634,13 +685,14 @@ break; case 'output': wrap.innerHTML = ` + ${joinStrategyHtml(cfg)} ${typedField('workflow-output-key', _t('workflows.config.outputKey'), cfg.output_key, 'result')} ${bindingFieldHtml('workflow-output-source', 'workflows.config.sourceBinding', bindingFromConfig(cfg, 'source_binding', 'previous', 'output'), 'workflows.config.sourceBindingHint')} ${typedField('workflow-output-static', _t('workflows.config.staticValue'), cfg.static_value || '', _t('workflows.config.optional'))} `; break; case 'end': - wrap.innerHTML = bindingFieldHtml('workflow-end-result', 'workflows.config.resultBinding', bindingFromConfig(cfg, 'result_binding', 'outputs', 'result'), 'workflows.config.resultBindingHint'); + wrap.innerHTML = joinStrategyHtml(cfg) + bindingFieldHtml('workflow-end-result', 'workflows.config.resultBinding', bindingFromConfig(cfg, 'result_binding', 'outputs', 'result'), 'workflows.config.resultBindingHint'); break; default: wrap.innerHTML = ''; @@ -677,9 +729,13 @@ function readTypedConfig(ele) { if (!ele) return {}; if (!ele.isNode()) { - return { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' }; + const cfg = { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' }; + const branchEl = document.getElementById('workflow-edge-branch'); + if (branchEl) cfg.branch = branchEl.value || ''; + return cfg; } const type = ele.data('type') || 'tool'; + const join_strategy = (document.getElementById('workflow-join-strategy') || {}).value || 'all_merge'; switch (type) { case 'start': return { input_keys: (document.getElementById('workflow-start-input-keys') || {}).value || '' }; @@ -687,31 +743,35 @@ return { tool_name: (document.getElementById('workflow-tool-name') || {}).value || '', arguments: (document.getElementById('workflow-tool-arguments') || {}).value || '{}', - timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || '' + timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || '', + join_strategy }; case 'agent': return { agent_mode: (document.getElementById('workflow-agent-mode') || {}).value || 'eino_single', input_binding: readBinding('workflow-agent-input'), instruction: (document.getElementById('workflow-agent-instruction') || {}).value || '', - output_key: (document.getElementById('workflow-agent-output-key') || {}).value || 'agent_result' + output_key: (document.getElementById('workflow-agent-output-key') || {}).value || 'agent_result', + join_strategy }; case 'condition': - return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '' }; + return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '', join_strategy }; case 'hitl': return { prompt: (document.getElementById('workflow-hitl-prompt') || {}).value || '', prompt_binding: readBinding('workflow-hitl-prompt-binding'), - reviewer: (document.getElementById('workflow-hitl-reviewer') || {}).value || 'human' + reviewer: (document.getElementById('workflow-hitl-reviewer') || {}).value || 'human', + join_strategy }; case 'output': return { output_key: (document.getElementById('workflow-output-key') || {}).value || 'result', source_binding: readBinding('workflow-output-source'), - static_value: (document.getElementById('workflow-output-static') || {}).value || '' + static_value: (document.getElementById('workflow-output-static') || {}).value || '', + join_strategy }; case 'end': - return { result_binding: readBinding('workflow-end-result') }; + return { result_binding: readBinding('workflow-end-result'), join_strategy }; default: return {}; } @@ -937,6 +997,7 @@ 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'); + const terminals = nodes.filter(node => node.type === 'output' || node.type === 'end'); if (!starts.length) errors.push(_t('workflows.validation.needStart')); if (!outputs.length) errors.push(_t('workflows.validation.needOutput')); edges.forEach(edge => { @@ -950,6 +1011,15 @@ outputs.forEach(node => { if (edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.outputOutgoing', { label: node.label || node.id })); }); + nodes.filter(node => node.type === 'end').forEach(node => { + if (edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.outputOutgoing', { label: node.label || node.id })); + }); + nodes.filter(node => node.type !== 'start').forEach(node => { + if (!edges.some(edge => edge.target === node.id)) errors.push(_t('workflows.validation.nodeNeedsIncoming', { label: node.label || node.id })); + }); + nodes.filter(node => node.type !== 'output' && node.type !== 'end').forEach(node => { + if (!edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.nodeNeedsOutgoing', { label: node.label || node.id })); + }); nodes.filter(node => node.type === 'tool').forEach(node => { if (!String((node.config || {}).tool_name || '').trim()) { errors.push(_t('workflows.validation.toolNeedsMcp', { label: node.label || node.id })); @@ -965,13 +1035,86 @@ } else if (outEdges.length > 2) { errors.push(_t('workflows.validation.conditionTooManyEdges', { label: node.label || node.id })); } + const branches = outEdges.map(edge => String(((edge.config || {}).branch || edge.label || '')).trim().toLowerCase()); + if (branches.some(branch => !['true', 'false', '是', '否', 'yes', 'no', 'y', 'n'].includes(branch))) { + errors.push(_t('workflows.validation.conditionBranchLabel', { label: node.label || node.id })); + } + if (new Set(branches).size !== branches.length) { + errors.push(_t('workflows.validation.conditionBranchDuplicate', { label: node.label || node.id })); + } }); nodes.filter(node => node.type === 'output').forEach(node => { if (!String((node.config || {}).output_key || '').trim()) { errors.push(_t('workflows.validation.outputNeedsKey', { label: node.label || node.id })); } }); - return errors; + if (terminals.length) { + const outgoing = new Map(); + edges.forEach(edge => { + if (!outgoing.has(edge.source)) outgoing.set(edge.source, []); + outgoing.get(edge.source).push(edge.target); + }); + const reached = new Set(); + const queue = starts.map(node => node.id); + while (queue.length) { + const id = queue.shift(); + if (reached.has(id)) continue; + reached.add(id); + (outgoing.get(id) || []).forEach(next => queue.push(next)); + } + nodes.forEach(node => { + if (!reached.has(node.id)) errors.push(_t('workflows.validation.nodeUnreachable', { label: node.label || node.id })); + }); + const visiting = new Set(); + const visited = new Set(); + function visit(id) { + if (visiting.has(id)) return true; + if (visited.has(id)) return false; + visiting.add(id); + for (const next of (outgoing.get(id) || [])) { + if (visit(next)) return true; + } + visiting.delete(id); + visited.add(id); + return false; + } + nodes.forEach(node => { + if (visit(node.id)) errors.push(_t('workflows.validation.graphCycle', { label: node.label || node.id })); + }); + } + return Array.from(new Set(errors)); + } + + async function validateWorkflowGraphOnServer(graph) { + const response = await apiFetch('/api/workflows/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph }) + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || _t('workflows.validation.serverFailed')); + } + } + + function renderWorkflowDryRunTrace(result) { + const panel = document.getElementById('workflow-dry-run-panel'); + const output = document.getElementById('workflow-dry-run-output'); + if (!panel || !output) return; + const trace = (result && result.trace) || []; + panel.hidden = false; + if (!trace.length) { + output.textContent = _t('workflows.dryRunNoTrace') || 'No trace'; + return; + } + output.innerHTML = trace.map((item, index) => { + const status = item.status || ''; + const label = item.label || item.nodeId || ('#' + (index + 1)); + return `
+ ${index + 1}. ${esc(label)} + ${esc(item.type || '')} · ${esc(status)} +
`; + }).join(''); } window.saveWorkflowDraft = async function () { @@ -990,6 +1133,12 @@ showNotification(errors.slice(0, 4).join(';'), 'error'); return; } + try { + await validateWorkflowGraphOnServer(graph); + } catch (error) { + showNotification(error.message || _t('workflows.validation.serverFailed'), 'error'); + return; + } const method = currentWorkflowId ? 'PUT' : 'POST'; const url = currentWorkflowId ? `/api/workflows/${encodeURIComponent(currentWorkflowId)}` : '/api/workflows'; const response = await apiFetch(url, { @@ -1019,6 +1168,45 @@ } }; + window.dryRunWorkflowDraft = async function () { + initCy(); + const graph = elementsToGraph(); + const errors = validateWorkflowGraph(graph); + if (errors.length) { + showNotification(errors.slice(0, 4).join(';'), 'error'); + return; + } + const message = window.prompt(_t('workflows.dryRunPrompt') || 'Input message for dry-run', 'ping'); + if (message === null) return; + try { + const response = await apiFetch('/api/workflows/dry-run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph, inputs: { message } }) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || _t('workflows.dryRunFailed')); + } + const result = data.result || {}; + const trace = result.trace || []; + console.groupCollapsed('[Workflow dry-run]'); + console.table(trace.map(item => ({ + nodeId: item.nodeId, + label: item.label, + type: item.type, + status: item.status + }))); + console.log(result); + console.groupEnd(); + renderWorkflowDryRunTrace(result); + const summary = trace.slice(0, 8).map(item => `${item.label || item.nodeId}: ${item.status}`).join('\n'); + window.alert((_t('workflows.dryRunDone') || 'Dry-run completed') + '\n\n' + summary); + } catch (error) { + showNotification(error.message || _t('workflows.dryRunFailed'), 'error'); + } + }; + window.deleteCurrentWorkflow = async function () { const meta = readWorkflowMetaFromForm(); const id = currentWorkflowId || meta.id; @@ -1140,6 +1328,14 @@ mergeVisibleConfig(); }; + window.useWorkflowConditionExample = function (expr) { + const input = document.getElementById('workflow-condition-expression'); + if (!input) return; + input.value = expr || ''; + updateWorkflowTypedConfig(); + input.focus(); + }; + window.removeWorkflowCustomField = function (index) { if (!selectedElement) return; const entries = Object.entries(stripTypedConfig(selectedElement)); diff --git a/web/templates/index.html b/web/templates/index.html index 9b0e900a..9323efb6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2593,6 +2593,7 @@ +
@@ -2632,6 +2633,12 @@
+ @@ -3300,8 +3307,66 @@

配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。

+
+
+
+

机器人管理

+

先选择机器人类型,再进入对应配置;已配置的平台会显示连接状态。

+
+ +
+ +
+ + + + +
+
+ +
+

选择一个机器人开始配置

+

从上方列表点击已有平台,或点击“新建机器人”按类型创建。

+
+ -
+