Add files via upload

This commit is contained in:
公明
2026-07-06 11:31:13 +08:00
committed by GitHub
parent be2800c248
commit 7b8b57907b
7 changed files with 721 additions and 20 deletions
+221
View File
@@ -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;
+35
View File
@@ -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 <code>{{previous.matched}} == \"true\"</code> 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"
}
},
+35
View File
@@ -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": "节点会计算 matchedtrue/false),由出边决定分支:第一条线为「是」,第二条为「否」;也可在连线上写 <code>{{previous.matched}} == \"true\"</code>。",
"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}} 需要输出变量名"
}
},
+110
View File
@@ -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) {
+9
View File
@@ -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();
}
}
}
+212 -16
View File
@@ -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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
@@ -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 `
<div class="form-group">
<label for="workflow-join-strategy">${esc(_t('workflows.config.joinStrategy') || '汇聚策略')}</label>
<select id="workflow-join-strategy" onchange="updateWorkflowTypedConfig()">
${JOIN_STRATEGIES.map(strategy => `<option value="${strategy}" ${strategy === selected ? 'selected' : ''}>${strategy}</option>`).join('')}
</select>
<p class="workflow-config-hint">${esc(_t('workflows.config.joinStrategyHint') || '多个上游进入同一节点时如何生成 previous。')}</p>
</div>
`;
}
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 `
<div class="workflow-config-hint workflow-condition-guide">
<div><strong>${esc(_t('workflows.config.conditionGuideTitle'))}</strong></div>
<div>${esc(_t('workflows.config.conditionGuideVars'))}</div>
<div>${esc(_t('workflows.config.conditionGuideOps'))}</div>
<div>${esc(_t('workflows.config.conditionGuideJson'))}</div>
<div class="workflow-example-chips">
${examples.map(expr => `<button type="button" class="btn-secondary btn-small" onclick="useWorkflowConditionExample(this.dataset.expression)" data-expression="${esc(expr)}">${esc(expr)}</button>`).join('')}
</div>
</div>
`;
}
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' ? '<p class="workflow-config-hint">' + esc(_t('workflows.config.edgeBranchHint')) + '</p>' : ''}
${sourceType === 'condition' ? `
<div class="form-group">
<label for="workflow-edge-branch">${esc(_t('workflows.config.edgeBranch') || '条件分支')}</label>
<select id="workflow-edge-branch" onchange="updateWorkflowTypedConfig()">
<option value="">${esc(_t('workflows.config.selectBranch') || '请选择')}</option>
<option value="true" ${cfg.branch === 'true' ? 'selected' : ''}>true / </option>
<option value="false" ${cfg.branch === 'false' ? 'selected' : ''}>false / </option>
</select>
</div>
<p class="workflow-config-hint">${esc(_t('workflows.config.edgeBranchHint'))}</p>
` : ''}
`;
return;
}
@@ -583,6 +629,7 @@
break;
case 'tool':
wrap.innerHTML = `
${joinStrategyHtml(cfg)}
<div class="form-group">
<label for="workflow-tool-name">${esc(_t('workflows.config.mcpTool'))}</label>
<select id="workflow-tool-name" onchange="updateWorkflowTypedConfig()">
@@ -601,6 +648,7 @@
break;
case 'agent':
wrap.innerHTML = `
${joinStrategyHtml(cfg)}
<div class="form-group">
<label for="workflow-agent-mode">${esc(_t('workflows.config.agentMode'))}</label>
<select id="workflow-agent-mode" onchange="updateWorkflowTypedConfig()">
@@ -614,12 +662,15 @@
break;
case 'condition':
wrap.innerHTML = `
${joinStrategyHtml(cfg)}
${typedField('workflow-condition-expression', _t('workflows.config.conditionExpression'), cfg.expression, '{{previous.output}} != ""')}
<p class="workflow-config-hint">${_t('workflows.config.conditionHint')}</p>
${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')}
<p class="workflow-config-hint">${_t('workflows.config.hitlInteractiveHint')}</p>
@@ -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 `<div class="workflow-dry-run-step">
<strong>${index + 1}. ${esc(label)}</strong>
<span>${esc(item.type || '')} · ${esc(status)}</span>
</div>`;
}).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));