@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.
- package/CLAUDE.md +9 -5
- package/README.md +166 -175
- package/app/api/pipelines/[id]/route.ts +28 -0
- package/app/api/pipelines/route.ts +52 -0
- package/bin/forge-server.mjs +85 -37
- package/components/Dashboard.tsx +20 -2
- package/components/DocsViewer.tsx +10 -1
- package/components/NewTaskModal.tsx +1 -1
- package/components/PipelineEditor.tsx +399 -0
- package/components/PipelineView.tsx +435 -0
- package/dev-test.sh +5 -0
- package/lib/init.ts +5 -5
- package/lib/pipeline.ts +514 -0
- package/lib/settings.ts +2 -1
- package/lib/telegram-bot.ts +12 -17
- package/package.json +2 -1
- package/publish.sh +45 -0
package/bin/forge-server.mjs
CHANGED
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
* forge-server — Start the Forge web platform.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* forge-server
|
|
7
|
-
* forge-server --dev
|
|
8
|
-
* forge-server --background
|
|
9
|
-
* forge-server --stop
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
//
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 },
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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-
|
|
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 >
|
|
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
|
+
}
|