Files
openfang/crates/openfang-api/static/js/pages/workflow-builder.js
iven 92e5def702
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
初始化提交
2026-03-01 16:24:24 +08:00

436 lines
16 KiB
JavaScript

// OpenFang Visual Workflow Builder — Drag-and-drop workflow designer
'use strict';
function workflowBuilder() {
return {
// -- Canvas state --
nodes: [],
connections: [],
selectedNode: null,
selectedConnection: null,
dragging: null,
dragOffset: { x: 0, y: 0 },
connecting: null, // { fromId, fromPort }
connectPreview: null, // { x, y } mouse position during connect drag
canvasOffset: { x: 0, y: 0 },
canvasDragging: false,
canvasDragStart: { x: 0, y: 0 },
zoom: 1,
nextId: 1,
workflowName: '',
workflowDescription: '',
showSaveModal: false,
showNodeEditor: false,
showTomlPreview: false,
tomlOutput: '',
agents: [],
_canvasEl: null,
// Node types with their configs
nodeTypes: [
{ type: 'agent', label: 'Agent Step', color: '#6366f1', icon: 'A', ports: { in: 1, out: 1 } },
{ type: 'parallel', label: 'Parallel Fan-out', color: '#f59e0b', icon: 'P', ports: { in: 1, out: 3 } },
{ type: 'condition', label: 'Condition', color: '#10b981', icon: '?', ports: { in: 1, out: 2 } },
{ type: 'loop', label: 'Loop', color: '#ef4444', icon: 'L', ports: { in: 1, out: 1 } },
{ type: 'collect', label: 'Collect', color: '#8b5cf6', icon: 'C', ports: { in: 3, out: 1 } },
{ type: 'start', label: 'Start', color: '#22c55e', icon: 'S', ports: { in: 0, out: 1 } },
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
],
async init() {
var self = this;
// Load agents for the agent step dropdown
try {
var list = await OpenFangAPI.get('/api/agents');
self.agents = Array.isArray(list) ? list : [];
} catch(_) {
self.agents = [];
}
// Add default start node
self.addNode('start', 60, 200);
},
// ── Node Management ──────────────────────────────────
addNode: function(type, x, y) {
var def = null;
for (var i = 0; i < this.nodeTypes.length; i++) {
if (this.nodeTypes[i].type === type) { def = this.nodeTypes[i]; break; }
}
if (!def) return;
var node = {
id: 'node-' + this.nextId++,
type: type,
label: def.label,
color: def.color,
icon: def.icon,
x: x || 200,
y: y || 200,
width: 180,
height: 70,
ports: { in: def.ports.in, out: def.ports.out },
config: {}
};
if (type === 'agent') {
node.config = { agent_name: '', prompt: '{{input}}', model: '' };
} else if (type === 'condition') {
node.config = { expression: '', true_label: 'Yes', false_label: 'No' };
} else if (type === 'loop') {
node.config = { max_iterations: 5, until: '' };
} else if (type === 'parallel') {
node.config = { fan_count: 3 };
} else if (type === 'collect') {
node.config = { strategy: 'all' };
}
this.nodes.push(node);
return node;
},
deleteNode: function(nodeId) {
this.connections = this.connections.filter(function(c) {
return c.from !== nodeId && c.to !== nodeId;
});
this.nodes = this.nodes.filter(function(n) { return n.id !== nodeId; });
if (this.selectedNode && this.selectedNode.id === nodeId) {
this.selectedNode = null;
this.showNodeEditor = false;
}
},
duplicateNode: function(node) {
var newNode = this.addNode(node.type, node.x + 30, node.y + 30);
if (newNode) {
newNode.config = JSON.parse(JSON.stringify(node.config));
newNode.label = node.label + ' copy';
}
},
getNode: function(id) {
for (var i = 0; i < this.nodes.length; i++) {
if (this.nodes[i].id === id) return this.nodes[i];
}
return null;
},
// ── Port Positions ───────────────────────────────────
getInputPortPos: function(node, portIndex) {
var total = node.ports.in;
var spacing = node.width / (total + 1);
return { x: node.x + spacing * (portIndex + 1), y: node.y };
},
getOutputPortPos: function(node, portIndex) {
var total = node.ports.out;
var spacing = node.width / (total + 1);
return { x: node.x + spacing * (portIndex + 1), y: node.y + node.height };
},
// ── Connection Management ────────────────────────────
startConnect: function(nodeId, portIndex, e) {
e.stopPropagation();
this.connecting = { fromId: nodeId, fromPort: portIndex };
var node = this.getNode(nodeId);
var pos = this.getOutputPortPos(node, portIndex);
this.connectPreview = { x: pos.x, y: pos.y };
},
endConnect: function(nodeId, portIndex, e) {
e.stopPropagation();
if (!this.connecting) return;
if (this.connecting.fromId === nodeId) {
this.connecting = null;
this.connectPreview = null;
return;
}
// Check for duplicate
var fromId = this.connecting.fromId;
var fromPort = this.connecting.fromPort;
var dup = false;
for (var i = 0; i < this.connections.length; i++) {
var c = this.connections[i];
if (c.from === fromId && c.fromPort === fromPort && c.to === nodeId && c.toPort === portIndex) {
dup = true;
break;
}
}
if (!dup) {
this.connections.push({
id: 'conn-' + this.nextId++,
from: fromId,
fromPort: fromPort,
to: nodeId,
toPort: portIndex
});
}
this.connecting = null;
this.connectPreview = null;
},
deleteConnection: function(connId) {
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
this.selectedConnection = null;
},
// ── Drag Handling ────────────────────────────────────
onNodeMouseDown: function(node, e) {
e.stopPropagation();
this.selectedNode = node;
this.selectedConnection = null;
this.dragging = node.id;
var rect = this._getCanvasRect();
this.dragOffset = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - node.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - node.y
};
},
onCanvasMouseDown: function(e) {
if (e.target.closest('.wf-node') || e.target.closest('.wf-port')) return;
this.selectedNode = null;
this.selectedConnection = null;
this.showNodeEditor = false;
// Start canvas pan
this.canvasDragging = true;
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
},
onCanvasMouseMove: function(e) {
var rect = this._getCanvasRect();
if (this.dragging) {
var node = this.getNode(this.dragging);
if (node) {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
}
} else if (this.connecting) {
this.connectPreview = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
};
} else if (this.canvasDragging) {
this.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
y: (e.clientY - this.canvasDragStart.y) / this.zoom
};
}
},
onCanvasMouseUp: function() {
this.dragging = null;
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
},
onCanvasWheel: function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? -0.05 : 0.05;
this.zoom = Math.max(0.3, Math.min(2, this.zoom + delta));
},
_getCanvasRect: function() {
if (!this._canvasEl) {
this._canvasEl = document.getElementById('wf-canvas');
}
return this._canvasEl ? this._canvasEl.getBoundingClientRect() : { left: 0, top: 0 };
},
// ── Connection Path ──────────────────────────────────
getConnectionPath: function(conn) {
var fromNode = this.getNode(conn.from);
var toNode = this.getNode(conn.to);
if (!fromNode || !toNode) return '';
var from = this.getOutputPortPos(fromNode, conn.fromPort);
var to = this.getInputPortPos(toNode, conn.toPort);
var dy = Math.abs(to.y - from.y);
var cp = Math.max(40, dy * 0.5);
return 'M ' + from.x + ' ' + from.y + ' C ' + from.x + ' ' + (from.y + cp) + ' ' + to.x + ' ' + (to.y - cp) + ' ' + to.x + ' ' + to.y;
},
getPreviewPath: function() {
if (!this.connecting || !this.connectPreview) return '';
var fromNode = this.getNode(this.connecting.fromId);
if (!fromNode) return '';
var from = this.getOutputPortPos(fromNode, this.connecting.fromPort);
var to = this.connectPreview;
var dy = Math.abs(to.y - from.y);
var cp = Math.max(40, dy * 0.5);
return 'M ' + from.x + ' ' + from.y + ' C ' + from.x + ' ' + (from.y + cp) + ' ' + to.x + ' ' + (to.y - cp) + ' ' + to.x + ' ' + to.y;
},
// ── Node editor ──────────────────────────────────────
editNode: function(node) {
this.selectedNode = node;
this.showNodeEditor = true;
},
// ── TOML Generation ──────────────────────────────────
generateToml: function() {
var self = this;
var lines = [];
lines.push('[workflow]');
lines.push('name = "' + (this.workflowName || 'untitled') + '"');
lines.push('description = "' + (this.workflowDescription || '') + '"');
lines.push('');
// Topological sort the nodes (skip start/end for step generation)
var stepNodes = this.nodes.filter(function(n) {
return n.type !== 'start' && n.type !== 'end';
});
for (var i = 0; i < stepNodes.length; i++) {
var node = stepNodes[i];
lines.push('[[workflow.steps]]');
lines.push('name = "' + (node.label || 'step-' + (i + 1)) + '"');
if (node.type === 'agent') {
lines.push('type = "agent"');
if (node.config.agent_name) lines.push('agent_name = "' + node.config.agent_name + '"');
lines.push('prompt = "' + (node.config.prompt || '{{input}}') + '"');
if (node.config.model) lines.push('model = "' + node.config.model + '"');
} else if (node.type === 'parallel') {
lines.push('type = "fan_out"');
lines.push('fan_count = ' + (node.config.fan_count || 3));
} else if (node.type === 'condition') {
lines.push('type = "conditional"');
lines.push('expression = "' + (node.config.expression || '') + '"');
} else if (node.type === 'loop') {
lines.push('type = "loop"');
lines.push('max_iterations = ' + (node.config.max_iterations || 5));
if (node.config.until) lines.push('until = "' + node.config.until + '"');
} else if (node.type === 'collect') {
lines.push('type = "collect"');
lines.push('strategy = "' + (node.config.strategy || 'all') + '"');
}
// Find what this node connects to
var outConns = self.connections.filter(function(c) { return c.from === node.id; });
if (outConns.length === 1) {
var target = self.getNode(outConns[0].to);
if (target && target.type !== 'end') {
lines.push('next = "' + target.label + '"');
}
} else if (outConns.length > 1 && node.type === 'condition') {
for (var j = 0; j < outConns.length; j++) {
var t2 = self.getNode(outConns[j].to);
if (t2 && t2.type !== 'end') {
var branchLabel = j === 0 ? 'true' : 'false';
lines.push('next_' + branchLabel + ' = "' + t2.label + '"');
}
}
} else if (outConns.length > 1 && node.type === 'parallel') {
var targets = [];
for (var k = 0; k < outConns.length; k++) {
var t3 = self.getNode(outConns[k].to);
if (t3 && t3.type !== 'end') targets.push('"' + t3.label + '"');
}
if (targets.length) lines.push('fan_targets = [' + targets.join(', ') + ']');
}
lines.push('');
}
this.tomlOutput = lines.join('\n');
this.showTomlPreview = true;
},
// ── Save Workflow ────────────────────────────────────
async saveWorkflow() {
var steps = [];
var stepNodes = this.nodes.filter(function(n) {
return n.type !== 'start' && n.type !== 'end';
});
for (var i = 0; i < stepNodes.length; i++) {
var node = stepNodes[i];
var step = {
name: node.label || 'step-' + (i + 1),
mode: node.type === 'parallel' ? 'fan_out' : node.type === 'loop' ? 'loop' : 'sequential'
};
if (node.type === 'agent') {
step.agent_name = node.config.agent_name || '';
step.prompt = node.config.prompt || '{{input}}';
}
steps.push(step);
}
try {
await OpenFangAPI.post('/api/workflows', {
name: this.workflowName || 'untitled',
description: this.workflowDescription || '',
steps: steps
});
OpenFangToast.success('Workflow saved!');
this.showSaveModal = false;
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
},
// ── Palette drop ─────────────────────────────────────
onPaletteDragStart: function(type, e) {
e.dataTransfer.setData('text/plain', type);
e.dataTransfer.effectAllowed = 'copy';
},
onCanvasDrop: function(e) {
e.preventDefault();
var type = e.dataTransfer.getData('text/plain');
if (!type) return;
var rect = this._getCanvasRect();
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
this.addNode(type, x - 90, y - 35);
},
onCanvasDragOver: function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
},
// ── Auto Layout ──────────────────────────────────────
autoLayout: function() {
// Simple top-to-bottom layout
var y = 40;
var x = 200;
for (var i = 0; i < this.nodes.length; i++) {
this.nodes[i].x = x;
this.nodes[i].y = y;
y += 120;
}
},
// ── Clear ────────────────────────────────────────────
clearCanvas: function() {
this.nodes = [];
this.connections = [];
this.selectedNode = null;
this.nextId = 1;
this.addNode('start', 60, 200);
},
// ── Zoom controls ────────────────────────────────────
zoomIn: function() {
this.zoom = Math.min(2, this.zoom + 0.1);
},
zoomOut: function() {
this.zoom = Math.max(0.3, this.zoom - 0.1);
},
zoomReset: function() {
this.zoom = 1;
this.canvasOffset = { x: 0, y: 0 };
}
};
}