mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-04 03:27:54 +02:00
1002 lines
43 KiB
JavaScript
1002 lines
43 KiB
JavaScript
(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'];
|
||
|
||
function esc(text) {
|
||
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
|
||
return String(text == null ? '' : text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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: '' };
|
||
case 'agent':
|
||
return { agent_mode: 'eino_single', input_binding: { from: 'previous', field: 'output' }, instruction: '', output_key: 'agent_result' };
|
||
case 'condition':
|
||
return { expression: '{{previous.output}} != ""' };
|
||
case 'hitl':
|
||
return { prompt: _t('workflows.defaultHitlPrompt'), prompt_binding: { from: 'previous', field: 'output' }, reviewer: 'human' };
|
||
case 'output':
|
||
return { output_key: 'result', source_binding: { from: 'previous', field: 'output' } };
|
||
case 'end':
|
||
return { result_binding: { from: 'outputs', field: 'result' } };
|
||
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';
|
||
}
|
||
|
||
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'
|
||
}
|
||
}
|
||
],
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
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 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 => `
|
||
<button type="button" class="workflow-list-item ${wf.id === currentWorkflowId ? 'is-active' : ''}" onclick="selectWorkflow(decodeURIComponent('${encodeURIComponent(wf.id)}'))">
|
||
<span class="workflow-list-title">${esc(wf.name || wf.id)}</span>
|
||
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1} · ${wf.enabled ? esc(_t('workflows.statusEnabled')) : esc(_t('workflows.statusDisabled'))}</span>
|
||
</button>
|
||
`).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) {
|
||
initCy();
|
||
const idEl = document.getElementById('workflow-id');
|
||
const nameEl = document.getElementById('workflow-name');
|
||
const descEl = document.getElementById('workflow-description');
|
||
const enabledEl = document.getElementById('workflow-enabled');
|
||
if (!idEl || !nameEl || !descEl || !enabledEl || !cy) return;
|
||
idEl.value = wf.id || '';
|
||
idEl.disabled = !!wf.id;
|
||
nameEl.value = wf.name || '';
|
||
descEl.value = wf.description || '';
|
||
enabledEl.checked = wf.enabled !== false;
|
||
currentWorkflowId = wf.id || '';
|
||
const graph = parseGraph(wf.graph_json || wf.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;
|
||
}
|
||
cy.elements().unselect();
|
||
selectedElement.select();
|
||
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');
|
||
}
|
||
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 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' ? '<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 = `
|
||
<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 = `
|
||
<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 = `
|
||
${typedField('workflow-condition-expression', _t('workflows.config.conditionExpression'), cfg.expression, '{{previous.output}} != ""')}
|
||
<p class="workflow-config-hint">${_t('workflows.config.conditionHint')}</p>
|
||
`;
|
||
break;
|
||
case 'hitl':
|
||
wrap.innerHTML = `
|
||
${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 = `
|
||
${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');
|
||
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()) {
|
||
return { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' };
|
||
}
|
||
const type = ele.data('type') || 'tool';
|
||
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 || ''
|
||
};
|
||
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'
|
||
};
|
||
case 'condition':
|
||
return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '' };
|
||
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'
|
||
};
|
||
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 || ''
|
||
};
|
||
case 'end':
|
||
return { result_binding: readBinding('workflow-end-result') };
|
||
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 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 || { x: 180 + cy.nodes().length * 28, y: 160 + cy.nodes().length * 28 }
|
||
});
|
||
selectWorkflowElement(node);
|
||
updateEmptyState();
|
||
}
|
||
|
||
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);
|
||
renderWorkflowList();
|
||
if (!currentWorkflowId && workflows.length) {
|
||
fillWorkflowForm(workflows[0]);
|
||
} else if (!workflows.length) {
|
||
newWorkflowDraft();
|
||
}
|
||
} 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 () {
|
||
fillWorkflowForm({
|
||
id: '',
|
||
name: '',
|
||
description: '',
|
||
enabled: true,
|
||
graph_json: defaultGraph()
|
||
});
|
||
};
|
||
|
||
window.selectWorkflow = function (id) {
|
||
const wf = workflows.find(item => item.id === id);
|
||
if (wf) fillWorkflowForm(wf);
|
||
};
|
||
|
||
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');
|
||
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 === '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 }));
|
||
}
|
||
});
|
||
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;
|
||
}
|
||
|
||
window.saveWorkflowDraft = async function () {
|
||
initCy();
|
||
const id = document.getElementById('workflow-id').value.trim();
|
||
const name = document.getElementById('workflow-name').value.trim();
|
||
const description = document.getElementById('workflow-description').value.trim();
|
||
const enabled = document.getElementById('workflow-enabled').checked;
|
||
if (!id || !name) {
|
||
showNotification(_t('workflows.idNameRequired'), 'error');
|
||
return;
|
||
}
|
||
const graph = elementsToGraph();
|
||
const errors = validateWorkflowGraph(graph);
|
||
if (errors.length) {
|
||
showNotification(errors.slice(0, 4).join(';'), '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, name, description, 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 : id;
|
||
showNotification(_t('workflows.saved'), 'success');
|
||
await refreshWorkflows();
|
||
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
|
||
await loadWorkflowOptionsForRoleModal();
|
||
}
|
||
};
|
||
|
||
window.deleteCurrentWorkflow = async function () {
|
||
const id = currentWorkflowId || document.getElementById('workflow-id').value.trim();
|
||
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();
|
||
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.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();
|
||
renderWorkflowList();
|
||
if (selectedElement && selectedElement.length) {
|
||
selectWorkflowElement(selectedElement);
|
||
} else {
|
||
selectWorkflowElement(null);
|
||
}
|
||
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
|
||
loadWorkflowOptionsForRoleModal();
|
||
}
|
||
}
|
||
|
||
document.addEventListener('languagechange', function () {
|
||
refreshWorkflowsI18n();
|
||
});
|
||
})();
|