@aion0/forge 0.2.1 → 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.
@@ -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
+ }