Files
CyberStrikeAI/web/static/js/workflows.js
T
2026-07-06 12:59:54 +08:00

1524 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
function _t(key, opts) {
if (typeof window.t === 'function') {
try {
var translated = window.t(key, opts);
if (typeof translated === 'string' && translated && translated !== key) {
return translated;
}
} catch (e) { /* ignore */ }
}
return key;
}
let workflows = [];
let currentWorkflowId = '';
let cy = null;
let nodeSeq = 1;
let edgeSeq = 1;
let connectMode = false;
let connectSourceId = '';
let selectedElement = null;
let workflowToolOptions = [];
let workflowToolsLoaded = false;
const KNOWN_NODE_LABELS = {
start: ['开始', 'Start'],
tool: ['工具', 'Tool'],
agent: ['Agent'],
condition: ['条件', 'Condition'],
hitl: ['审批', 'Approval'],
output: ['输出', 'Output'],
end: ['结束', 'End']
};
const KNOWN_EDGE_LABELS = {
yes: ['是', 'Yes'],
no: ['否', 'No']
};
function wfNodeLabel(type) {
const key = type && KNOWN_NODE_LABELS[type] ? 'workflows.nodes.' + type : 'workflows.nodes.default';
return _t(key);
}
const AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor'];
const JOIN_STRATEGIES = ['all_merge', 'last_by_canvas', 'first_non_empty', 'fail_fast'];
const NODE_DEFAULT_SIZE = { w: 150, h: 52 };
const NODE_TYPE_SIZES = { condition: { w: 118, h: 86 } };
const NODE_PLACEMENT_GAP = 48;
const NODE_PLACEMENT_PADDING = 20;
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>';
function esc(text) {
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const BINDING_FROM_OPTIONS = ['previous', 'inputs', 'outputs'];
function bindingFromConfig(cfg, key, fallbackFrom, fallbackField) {
const b = cfg && cfg[key];
if (b && typeof b === 'object') {
return {
from: b.from || fallbackFrom,
field: b.field || fallbackField
};
}
return { from: fallbackFrom, field: fallbackField };
}
function bindingFieldHtml(prefix, labelKey, binding, hintKey) {
const from = binding.from || 'previous';
const field = binding.field || 'output';
const label = _t(labelKey);
const hint = hintKey ? _t(hintKey) : '';
const options = BINDING_FROM_OPTIONS.map(v =>
`<option value="${esc(v)}" ${v === from ? 'selected' : ''}>${esc(v)}</option>`
).join('');
return `
<div class="form-group">
<label>${esc(label)}</label>
<div class="workflow-binding-row" style="display:flex;gap:8px;">
<select id="${prefix}-from" onchange="updateWorkflowTypedConfig()" style="flex:1;">${options}</select>
<input type="text" id="${prefix}-field" value="${esc(field)}" placeholder="output" oninput="updateWorkflowTypedConfig()" style="flex:1;">
</div>
${hint ? '<p class="workflow-config-hint">' + hint + '</p>' : ''}
</div>`;
}
function readBinding(prefix) {
return {
from: (document.getElementById(prefix + '-from') || {}).value || 'previous',
field: (document.getElementById(prefix + '-field') || {}).value || 'output'
};
}
function defaultGraph() {
return { nodes: [], edges: [], config: {} };
}
function defaultConfigForType(type) {
switch (type) {
case 'start':
return { input_keys: 'message, conversationId, projectId' };
case 'tool':
return { tool_name: '', arguments: '{}', timeout_seconds: '', join_strategy: 'all_merge' };
case 'agent':
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}} != ""', join_strategy: 'all_merge' };
case 'hitl':
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' }, join_strategy: 'all_merge' };
case 'end':
return { result_binding: { from: 'outputs', field: 'result' }, join_strategy: 'all_merge' };
default:
return {};
}
}
function configWithDefaults(type, config) {
return Object.assign(defaultConfigForType(type), config && typeof config === 'object' ? config : {});
}
function parseGraph(raw) {
if (!raw) return defaultGraph();
let graph = raw;
if (typeof raw === 'string') {
try {
graph = JSON.parse(raw);
} catch (_) {
return defaultGraph();
}
}
return {
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
edges: Array.isArray(graph.edges) ? graph.edges : [],
config: graph.config && typeof graph.config === 'object' ? graph.config : {}
};
}
function graphToElements(graph) {
const nodes = (graph.nodes || []).map((node, index) => ({
group: 'nodes',
data: {
id: node.id || `node-${index + 1}`,
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
type: node.type || 'tool',
config: configWithDefaults(node.type || 'tool', node.config)
},
position: node.position || { x: 120 + index * 80, y: 120 + index * 40 }
}));
const edges = (graph.edges || []).map((edge, index) => ({
group: 'edges',
data: {
id: edge.id || `edge-${index + 1}`,
source: edge.source,
target: edge.target,
label: edge.label || '',
config: edge.config && typeof edge.config === 'object' ? edge.config : {}
}
})).filter(edge => edge.data.source && edge.data.target);
return nodes.concat(edges);
}
function elementsToGraph() {
if (!cy) return defaultGraph();
return {
nodes: cy.nodes().map(node => ({
id: node.id(),
type: node.data('type') || 'tool',
label: node.data('label') || '',
position: node.position(),
config: node.data('config') || {}
})),
edges: cy.edges().map(edge => ({
id: edge.id(),
source: edge.source().id(),
target: edge.target().id(),
label: edge.data('label') || '',
config: edge.data('config') || {}
})),
config: { schema_version: 1 }
};
}
function updateEmptyState() {
const empty = document.getElementById('workflow-canvas-empty');
if (!empty || !cy) return;
empty.style.display = cy.nodes().length ? 'none' : 'flex';
}
let workflowResizeObserver = null;
function setupWorkflowResizeObserver(container) {
if (workflowResizeObserver || typeof ResizeObserver === 'undefined' || !container) return;
workflowResizeObserver = new ResizeObserver(function () {
if (cy) cy.resize();
});
const canvasWrap = container.closest('.workflow-canvas-wrap');
const pageContent = container.closest('.workflow-page-content');
if (canvasWrap) workflowResizeObserver.observe(canvasWrap);
if (pageContent) workflowResizeObserver.observe(pageContent);
}
function initCy() {
const container = document.getElementById('workflow-canvas');
if (!container || typeof cytoscape !== 'function') return;
if (cy) {
cy.resize();
return;
}
cy = cytoscape({
container,
elements: [],
wheelSensitivity: 0.18,
style: [
{
selector: 'node',
style: {
'shape': 'round-rectangle',
'width': 150,
'height': 52,
'background-color': '#1d4ed8',
'border-width': 1,
'border-color': '#60a5fa',
'label': 'data(label)',
'color': '#e5edff',
'font-size': 13,
'font-weight': 700,
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': 132
}
},
{ selector: 'node[type="start"]', style: { 'background-color': '#047857', 'border-color': '#34d399' } },
{ selector: 'node[type="tool"]', style: { 'background-color': '#1d4ed8', 'border-color': '#60a5fa' } },
{ selector: 'node[type="agent"]', style: { 'background-color': '#7c3aed', 'border-color': '#c4b5fd' } },
{ selector: 'node[type="condition"]', style: { 'shape': 'diamond', 'background-color': '#b45309', 'border-color': '#fbbf24', 'width': 118, 'height': 86 } },
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#64748b',
'target-arrow-color': '#64748b',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': 11,
'color': '#cbd5e1',
'text-background-color': '#0f172a',
'text-background-opacity': 0.8,
'text-background-padding': 3
}
},
{
selector: ':selected',
style: {
'border-width': 3,
'border-color': '#93c5fd',
'line-color': '#93c5fd',
'target-arrow-color': '#93c5fd'
}
},
{
selector: '.connect-source',
style: {
'border-width': 4,
'border-color': '#fbbf24'
}
},
{
selector: 'node.just-added',
style: {
'border-width': 4,
'border-color': '#fbbf24',
'border-opacity': 1,
'z-index': 999
}
}
],
layout: { name: 'preset' }
});
cy.on('tap', 'node', event => {
if (connectMode) {
handleConnectTap(event.target);
return;
}
selectWorkflowElement(event.target);
});
cy.on('tap', 'edge', event => {
selectWorkflowElement(event.target);
});
cy.on('tap', event => {
if (event.target === cy) {
if (connectMode) clearConnectSource();
selectWorkflowElement(null);
}
});
cy.on('add remove', updateEmptyState);
document.addEventListener('keydown', event => {
const active = document.activeElement;
const editing = active && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName);
if (editing) return;
if (typeof currentPage !== 'undefined' && currentPage !== 'workflows') return;
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
deleteWorkflowSelection();
}
});
setupWorkflowResizeObserver(container);
}
async function loadWorkflows(includeDisabled) {
const response = await apiFetch(`/api/workflows?includeDisabled=${includeDisabled ? 'true' : 'false'}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || _t('workflows.loadFailed'));
}
const data = await response.json();
workflows = data.workflows || [];
return workflows;
}
async function loadWorkflowTools() {
if (workflowToolsLoaded) return workflowToolOptions;
const collected = [];
const seen = new Set();
let page = 1;
let totalPages = 1;
while (page <= totalPages && page <= 20) {
const response = await apiFetch(`/api/config/tools?page=${page}&page_size=100`);
if (!response.ok) break;
const data = await response.json();
totalPages = data.total_pages || 1;
(data.tools || []).forEach(tool => {
if (!tool || !tool.name) return;
const key = tool.is_external && tool.external_mcp ? `${tool.external_mcp}::${tool.name}` : tool.name;
if (seen.has(key)) return;
seen.add(key);
collected.push({ key, name: tool.name, enabled: tool.enabled !== false });
});
page += 1;
}
workflowToolOptions = collected;
workflowToolsLoaded = true;
return workflowToolOptions;
}
function readWorkflowMetaFromForm() {
const idEl = document.getElementById('workflow-id');
const nameEl = document.getElementById('workflow-name');
const descEl = document.getElementById('workflow-description');
const enabledEl = document.getElementById('workflow-enabled');
return {
id: idEl ? idEl.value.trim() : '',
name: nameEl ? nameEl.value.trim() : '',
description: descEl ? descEl.value.trim() : '',
enabled: enabledEl ? enabledEl.checked : true
};
}
function updateWorkflowCanvasTitle() {
const titleEl = document.getElementById('workflow-canvas-title');
const subtitleEl = document.getElementById('workflow-canvas-subtitle');
if (!titleEl) return;
const meta = readWorkflowMetaFromForm();
const wf = workflows.find(item => item.id === currentWorkflowId);
if (!meta.name && !meta.id) {
titleEl.textContent = _t('workflows.untitled');
} else {
titleEl.textContent = meta.name || meta.id;
}
titleEl.classList.toggle('is-disabled', !meta.enabled);
titleEl.title = meta.description || '';
if (subtitleEl) {
const parts = [];
if (meta.id) parts.push(meta.id);
if (wf && wf.version) parts.push(`v${wf.version}`);
parts.push(meta.enabled ? _t('workflows.statusEnabled') : _t('workflows.statusDisabled'));
subtitleEl.textContent = parts.join(' · ');
subtitleEl.hidden = !parts.length;
}
}
function syncWorkflowMetaIdField(locked, id) {
const idEl = document.getElementById('workflow-id');
const lockedEl = document.getElementById('workflow-id-locked');
const displayEl = document.getElementById('workflow-id-display');
const hintEl = document.querySelector('.workflow-meta-id-hint');
const idGroup = document.getElementById('workflow-meta-id-group');
if (!idEl) return;
idEl.value = id || '';
if (locked) {
idEl.hidden = true;
idEl.disabled = true;
if (lockedEl) lockedEl.hidden = false;
if (displayEl) displayEl.textContent = id || '';
if (hintEl) hintEl.hidden = true;
if (idGroup) idGroup.classList.add('is-locked');
} else {
idEl.hidden = false;
idEl.disabled = false;
if (lockedEl) lockedEl.hidden = true;
if (displayEl) displayEl.textContent = '';
if (hintEl) hintEl.hidden = false;
if (idGroup) idGroup.classList.remove('is-locked');
}
}
function syncWorkflowMetaForm(wf) {
const nameEl = document.getElementById('workflow-name');
const descEl = document.getElementById('workflow-description');
const enabledEl = document.getElementById('workflow-enabled');
if (!nameEl || !descEl || !enabledEl) return;
syncWorkflowMetaIdField(!!wf.id, wf.id || '');
nameEl.value = wf.name || '';
descEl.value = wf.description || '';
enabledEl.checked = wf.enabled !== false;
updateWorkflowCanvasTitle();
}
function renderWorkflowList() {
const list = document.getElementById('workflow-list');
if (!list) return;
if (!workflows.length) {
list.innerHTML = '<div class="empty-state">' + esc(_t('workflows.emptyList')) + '</div>';
return;
}
list.innerHTML = workflows.map(wf => {
const encodedId = encodeURIComponent(wf.id);
const isActive = wf.id === currentWorkflowId;
const toggleTitle = esc(_t('workflows.toggleEnabled'));
const editTitle = esc(_t('workflows.editMeta'));
const enabled = wf.enabled !== false;
return `
<div class="workflow-list-item ${isActive ? 'is-active' : ''}">
<button type="button" class="workflow-list-main" onclick="selectWorkflow(decodeURIComponent('${encodedId}'))">
<span class="workflow-list-title">${esc(wf.name || wf.id)}</span>
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1}</span>
</button>
<div class="workflow-list-actions">
<label class="workflow-switch" title="${toggleTitle}" onclick="event.stopPropagation()">
<input type="checkbox" ${enabled ? 'checked' : ''} aria-label="${toggleTitle}" onchange="toggleWorkflowEnabled(decodeURIComponent('${encodedId}'), this.checked)">
<span class="workflow-switch-slider" aria-hidden="true"></span>
</label>
<button type="button" class="btn-icon workflow-list-edit" title="${editTitle}" aria-label="${editTitle}" onclick="event.stopPropagation(); editWorkflowFromList(decodeURIComponent('${encodedId}'))">${WORKFLOW_EDIT_ICON}</button>
</div>
</div>
`;
}).join('');
}
function nextNodeId(type) {
while (cy && cy.getElementById(`node-${nodeSeq}`).length) nodeSeq += 1;
const id = `node-${nodeSeq}`;
nodeSeq += 1;
return id;
}
function nextEdgeId() {
while (cy && cy.getElementById(`edge-${edgeSeq}`).length) edgeSeq += 1;
const id = `edge-${edgeSeq}`;
edgeSeq += 1;
return id;
}
function resetSequences(graph) {
nodeSeq = 1;
edgeSeq = 1;
(graph.nodes || []).forEach(node => {
const m = String(node.id || '').match(/^node-(\d+)$/);
if (m) nodeSeq = Math.max(nodeSeq, Number(m[1]) + 1);
});
(graph.edges || []).forEach(edge => {
const m = String(edge.id || '').match(/^edge-(\d+)$/);
if (m) edgeSeq = Math.max(edgeSeq, Number(m[1]) + 1);
});
}
function fillWorkflowForm(wf) {
const data = wf || {};
syncWorkflowMetaForm(data);
currentWorkflowId = data.id ? data.id : '';
initCy();
if (!cy) return;
const graph = parseGraph(data.graph_json || data.graph || defaultGraph());
resetSequences(graph);
cy.elements().remove();
cy.add(graphToElements(graph));
if (cy.nodes().length) {
layoutWorkflowGraph(false);
}
selectWorkflowElement(null);
updateEmptyState();
renderWorkflowList();
setTimeout(() => cy && cy.resize(), 0);
}
function selectWorkflowElement(ele) {
selectedElement = ele && ele.length ? ele : null;
const empty = document.getElementById('workflow-property-empty');
const form = document.getElementById('workflow-property-form');
const title = document.getElementById('workflow-property-title');
const deleteBtn = document.getElementById('workflow-property-delete-btn');
if (!empty || !form) return;
if (!selectedElement) {
empty.hidden = false;
form.hidden = true;
if (title) title.textContent = _t('workflows.properties');
if (deleteBtn) deleteBtn.hidden = true;
return;
}
empty.hidden = true;
form.hidden = false;
if (title) title.textContent = selectedElement.isNode() ? _t('workflows.nodeProperties') : _t('workflows.edgeProperties');
if (deleteBtn) {
deleteBtn.hidden = false;
deleteBtn.textContent = selectedElement.isNode() ? _t('workflows.deleteNode') : _t('workflows.deleteEdge');
}
cy.elements().unselect();
selectedElement.select();
const typeWrap = document.getElementById('workflow-prop-type-wrap');
const label = document.getElementById('workflow-prop-label');
const type = document.getElementById('workflow-prop-type');
label.value = selectedElement.data('label') || '';
if (selectedElement.isNode()) {
typeWrap.style.display = '';
type.value = selectedElement.data('type') || 'tool';
} else {
typeWrap.style.display = 'none';
}
renderTypedConfig(selectedElement);
renderCustomFields(stripTypedConfig(selectedElement));
}
function typedKeysForType(type) {
return new Set(Object.keys(defaultConfigForType(type)));
}
function stripTypedConfig(ele) {
const cfg = Object.assign({}, ele.data('config') || {});
const typed = ele.isNode() ? typedKeysForType(ele.data('type') || 'tool') : new Set(['condition']);
typed.forEach(key => delete cfg[key]);
return cfg;
}
function typedField(id, label, value, placeholder) {
return `
<div class="form-group">
<label for="${id}">${label}</label>
<input type="text" id="${id}" class="form-input" value="${esc(value || '')}" placeholder="${esc(placeholder || '')}" oninput="updateWorkflowTypedConfig()">
</div>
`;
}
function typedTextarea(id, label, value, placeholder) {
return `
<div class="form-group">
<label for="${id}">${label}</label>
<textarea id="${id}" class="form-input" rows="4" placeholder="${esc(placeholder || '')}" oninput="updateWorkflowTypedConfig()">${esc(value || '')}</textarea>
</div>
`;
}
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;
const cfg = configWithDefaults(ele.isNode() ? ele.data('type') : 'edge', ele.data('config') || {});
if (!ele.isNode()) {
const sourceType = ele.source().data('type') || '';
const edgeHint = sourceType === 'condition'
? _t('workflows.config.edgeConditionHintCondition')
: _t('workflows.config.edgeConditionHintExample');
wrap.innerHTML = `
${typedField('workflow-edge-condition', _t('workflows.config.edgeCondition'), cfg.condition || '', edgeHint)}
${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;
}
const type = ele.data('type') || 'tool';
switch (type) {
case 'start':
wrap.innerHTML = typedField('workflow-start-input-keys', _t('workflows.config.inputKeys'), cfg.input_keys, 'message, projectId');
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()">
<option value="">${esc(_t('workflows.config.selectTool'))}</option>
${workflowToolOptions.map(tool => `<option value="${esc(tool.key)}" ${tool.key === cfg.tool_name ? 'selected' : ''}>${esc(tool.key)}${tool.enabled ? '' : esc(_t('workflows.config.toolDisabled'))}</option>`).join('')}
</select>
</div>
${typedTextarea('workflow-tool-arguments', _t('workflows.config.argumentsStatic'), cfg.arguments, '{"target":"example.com"}')}
${typedField('workflow-tool-timeout', _t('workflows.config.timeoutSeconds'), cfg.timeout_seconds, _t('workflows.config.optional'))}
`;
if (!workflowToolsLoaded) {
loadWorkflowTools().then(() => {
if (selectedElement === ele) renderTypedConfig(ele);
});
}
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()">
${AGENT_MODES.map(mode => `<option value="${mode}" ${mode === cfg.agent_mode ? 'selected' : ''}>${mode}</option>`).join('')}
</select>
</div>
${bindingFieldHtml('workflow-agent-input', 'workflows.config.inputBinding', bindingFromConfig(cfg, 'input_binding', 'previous', 'output'), 'workflows.config.inputBindingHint')}
${typedTextarea('workflow-agent-instruction', _t('workflows.config.nodeInstruction'), cfg.instruction, _t('workflows.config.instructionPlaceholder'))}
${typedField('workflow-agent-output-key', _t('workflows.config.outputKey'), cfg.output_key, 'agent_result')}
`;
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>
<div class="form-group">
<label for="workflow-hitl-reviewer">${esc(_t('workflows.config.hitlReviewer'))}</label>
<select id="workflow-hitl-reviewer" onchange="updateWorkflowTypedConfig()">
<option value="human" ${cfg.reviewer === 'human' ? 'selected' : ''}>human</option>
<option value="audit_agent" ${cfg.reviewer === 'audit_agent' ? 'selected' : ''}>audit_agent</option>
</select>
</div>
`;
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 = joinStrategyHtml(cfg) + bindingFieldHtml('workflow-end-result', 'workflows.config.resultBinding', bindingFromConfig(cfg, 'result_binding', 'outputs', 'result'), 'workflows.config.resultBindingHint');
break;
default:
wrap.innerHTML = '';
}
}
function renderCustomFields(config) {
const wrap = document.getElementById('workflow-custom-fields');
if (!wrap) return;
const entries = Object.entries(config || {});
if (!entries.length) {
wrap.innerHTML = '<div class="workflow-property-empty workflow-property-empty--compact">' + esc(_t('workflows.noCustomFields')) + '</div>';
return;
}
wrap.innerHTML = entries.map(([key, value], index) => `
<div class="workflow-custom-field" data-index="${index}">
<input type="text" value="${esc(key)}" data-field-key oninput="updateWorkflowCustomFields()">
<input type="text" value="${esc(String(value == null ? '' : value))}" data-field-value oninput="updateWorkflowCustomFields()">
<button type="button" onclick="removeWorkflowCustomField(${index})">×</button>
</div>
`).join('');
}
function readCustomFields() {
const out = {};
document.querySelectorAll('#workflow-custom-fields .workflow-custom-field').forEach(row => {
const key = row.querySelector('[data-field-key]').value.trim();
const value = row.querySelector('[data-field-value]').value;
if (key) out[key] = value;
});
return out;
}
function readTypedConfig(ele) {
if (!ele) return {};
if (!ele.isNode()) {
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 || '' };
case 'tool':
return {
tool_name: (document.getElementById('workflow-tool-name') || {}).value || '',
arguments: (document.getElementById('workflow-tool-arguments') || {}).value || '{}',
timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || '',
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',
join_strategy
};
case 'condition':
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',
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 || '',
join_strategy
};
case 'end':
return { result_binding: readBinding('workflow-end-result'), join_strategy };
default:
return {};
}
}
function mergeVisibleConfig() {
if (!selectedElement) return;
selectedElement.data('config', Object.assign({}, readCustomFields(), readTypedConfig(selectedElement)));
}
function handleConnectTap(node) {
if (!connectSourceId) {
connectSourceId = node.id();
node.addClass('connect-source');
return;
}
if (connectSourceId === node.id()) {
clearConnectSource();
return;
}
const duplicate = cy.edges().some(edge => edge.source().id() === connectSourceId && edge.target().id() === node.id());
if (duplicate) {
if (typeof showNotification === 'function') {
showNotification(_t('workflows.duplicateEdge'), 'warning');
}
clearConnectSource();
return;
}
const sourceNode = cy.getElementById(connectSourceId);
const sourceType = sourceNode.data('type') || '';
let edgeLabel = '';
let edgeConfig = {};
if (sourceType === 'condition') {
const siblingCount = cy.edges().filter(edge => edge.source().id() === connectSourceId).length;
if (siblingCount === 0) {
edgeLabel = _t('workflows.edges.yes');
edgeConfig = { condition: '{{previous.matched}} == "true"', branch: 'true' };
} else if (siblingCount === 1) {
edgeLabel = _t('workflows.edges.no');
edgeConfig = { condition: '{{previous.matched}} == "false"', branch: 'false' };
} else {
edgeConfig = { condition: '' };
}
}
cy.add({
group: 'edges',
data: {
id: nextEdgeId(),
source: connectSourceId,
target: node.id(),
label: edgeLabel,
config: edgeConfig
}
});
clearConnectSource();
}
function clearConnectSource() {
if (cy) cy.nodes().removeClass('connect-source');
connectSourceId = '';
}
function nodeSizeForType(type) {
return NODE_TYPE_SIZES[type] || NODE_DEFAULT_SIZE;
}
function viewportCenterPosition() {
const pan = cy.pan();
const zoom = cy.zoom();
const container = cy.container();
return {
x: (container.clientWidth / 2 - pan.x) / zoom,
y: (container.clientHeight / 2 - pan.y) / zoom
};
}
function positionOverlaps(x, y, width, height, excludeId) {
const pad = NODE_PLACEMENT_PADDING;
const hw = width / 2 + pad;
const hh = height / 2 + pad;
return cy.nodes().some(node => {
if (excludeId && node.id() === excludeId) return false;
const p = node.position();
const bb = node.boundingBox();
return Math.abs(p.x - x) < hw + bb.w / 2 && Math.abs(p.y - y) < hh + bb.h / 2;
});
}
function findOpenPosition(anchor, type) {
const size = nodeSizeForType(type);
const step = 36;
for (let i = 0; i < 20; i++) {
const x = anchor.x + (i % 5) * step;
const y = anchor.y + Math.floor(i / 5) * step;
if (!positionOverlaps(x, y, size.w, size.h)) {
return { x, y };
}
}
return anchor;
}
function anchorFromSelection(type) {
if (selectedElement && selectedElement.length && selectedElement.isNode()) {
const p = selectedElement.position();
const srcBb = selectedElement.boundingBox();
const size = nodeSizeForType(type);
const gap = NODE_PLACEMENT_GAP;
const candidates = [
{ x: p.x + srcBb.w / 2 + gap + size.w / 2, y: p.y },
{ x: p.x, y: p.y + srcBb.h / 2 + gap + size.h / 2 },
{ x: p.x - srcBb.w / 2 - gap - size.w / 2, y: p.y },
{ x: p.x, y: p.y - srcBb.h / 2 - gap - size.h / 2 }
];
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
if (!positionOverlaps(c.x, c.y, size.w, size.h)) {
return c;
}
}
return findOpenPosition(candidates[0], type);
}
return viewportCenterPosition();
}
function defaultNodePosition(type) {
return findOpenPosition(anchorFromSelection(type), type);
}
function isPositionInViewport(x, y, padding) {
const extent = cy.extent();
const pad = padding == null ? 40 : padding;
return x >= extent.x1 + pad && x <= extent.x2 - pad &&
y >= extent.y1 + pad && y <= extent.y2 - pad;
}
function highlightNewNode(node) {
if (!node || !node.length) return;
if (typeof node.flashClass === 'function') {
node.flashClass('just-added', 650);
} else {
node.addClass('just-added');
setTimeout(function () {
if (node.nonempty()) node.removeClass('just-added');
}, 650);
}
}
function revealWorkflowNode(node) {
if (!node || !node.length) return;
const p = node.position();
if (!isPositionInViewport(p.x, p.y)) {
cy.animate({ center: { eles: node }, duration: 200 });
}
highlightNewNode(node);
}
function addNode(type, position) {
initCy();
if (!cy) return;
const node = cy.add({
group: 'nodes',
data: {
id: nextNodeId(type),
type,
label: wfNodeLabel(type),
config: defaultConfigForType(type)
},
position: position || defaultNodePosition(type)
});
selectWorkflowElement(node);
updateEmptyState();
if (position) {
highlightNewNode(node);
} else {
revealWorkflowNode(node);
}
}
window.refreshWorkflows = async function () {
initCy();
const list = document.getElementById('workflow-list');
if (list) list.innerHTML = '<div class="loading-spinner">' + esc(_t('common.loading')) + '</div>';
try {
await loadWorkflows(true);
if (currentWorkflowId) {
const wf = workflows.find(item => item.id === currentWorkflowId);
if (wf) {
syncWorkflowMetaForm(wf);
}
} else if (workflows.length) {
fillWorkflowForm(workflows[0]);
} else {
newWorkflowDraft({ openMeta: false });
return;
}
renderWorkflowList();
} catch (error) {
if (list) list.innerHTML = `<div class="empty-state">${esc(error.message)}</div>`;
if (typeof showNotification === 'function') showNotification(error.message, 'error');
}
};
window.newWorkflowDraft = function (options) {
const shouldOpenMeta = !options || options.openMeta !== false;
currentWorkflowId = '';
fillWorkflowForm({
id: '',
name: '',
description: '',
enabled: true,
graph_json: defaultGraph()
});
syncWorkflowMetaIdField(false, '');
if (shouldOpenMeta) {
openWorkflowMetaModal();
}
};
window.selectWorkflow = function (id) {
const wf = workflows.find(item => item.id === id);
if (wf) fillWorkflowForm(wf);
};
window.openWorkflowMetaModal = function () {
const nameEl = document.getElementById('workflow-name');
const idEl = document.getElementById('workflow-id');
if (currentWorkflowId) {
syncWorkflowMetaIdField(true, currentWorkflowId);
} else {
syncWorkflowMetaIdField(false, idEl ? idEl.value.trim() : '');
}
if (typeof openAppModal === 'function') {
openAppModal('workflow-meta-modal', {
focusEl: currentWorkflowId ? nameEl : (idEl && !idEl.hidden ? idEl : nameEl)
});
}
};
window.closeWorkflowMetaModal = function () {
if (typeof closeAppModal === 'function') {
closeAppModal('workflow-meta-modal');
}
};
window.applyWorkflowMetaModal = function () {
const meta = readWorkflowMetaFromForm();
if (!meta.id || !meta.name) {
if (typeof showNotification === 'function') {
showNotification(_t('workflows.idNameRequired'), 'error');
}
return;
}
updateWorkflowCanvasTitle();
renderWorkflowList();
closeWorkflowMetaModal();
};
window.editWorkflowFromList = function (id) {
if (id !== currentWorkflowId) {
selectWorkflow(id);
}
openWorkflowMetaModal();
};
window.toggleWorkflowEnabled = async function (id, enabled) {
const wf = workflows.find(item => item.id === id);
if (!wf) return;
const previous = wf.enabled !== false;
wf.enabled = enabled;
if (id === currentWorkflowId) {
const enabledEl = document.getElementById('workflow-enabled');
if (enabledEl) enabledEl.checked = enabled;
updateWorkflowCanvasTitle();
}
renderWorkflowList();
let graph = defaultGraph();
if (id === currentWorkflowId && cy) {
graph = elementsToGraph();
} else {
graph = parseGraph(wf.graph_json || wf.graph || defaultGraph());
}
try {
const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: wf.id,
name: wf.name,
description: wf.description || '',
enabled,
graph
})
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || _t('workflows.enabledUpdateFailed'));
}
if (typeof showNotification === 'function') {
showNotification(_t('workflows.enabledUpdated'), 'success');
}
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
await loadWorkflowOptionsForRoleModal();
}
} catch (error) {
wf.enabled = previous;
if (id === currentWorkflowId) {
const enabledEl = document.getElementById('workflow-enabled');
if (enabledEl) enabledEl.checked = previous;
updateWorkflowCanvasTitle();
}
renderWorkflowList();
if (typeof showNotification === 'function') {
showNotification(error.message || _t('workflows.enabledUpdateFailed'), 'error');
}
}
};
function validateWorkflowGraph(graph) {
const errors = [];
const nodes = graph.nodes || [];
const edges = graph.edges || [];
const ids = new Set(nodes.map(node => node.id));
const starts = nodes.filter(node => node.type === 'start');
const outputs = nodes.filter(node => node.type === 'output');
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 => {
if (edge.source === edge.target) errors.push(_t('workflows.validation.edgeSelfLoop', { id: edge.id }));
if (!ids.has(edge.source)) errors.push(_t('workflows.validation.edgeSourceMissing', { id: edge.id }));
if (!ids.has(edge.target)) errors.push(_t('workflows.validation.edgeTargetMissing', { id: edge.id }));
});
starts.forEach(node => {
if (edges.some(edge => edge.target === node.id)) errors.push(_t('workflows.validation.startIncoming', { label: node.label || node.id }));
});
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 }));
}
});
nodes.filter(node => node.type === 'condition').forEach(node => {
if (!String((node.config || {}).expression || '').trim()) {
errors.push(_t('workflows.validation.conditionNeedsExpr', { label: node.label || node.id }));
}
const outEdges = edges.filter(edge => edge.source === node.id);
if (outEdges.length === 0) {
errors.push(_t('workflows.validation.conditionNeedsOutEdge', { label: node.label || node.id }));
} 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 }));
}
});
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 () {
initCy();
const meta = readWorkflowMetaFromForm();
if (!meta.id || !meta.name) {
if (typeof showNotification === 'function') {
showNotification(_t('workflows.idNameRequired'), 'error');
}
openWorkflowMetaModal();
return;
}
const graph = elementsToGraph();
const errors = validateWorkflowGraph(graph);
if (errors.length) {
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, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: meta.id,
name: meta.name,
description: meta.description,
enabled: meta.enabled,
graph
})
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.error || _t('workflows.saveFailed'), 'error');
return;
}
const data = await response.json();
currentWorkflowId = data.workflow && data.workflow.id ? data.workflow.id : meta.id;
syncWorkflowMetaIdField(true, currentWorkflowId);
closeWorkflowMetaModal();
showNotification(_t('workflows.saved'), 'success');
await refreshWorkflows();
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
await loadWorkflowOptionsForRoleModal();
}
};
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;
if (!id) {
showNotification(_t('workflows.selectToDelete'), 'warning');
return;
}
if (!confirm(_t('workflows.confirmDelete', { id: id }))) return;
const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.error || _t('workflows.deleteFailed'), 'error');
return;
}
currentWorkflowId = '';
showNotification(_t('workflows.deleted'), 'success');
newWorkflowDraft({ openMeta: false });
await refreshWorkflows();
};
window.workflowPaletteDragStart = function (event) {
const type = event.currentTarget.dataset.nodeType || 'tool';
event.dataTransfer.setData('application/x-workflow-node', type);
event.dataTransfer.setData('text/plain', type);
event.dataTransfer.effectAllowed = 'copy';
};
window.workflowCanvasDragOver = function (event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
};
window.workflowCanvasDrop = function (event) {
event.preventDefault();
const type = event.dataTransfer.getData('application/x-workflow-node') || event.dataTransfer.getData('text/plain') || 'tool';
const rect = document.getElementById('workflow-canvas').getBoundingClientRect();
const pan = cy.pan();
const zoom = cy.zoom();
addNode(type, {
x: (event.clientX - rect.left - pan.x) / zoom,
y: (event.clientY - rect.top - pan.y) / zoom
});
};
window.addWorkflowNodeFromPalette = function (type) {
addNode(type || 'tool');
};
window.toggleWorkflowConnectMode = function () {
connectMode = !connectMode;
clearConnectSource();
const btn = document.getElementById('workflow-connect-btn');
if (btn) {
btn.classList.toggle('active', connectMode);
btn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect');
}
if (typeof showNotification === 'function') {
showNotification(connectMode ? _t('workflows.connectModeOn') : _t('workflows.connectModeOff'), 'info');
}
};
window.deleteWorkflowSelection = function () {
if (!cy) return;
const selected = selectedElement && selectedElement.length ? selectedElement : cy.$(':selected');
if (!selected.length) return;
selected.remove();
selectWorkflowElement(null);
updateEmptyState();
};
window.layoutWorkflowGraph = function (animate) {
if (!cy || !cy.nodes().length) return;
cy.layout({
name: 'breadthfirst',
directed: true,
padding: 40,
spacingFactor: 1.25,
animate: animate !== false,
animationDuration: 250
}).run();
cy.fit(undefined, 40);
};
window.updateWorkflowSelectedProperty = function () {
if (!selectedElement) return;
const label = document.getElementById('workflow-prop-label').value.trim();
selectedElement.data('label', label);
if (selectedElement.isNode()) {
const type = document.getElementById('workflow-prop-type').value || 'tool';
const prevType = selectedElement.data('type') || 'tool';
selectedElement.data('type', type);
if (type !== prevType) {
selectedElement.data('config', defaultConfigForType(type));
selectedElement.data('label', label || wfNodeLabel(type));
document.getElementById('workflow-prop-label').value = selectedElement.data('label') || '';
renderTypedConfig(selectedElement);
renderCustomFields({});
}
}
};
window.addWorkflowCustomField = function () {
if (!selectedElement) return;
const cfg = Object.assign({}, selectedElement.data('config') || {});
let i = 1;
while (Object.prototype.hasOwnProperty.call(cfg, `field_${i}`)) i += 1;
cfg[`field_${i}`] = '';
selectedElement.data('config', cfg);
renderCustomFields(cfg);
};
window.updateWorkflowCustomFields = function () {
if (!selectedElement) return;
mergeVisibleConfig();
};
window.updateWorkflowTypedConfig = function () {
if (!selectedElement) return;
mergeVisibleConfig();
};
window.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));
entries.splice(index, 1);
const next = {};
entries.forEach(([key, value]) => {
if (key) next[key] = value;
});
selectedElement.data('config', Object.assign({}, next, readTypedConfig(selectedElement)));
renderCustomFields(next);
};
window.loadWorkflowOptionsForRoleModal = async function (selectedId) {
try {
await loadWorkflows(true);
} catch (_) {
workflows = [];
}
const select = document.getElementById('role-workflow-id');
if (!select) return;
const current = selectedId !== undefined ? selectedId : select.value;
select.innerHTML = '<option value="">' + esc(_t('roleModal.noWorkflowBind')) + '</option>' + workflows.map(wf => (
`<option value="${esc(wf.id)}">${esc(wf.name || wf.id)}${wf.enabled ? '' : esc(_t('roleModal.workflowDisabledSuffix'))}</option>`
)).join('');
select.value = current || '';
};
function refreshCanvasLabels() {
if (!cy) return;
cy.nodes().forEach(function (node) {
const type = node.data('type') || 'tool';
const label = node.data('label') || '';
const known = KNOWN_NODE_LABELS[type] || [];
if (known.indexOf(label) !== -1) {
node.data('label', wfNodeLabel(type));
}
});
cy.edges().forEach(function (edge) {
const label = edge.data('label') || '';
if (KNOWN_EDGE_LABELS.yes.indexOf(label) !== -1) {
edge.data('label', _t('workflows.edges.yes'));
} else if (KNOWN_EDGE_LABELS.no.indexOf(label) !== -1) {
edge.data('label', _t('workflows.edges.no'));
}
});
}
function refreshWorkflowsI18n() {
const page = document.getElementById('page-workflows');
if (page && typeof window.applyTranslations === 'function') {
window.applyTranslations(page);
}
const connectBtn = document.getElementById('workflow-connect-btn');
if (connectBtn) {
connectBtn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect');
}
refreshCanvasLabels();
updateWorkflowCanvasTitle();
renderWorkflowList();
if (selectedElement && selectedElement.length) {
selectWorkflowElement(selectedElement);
} else {
selectWorkflowElement(null);
}
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
loadWorkflowOptionsForRoleModal();
}
}
document.addEventListener('languagechange', function () {
refreshWorkflowsI18n();
});
})();