@aion0/forge 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -175
- package/app/api/pipelines/[id]/route.ts +28 -0
- package/app/api/pipelines/route.ts +52 -0
- package/components/Dashboard.tsx +19 -1
- package/components/DocsViewer.tsx +10 -1
- package/components/PipelineEditor.tsx +399 -0
- package/components/PipelineView.tsx +435 -0
- package/lib/pipeline.ts +514 -0
- package/package.json +2 -1
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Background,
|
|
7
|
+
Controls,
|
|
8
|
+
addEdge,
|
|
9
|
+
useNodesState,
|
|
10
|
+
useEdgesState,
|
|
11
|
+
Handle,
|
|
12
|
+
Position,
|
|
13
|
+
type Node,
|
|
14
|
+
type Edge,
|
|
15
|
+
type Connection,
|
|
16
|
+
type NodeProps,
|
|
17
|
+
MarkerType,
|
|
18
|
+
} from '@xyflow/react';
|
|
19
|
+
import '@xyflow/react/dist/style.css';
|
|
20
|
+
|
|
21
|
+
// ─── Custom Node ──────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
interface NodeData {
|
|
24
|
+
label: string;
|
|
25
|
+
project: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
outputs: { name: string; extract: string }[];
|
|
28
|
+
onEdit: (id: string) => void;
|
|
29
|
+
onDelete: (id: string) => void;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function PipelineNode({ id, data }: NodeProps<Node<NodeData>>) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="bg-[#1e1e3a] border border-[#3a3a5a] rounded-lg shadow-lg min-w-[180px]">
|
|
36
|
+
<Handle type="target" position={Position.Top} className="!bg-[var(--accent)] !w-3 !h-3" />
|
|
37
|
+
|
|
38
|
+
<div className="px-3 py-2 border-b border-[#3a3a5a] flex items-center gap-2">
|
|
39
|
+
<span className="text-xs font-semibold text-white">{data.label}</span>
|
|
40
|
+
<div className="ml-auto flex gap-1">
|
|
41
|
+
<button onClick={() => data.onEdit(id)} className="text-[9px] text-[var(--accent)] hover:text-white">edit</button>
|
|
42
|
+
<button onClick={() => data.onDelete(id)} className="text-[9px] text-red-400 hover:text-red-300">x</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className="px-3 py-1.5 space-y-0.5">
|
|
47
|
+
{data.project && <div className="text-[9px] text-[var(--accent)]">{data.project}</div>}
|
|
48
|
+
<div className="text-[9px] text-gray-400 truncate max-w-[200px]">{data.prompt.slice(0, 60) || 'No prompt'}{data.prompt.length > 60 ? '...' : ''}</div>
|
|
49
|
+
{data.outputs.length > 0 && (
|
|
50
|
+
<div className="text-[8px] text-green-400">
|
|
51
|
+
outputs: {data.outputs.map(o => o.name).join(', ')}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<Handle type="source" position={Position.Bottom} className="!bg-[var(--accent)] !w-3 !h-3" />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const nodeTypes = { pipeline: PipelineNode };
|
|
62
|
+
|
|
63
|
+
// ─── Node Edit Modal ──────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function NodeEditModal({ node, projects, onSave, onClose }: {
|
|
66
|
+
node: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] };
|
|
67
|
+
projects: { name: string; root: string }[];
|
|
68
|
+
onSave: (data: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] }) => void;
|
|
69
|
+
onClose: () => void;
|
|
70
|
+
}) {
|
|
71
|
+
const [id, setId] = useState(node.id);
|
|
72
|
+
const [project, setProject] = useState(node.project);
|
|
73
|
+
const [prompt, setPrompt] = useState(node.prompt);
|
|
74
|
+
const [outputs, setOutputs] = useState(node.outputs);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
78
|
+
<div className="bg-[#1e1e3a] border border-[#3a3a5a] rounded-lg shadow-xl w-[450px] max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
79
|
+
<div className="px-4 py-3 border-b border-[#3a3a5a]">
|
|
80
|
+
<h3 className="text-sm font-semibold text-white">Edit Node</h3>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="p-4 space-y-3">
|
|
83
|
+
<div>
|
|
84
|
+
<label className="text-[10px] text-gray-400 block mb-1">Node ID</label>
|
|
85
|
+
<input
|
|
86
|
+
value={id}
|
|
87
|
+
onChange={e => setId(e.target.value.replace(/\s+/g, '_'))}
|
|
88
|
+
className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white focus:outline-none focus:border-[var(--accent)] font-mono"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<div>
|
|
92
|
+
<label className="text-[10px] text-gray-400 block mb-1">Project</label>
|
|
93
|
+
<select
|
|
94
|
+
value={project}
|
|
95
|
+
onChange={e => setProject(e.target.value)}
|
|
96
|
+
className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
|
|
97
|
+
>
|
|
98
|
+
<option value="">Select project...</option>
|
|
99
|
+
{[...new Set(projects.map(p => p.root))].map(root => (
|
|
100
|
+
<optgroup key={root} label={root.split('/').pop() || root}>
|
|
101
|
+
{projects.filter(p => p.root === root).map((p, i) => (
|
|
102
|
+
<option key={`${p.name}-${i}`} value={p.name}>{p.name}</option>
|
|
103
|
+
))}
|
|
104
|
+
</optgroup>
|
|
105
|
+
))}
|
|
106
|
+
</select>
|
|
107
|
+
</div>
|
|
108
|
+
<div>
|
|
109
|
+
<label className="text-[10px] text-gray-400 block mb-1">Prompt</label>
|
|
110
|
+
<textarea
|
|
111
|
+
value={prompt}
|
|
112
|
+
onChange={e => setPrompt(e.target.value)}
|
|
113
|
+
rows={6}
|
|
114
|
+
className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white focus:outline-none focus:border-[var(--accent)] font-mono resize-y"
|
|
115
|
+
placeholder="Use {{nodes.xxx.outputs.yyy}} to reference upstream outputs"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
<div>
|
|
119
|
+
<label className="text-[10px] text-gray-400 block mb-1">Outputs</label>
|
|
120
|
+
{outputs.map((o, i) => (
|
|
121
|
+
<div key={i} className="flex gap-2 mb-1">
|
|
122
|
+
<input
|
|
123
|
+
value={o.name}
|
|
124
|
+
onChange={e => { const n = [...outputs]; n[i] = { ...n[i], name: e.target.value }; setOutputs(n); }}
|
|
125
|
+
placeholder="output name"
|
|
126
|
+
className="flex-1 text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1 text-white font-mono"
|
|
127
|
+
/>
|
|
128
|
+
<select
|
|
129
|
+
value={o.extract}
|
|
130
|
+
onChange={e => { const n = [...outputs]; n[i] = { ...n[i], extract: e.target.value }; setOutputs(n); }}
|
|
131
|
+
className="text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1 text-white"
|
|
132
|
+
>
|
|
133
|
+
<option value="result">result</option>
|
|
134
|
+
<option value="git_diff">git_diff</option>
|
|
135
|
+
</select>
|
|
136
|
+
<button onClick={() => setOutputs(outputs.filter((_, j) => j !== i))} className="text-red-400 text-xs">x</button>
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => setOutputs([...outputs, { name: '', extract: 'result' }])}
|
|
141
|
+
className="text-[9px] text-[var(--accent)] hover:text-white"
|
|
142
|
+
>
|
|
143
|
+
+ Add output
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="px-4 py-3 border-t border-[#3a3a5a] flex gap-2 justify-end">
|
|
148
|
+
<button onClick={onClose} className="text-xs px-3 py-1 text-gray-400 hover:text-white">Cancel</button>
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => onSave({ id, project, prompt, outputs: outputs.filter(o => o.name) })}
|
|
151
|
+
className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
152
|
+
>
|
|
153
|
+
Save
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Main Editor ──────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
164
|
+
onSave: (yaml: string) => void;
|
|
165
|
+
onClose: () => void;
|
|
166
|
+
initialYaml?: string;
|
|
167
|
+
}) {
|
|
168
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
|
|
169
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
170
|
+
const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
|
|
171
|
+
const [workflowName, setWorkflowName] = useState('my-workflow');
|
|
172
|
+
const [workflowDesc, setWorkflowDesc] = useState('');
|
|
173
|
+
const [varsProject, setVarsProject] = useState('');
|
|
174
|
+
const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
|
|
175
|
+
const nextNodeId = useRef(1);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
fetch('/api/projects').then(r => r.json())
|
|
179
|
+
.then((p: { name: string; root: string }[]) => { if (Array.isArray(p)) setProjects(p); })
|
|
180
|
+
.catch(() => {});
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
// Load initial YAML if provided
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!initialYaml) return;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = require('yaml').parse(initialYaml);
|
|
188
|
+
if (parsed.name) setWorkflowName(parsed.name);
|
|
189
|
+
if (parsed.description) setWorkflowDesc(parsed.description);
|
|
190
|
+
if (parsed.vars?.project) setVarsProject(parsed.vars.project);
|
|
191
|
+
|
|
192
|
+
const nodeEntries = Object.entries(parsed.nodes || {});
|
|
193
|
+
const newNodes: Node<NodeData>[] = [];
|
|
194
|
+
const newEdges: Edge[] = [];
|
|
195
|
+
|
|
196
|
+
nodeEntries.forEach(([id, def]: [string, any], idx) => {
|
|
197
|
+
newNodes.push({
|
|
198
|
+
id,
|
|
199
|
+
type: 'pipeline',
|
|
200
|
+
position: { x: 250, y: idx * 150 + 50 },
|
|
201
|
+
data: {
|
|
202
|
+
label: id,
|
|
203
|
+
project: def.project || '',
|
|
204
|
+
prompt: def.prompt || '',
|
|
205
|
+
outputs: (def.outputs || []).map((o: any) => ({ name: o.name, extract: o.extract || 'result' })),
|
|
206
|
+
onEdit: (nid: string) => handleEditNode(nid),
|
|
207
|
+
onDelete: (nid: string) => handleDeleteNode(nid),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
for (const dep of (def.depends_on || [])) {
|
|
212
|
+
newEdges.push({
|
|
213
|
+
id: `${dep}-${id}`,
|
|
214
|
+
source: dep,
|
|
215
|
+
target: id,
|
|
216
|
+
markerEnd: { type: MarkerType.ArrowClosed },
|
|
217
|
+
style: { stroke: '#7c5bf0' },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
setNodes(newNodes);
|
|
223
|
+
setEdges(newEdges);
|
|
224
|
+
nextNodeId.current = nodeEntries.length + 1;
|
|
225
|
+
} catch {}
|
|
226
|
+
}, [initialYaml]);
|
|
227
|
+
|
|
228
|
+
const onConnect = useCallback((params: Connection) => {
|
|
229
|
+
setEdges(eds => addEdge({
|
|
230
|
+
...params,
|
|
231
|
+
markerEnd: { type: MarkerType.ArrowClosed },
|
|
232
|
+
style: { stroke: '#7c5bf0' },
|
|
233
|
+
}, eds));
|
|
234
|
+
}, [setEdges]);
|
|
235
|
+
|
|
236
|
+
const handleAddNode = useCallback(() => {
|
|
237
|
+
const id = `step_${nextNodeId.current++}`;
|
|
238
|
+
const newNode: Node<NodeData> = {
|
|
239
|
+
id,
|
|
240
|
+
type: 'pipeline',
|
|
241
|
+
position: { x: 250, y: nodes.length * 150 + 50 },
|
|
242
|
+
data: {
|
|
243
|
+
label: id,
|
|
244
|
+
project: varsProject ? '{{vars.project}}' : '',
|
|
245
|
+
prompt: '',
|
|
246
|
+
outputs: [],
|
|
247
|
+
onEdit: (nid: string) => handleEditNode(nid),
|
|
248
|
+
onDelete: (nid: string) => handleDeleteNode(nid),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
setNodes(nds => [...nds, newNode]);
|
|
252
|
+
}, [nodes.length, varsProject, setNodes]);
|
|
253
|
+
|
|
254
|
+
const handleEditNode = useCallback((id: string) => {
|
|
255
|
+
setNodes(nds => {
|
|
256
|
+
const node = nds.find(n => n.id === id);
|
|
257
|
+
if (node) {
|
|
258
|
+
setEditingNode({
|
|
259
|
+
id: node.id,
|
|
260
|
+
project: node.data.project,
|
|
261
|
+
prompt: node.data.prompt,
|
|
262
|
+
outputs: node.data.outputs,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return nds;
|
|
266
|
+
});
|
|
267
|
+
}, [setNodes]);
|
|
268
|
+
|
|
269
|
+
const handleDeleteNode = useCallback((id: string) => {
|
|
270
|
+
setNodes(nds => nds.filter(n => n.id !== id));
|
|
271
|
+
setEdges(eds => eds.filter(e => e.source !== id && e.target !== id));
|
|
272
|
+
}, [setNodes, setEdges]);
|
|
273
|
+
|
|
274
|
+
const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] }) => {
|
|
275
|
+
setNodes(nds => nds.map(n => {
|
|
276
|
+
if (n.id === editingNode?.id) {
|
|
277
|
+
return {
|
|
278
|
+
...n,
|
|
279
|
+
id: data.id,
|
|
280
|
+
data: {
|
|
281
|
+
...n.data,
|
|
282
|
+
label: data.id,
|
|
283
|
+
project: data.project,
|
|
284
|
+
prompt: data.prompt,
|
|
285
|
+
outputs: data.outputs,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return n;
|
|
290
|
+
}));
|
|
291
|
+
// Update edges if id changed
|
|
292
|
+
if (editingNode && data.id !== editingNode.id) {
|
|
293
|
+
setEdges(eds => eds.map(e => ({
|
|
294
|
+
...e,
|
|
295
|
+
id: e.id.replace(editingNode.id, data.id),
|
|
296
|
+
source: e.source === editingNode.id ? data.id : e.source,
|
|
297
|
+
target: e.target === editingNode.id ? data.id : e.target,
|
|
298
|
+
})));
|
|
299
|
+
}
|
|
300
|
+
setEditingNode(null);
|
|
301
|
+
}, [editingNode, setNodes, setEdges]);
|
|
302
|
+
|
|
303
|
+
// Generate YAML from current state
|
|
304
|
+
const generateYaml = useCallback(() => {
|
|
305
|
+
const workflow: any = {
|
|
306
|
+
name: workflowName,
|
|
307
|
+
description: workflowDesc || undefined,
|
|
308
|
+
vars: varsProject ? { project: varsProject } : undefined,
|
|
309
|
+
nodes: {} as any,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (const node of nodes) {
|
|
313
|
+
const deps = edges.filter(e => e.target === node.id).map(e => e.source);
|
|
314
|
+
const nodeDef: any = {
|
|
315
|
+
project: node.data.project,
|
|
316
|
+
prompt: node.data.prompt,
|
|
317
|
+
};
|
|
318
|
+
if (deps.length > 0) nodeDef.depends_on = deps;
|
|
319
|
+
if (node.data.outputs.length > 0) nodeDef.outputs = node.data.outputs;
|
|
320
|
+
workflow.nodes[node.id] = nodeDef;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const YAML = require('yaml');
|
|
324
|
+
return YAML.stringify(workflow);
|
|
325
|
+
}, [nodes, edges, workflowName, workflowDesc, varsProject]);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-[#0a0a1a]">
|
|
329
|
+
{/* Top bar */}
|
|
330
|
+
<div className="h-10 border-b border-[#3a3a5a] flex items-center px-4 gap-3 shrink-0">
|
|
331
|
+
<span className="text-xs font-semibold text-white">Pipeline Editor</span>
|
|
332
|
+
<input
|
|
333
|
+
value={workflowName}
|
|
334
|
+
onChange={e => setWorkflowName(e.target.value)}
|
|
335
|
+
className="text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-0.5 text-white font-mono w-40"
|
|
336
|
+
placeholder="Workflow name"
|
|
337
|
+
/>
|
|
338
|
+
<input
|
|
339
|
+
value={workflowDesc}
|
|
340
|
+
onChange={e => setWorkflowDesc(e.target.value)}
|
|
341
|
+
className="text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-0.5 text-gray-400 flex-1"
|
|
342
|
+
placeholder="Description (optional)"
|
|
343
|
+
/>
|
|
344
|
+
<input
|
|
345
|
+
value={varsProject}
|
|
346
|
+
onChange={e => setVarsProject(e.target.value)}
|
|
347
|
+
className="text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-0.5 text-white font-mono w-32"
|
|
348
|
+
placeholder="Default project"
|
|
349
|
+
/>
|
|
350
|
+
<button
|
|
351
|
+
onClick={handleAddNode}
|
|
352
|
+
className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
353
|
+
>
|
|
354
|
+
+ Node
|
|
355
|
+
</button>
|
|
356
|
+
<button
|
|
357
|
+
onClick={() => onSave(generateYaml())}
|
|
358
|
+
className="text-xs px-3 py-1 bg-green-600 text-white rounded hover:opacity-90"
|
|
359
|
+
>
|
|
360
|
+
Save
|
|
361
|
+
</button>
|
|
362
|
+
<button
|
|
363
|
+
onClick={onClose}
|
|
364
|
+
className="text-xs px-3 py-1 text-gray-400 hover:text-white"
|
|
365
|
+
>
|
|
366
|
+
Cancel
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Flow canvas */}
|
|
371
|
+
<div className="flex-1">
|
|
372
|
+
<ReactFlow
|
|
373
|
+
nodes={nodes}
|
|
374
|
+
edges={edges}
|
|
375
|
+
onNodesChange={onNodesChange}
|
|
376
|
+
onEdgesChange={onEdgesChange}
|
|
377
|
+
onConnect={onConnect}
|
|
378
|
+
nodeTypes={nodeTypes}
|
|
379
|
+
fitView
|
|
380
|
+
deleteKeyCode="Delete"
|
|
381
|
+
style={{ background: '#0a0a1a' }}
|
|
382
|
+
>
|
|
383
|
+
<Background color="#1a1a3a" gap={20} />
|
|
384
|
+
<Controls />
|
|
385
|
+
</ReactFlow>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{/* Edit modal */}
|
|
389
|
+
{editingNode && (
|
|
390
|
+
<NodeEditModal
|
|
391
|
+
node={editingNode}
|
|
392
|
+
projects={projects}
|
|
393
|
+
onSave={handleSaveNode}
|
|
394
|
+
onClose={() => setEditingNode(null)}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|