@aion0/forge 0.2.2 → 0.2.4

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.
@@ -3,10 +3,19 @@
3
3
  * forge-server — Start the Forge web platform.
4
4
  *
5
5
  * Usage:
6
- * forge-server Start in foreground (production mode)
7
- * forge-server --dev Start in foreground (development mode)
8
- * forge-server --background Start in background (production mode), logs to ~/.forge/forge.log
9
- * forge-server --stop Stop background server
6
+ * forge-server Start in foreground (production)
7
+ * forge-server --dev Start in foreground (development)
8
+ * forge-server --background Start in background
9
+ * forge-server --stop Stop background server
10
+ * forge-server --restart Stop + start (safe for remote)
11
+ * forge-server --rebuild Force rebuild
12
+ * forge-server --port 4000 Custom web port (default: 3000)
13
+ * forge-server --terminal-port 4001 Custom terminal port (default: 3001)
14
+ * forge-server --dir ~/.forge-test Custom data directory (default: ~/.forge)
15
+ *
16
+ * Examples:
17
+ * forge-server --background --port 4000 --terminal-port 4001 --dir ~/.forge-staging
18
+ * forge-server --restart
10
19
  */
11
20
 
12
21
  import { execSync, spawn } from 'node:child_process';
@@ -17,19 +26,32 @@ import { homedir } from 'node:os';
17
26
 
18
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
28
  const ROOT = join(__dirname, '..');
20
- const DATA_DIR = join(homedir(), '.forge');
21
- const PID_FILE = join(DATA_DIR, 'forge.pid');
22
- const LOG_FILE = join(DATA_DIR, 'forge.log');
29
+
30
+ // ── Parse arguments ──
31
+
32
+ function getArg(name) {
33
+ const idx = process.argv.indexOf(name);
34
+ if (idx === -1 || idx + 1 >= process.argv.length) return null;
35
+ return process.argv[idx + 1];
36
+ }
23
37
 
24
38
  const isDev = process.argv.includes('--dev');
25
39
  const isBackground = process.argv.includes('--background');
26
40
  const isStop = process.argv.includes('--stop');
41
+ const isRestart = process.argv.includes('--restart');
27
42
  const isRebuild = process.argv.includes('--rebuild');
28
43
 
44
+ const webPort = parseInt(getArg('--port')) || 3000;
45
+ const terminalPort = parseInt(getArg('--terminal-port')) || 3001;
46
+ const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge');
47
+
48
+ const PID_FILE = join(DATA_DIR, 'forge.pid');
49
+ const LOG_FILE = join(DATA_DIR, 'forge.log');
50
+
29
51
  process.chdir(ROOT);
30
52
  mkdirSync(DATA_DIR, { recursive: true });
31
53
 
32
- // ── Load ~/.forge/.env.local ──
54
+ // ── Load <data-dir>/.env.local ──
33
55
  const envFile = join(DATA_DIR, '.env.local');
34
56
  if (existsSync(envFile)) {
35
57
  for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
@@ -43,30 +65,74 @@ if (existsSync(envFile)) {
43
65
  }
44
66
  }
45
67
 
46
- // ── Stop ──
47
- if (isStop) {
68
+ // Set env vars for Next.js and terminal server
69
+ process.env.PORT = String(webPort);
70
+ process.env.TERMINAL_PORT = String(terminalPort);
71
+ process.env.FORGE_DATA_DIR = DATA_DIR;
72
+
73
+ // ── Helper: stop running instance ──
74
+ function stopServer() {
48
75
  try {
49
76
  const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim());
50
77
  process.kill(pid, 'SIGTERM');
51
78
  unlinkSync(PID_FILE);
52
79
  console.log(`[forge] Stopped (pid ${pid})`);
80
+ return true;
53
81
  } catch {
54
82
  console.log('[forge] No running server found');
83
+ return false;
55
84
  }
85
+ }
86
+
87
+ // ── Helper: start background server ──
88
+ function startBackground() {
89
+ if (!existsSync(join(ROOT, '.next'))) {
90
+ console.log('[forge] Building...');
91
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
92
+ }
93
+
94
+ const logFd = openSync(LOG_FILE, 'a');
95
+ const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
96
+ cwd: ROOT,
97
+ stdio: ['ignore', logFd, logFd],
98
+ env: { ...process.env },
99
+ detached: true,
100
+ });
101
+
102
+ writeFileSync(PID_FILE, String(child.pid));
103
+ child.unref();
104
+ console.log(`[forge] Started in background (pid ${child.pid})`);
105
+ console.log(`[forge] Web: http://localhost:${webPort}`);
106
+ console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
107
+ console.log(`[forge] Data: ${DATA_DIR}`);
108
+ console.log(`[forge] Log: ${LOG_FILE}`);
109
+ console.log(`[forge] Stop: forge-server --stop${DATA_DIR !== join(homedir(), '.forge') ? ` --dir ${DATA_DIR}` : ''}`);
110
+ }
111
+
112
+ // ── Stop ──
113
+ if (isStop) {
114
+ stopServer();
115
+ process.exit(0);
116
+ }
117
+
118
+ // ── Restart ──
119
+ if (isRestart) {
120
+ stopServer();
121
+ // Brief delay to let port release
122
+ await new Promise(r => setTimeout(r, 1500));
123
+ startBackground();
56
124
  process.exit(0);
57
125
  }
58
126
 
59
127
  // ── Rebuild ──
60
128
  if (isRebuild || existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
61
- // Always rebuild after npm install (new version)
62
- const buildIdFile = join(ROOT, '.next', 'BUILD_ID');
63
129
  const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
64
130
  const versionFile = join(ROOT, '.next', '.forge-version');
65
131
  const lastBuiltVersion = existsSync(versionFile) ? readFileSync(versionFile, 'utf-8').trim() : '';
66
132
  if (isRebuild || lastBuiltVersion !== pkgVersion) {
67
133
  console.log(`[forge] Rebuilding (v${pkgVersion})...`);
68
134
  execSync('rm -rf .next', { cwd: ROOT });
69
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
135
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
70
136
  writeFileSync(versionFile, pkgVersion);
71
137
  if (isRebuild) {
72
138
  console.log('[forge] Rebuild complete');
@@ -77,32 +143,14 @@ if (isRebuild || existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
77
143
 
78
144
  // ── Background ──
79
145
  if (isBackground) {
80
- // Build if needed
81
- if (!existsSync(join(ROOT, '.next'))) {
82
- console.log('[forge] Building...');
83
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
84
- }
85
-
86
- const logFd = openSync(LOG_FILE, 'a');
87
- const child = spawn('npx', ['next', 'start'], {
88
- cwd: ROOT,
89
- stdio: ['ignore', logFd, logFd],
90
- env: { ...process.env },
91
- detached: true,
92
- });
93
-
94
- writeFileSync(PID_FILE, String(child.pid));
95
- child.unref();
96
- console.log(`[forge] Started in background (pid ${child.pid})`);
97
- console.log(`[forge] Log: ${LOG_FILE}`);
98
- console.log(`[forge] Stop: forge-server --stop`);
146
+ startBackground();
99
147
  process.exit(0);
100
148
  }
101
149
 
102
150
  // ── Foreground ──
103
151
  if (isDev) {
104
- console.log('[forge] Starting in development mode...');
105
- const child = spawn('npx', ['next', 'dev', '--turbopack'], {
152
+ console.log(`[forge] Starting dev mode (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
153
+ const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
106
154
  cwd: ROOT,
107
155
  stdio: 'inherit',
108
156
  env: { ...process.env },
@@ -111,10 +159,10 @@ if (isDev) {
111
159
  } else {
112
160
  if (!existsSync(join(ROOT, '.next'))) {
113
161
  console.log('[forge] Building...');
114
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
162
+ execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
115
163
  }
116
- console.log('[forge] Starting server...');
117
- const child = spawn('npx', ['next', 'start'], {
164
+ console.log(`[forge] Starting server (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
165
+ const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
118
166
  cwd: ROOT,
119
167
  stdio: 'inherit',
120
168
  env: { ...process.env },
@@ -16,6 +16,7 @@ const DocsViewer = lazy(() => import('./DocsViewer'));
16
16
  const CodeViewer = lazy(() => import('./CodeViewer'));
17
17
  const ProjectManager = lazy(() => import('./ProjectManager'));
18
18
  const PreviewPanel = lazy(() => import('./PreviewPanel'));
19
+ const PipelineView = lazy(() => import('./PipelineView'));
19
20
 
20
21
  interface UsageSummary {
21
22
  provider: string;
@@ -38,7 +39,7 @@ interface ProjectInfo {
38
39
  }
39
40
 
40
41
  export default function Dashboard({ user }: { user: any }) {
41
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview'>('terminal');
42
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines'>('terminal');
42
43
  const [tasks, setTasks] = useState<Task[]>([]);
43
44
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
44
45
  const [showNewTask, setShowNewTask] = useState(false);
@@ -90,7 +91,7 @@ export default function Dashboard({ user }: { user: any }) {
90
91
  return (
91
92
  <div className="h-screen flex flex-col">
92
93
  {/* Top bar */}
93
- <header className="h-10 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0">
94
+ <header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
94
95
  <div className="flex items-center gap-4">
95
96
  <span className="text-sm font-bold text-[var(--accent)]">Forge</span>
96
97
 
@@ -136,6 +137,16 @@ export default function Dashboard({ user }: { user: any }) {
136
137
  >
137
138
  Tasks
138
139
  </button>
140
+ <button
141
+ onClick={() => setViewMode('pipelines')}
142
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
143
+ viewMode === 'pipelines'
144
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
145
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
146
+ }`}
147
+ >
148
+ Pipelines
149
+ </button>
139
150
  <button
140
151
  onClick={() => setViewMode('sessions')}
141
152
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
@@ -310,6 +321,13 @@ export default function Dashboard({ user }: { user: any }) {
310
321
  </Suspense>
311
322
  )}
312
323
 
324
+ {/* Pipelines */}
325
+ {viewMode === 'pipelines' && (
326
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
327
+ <PipelineView />
328
+ </Suspense>
329
+ )}
330
+
313
331
  {/* Preview */}
314
332
  {viewMode === 'preview' && (
315
333
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -97,6 +97,15 @@ export default function DocsViewer() {
97
97
 
98
98
  useEffect(() => { fetchTree(activeRoot); }, [activeRoot, fetchTree]);
99
99
 
100
+ // Re-fetch when tab becomes visible (settings may have changed)
101
+ useEffect(() => {
102
+ const handleVisibility = () => {
103
+ if (!document.hidden) fetchTree(activeRoot);
104
+ };
105
+ document.addEventListener('visibilitychange', handleVisibility);
106
+ return () => document.removeEventListener('visibilitychange', handleVisibility);
107
+ }, [activeRoot, fetchTree]);
108
+
100
109
  const [fileWarning, setFileWarning] = useState<string | null>(null);
101
110
 
102
111
  // Fetch file content
@@ -167,7 +176,7 @@ export default function DocsViewer() {
167
176
  {sidebarOpen && (
168
177
  <aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
169
178
  {/* Root selector */}
170
- {roots.length > 1 && (
179
+ {roots.length > 0 && (
171
180
  <div className="p-2 border-b border-[var(--border)]">
172
181
  <select
173
182
  value={activeRoot}
@@ -155,7 +155,7 @@ export default function NewTaskModal({
155
155
  className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
156
156
  >
157
157
  {projects.map(p => (
158
- <option key={p.name} value={p.name}>
158
+ <option key={`${p.name}-${p.path}`} value={p.name}>
159
159
  {p.name} {p.language ? `(${p.language})` : ''}
160
160
  </option>
161
161
  ))}
@@ -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
+ }