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
+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));