@aion0/forge 0.4.15 → 0.5.0
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 +1 -1
- package/README.md +2 -2
- package/RELEASE_NOTES.md +170 -13
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +23 -4
- package/check-forge-status.sh +9 -0
- package/cli/mw.ts +2 -2
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +12 -4
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +13 -8
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +34 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +259 -45
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/docs/LOCAL-DEPLOY.md +15 -15
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/cloudflared.ts +1 -1
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +8 -2
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +95 -6
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +62 -12
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/telegram-standalone.ts +1 -1
- package/lib/terminal-server.ts +2 -2
- package/lib/terminal-standalone.ts +1 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +5 -2
- package/src/config/index.ts +13 -2
- package/src/core/db/database.ts +1 -0
- package/start.sh +10 -0
|
@@ -8,13 +8,13 @@ import '@xterm/xterm/css/xterm.css';
|
|
|
8
8
|
const SESSION_NAME = 'mw-forge-help';
|
|
9
9
|
|
|
10
10
|
function getWsUrl() {
|
|
11
|
-
if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '
|
|
11
|
+
if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
|
|
12
12
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
13
13
|
const wsHost = window.location.hostname;
|
|
14
14
|
if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
|
|
15
15
|
return `${wsProtocol}//${window.location.host}/terminal-ws`;
|
|
16
16
|
}
|
|
17
|
-
const webPort = parseInt(window.location.port) ||
|
|
17
|
+
const webPort = parseInt(window.location.port) || 8403;
|
|
18
18
|
return `${wsProtocol}//${wsHost}:${webPort + 1}`;
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -27,6 +27,7 @@ export default function HelpTerminal() {
|
|
|
27
27
|
|
|
28
28
|
let disposed = false;
|
|
29
29
|
let dataDir = '~/.forge/data';
|
|
30
|
+
let agentCmd = 'claude';
|
|
30
31
|
|
|
31
32
|
const cs = getComputedStyle(document.documentElement);
|
|
32
33
|
const tv = (name: string) => cs.getPropertyValue(name).trim();
|
|
@@ -75,7 +76,7 @@ export default function HelpTerminal() {
|
|
|
75
76
|
isNewSession = false;
|
|
76
77
|
setTimeout(() => {
|
|
77
78
|
if (socket.readyState === WebSocket.OPEN) {
|
|
78
|
-
socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null &&
|
|
79
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${agentCmd}\n` }));
|
|
79
80
|
}
|
|
80
81
|
}, 300);
|
|
81
82
|
}
|
|
@@ -96,11 +97,15 @@ export default function HelpTerminal() {
|
|
|
96
97
|
socket.onerror = () => {};
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
// Fetch data dir then connect
|
|
100
|
-
|
|
101
|
-
.then(data => { if (data.dataDir) dataDir = data.dataDir; })
|
|
102
|
-
.
|
|
103
|
-
|
|
100
|
+
// Fetch data dir + default agent then connect
|
|
101
|
+
Promise.all([
|
|
102
|
+
fetch('/api/help?action=status').then(r => r.json()).then(data => { if (data.dataDir) dataDir = data.dataDir; }).catch(() => {}),
|
|
103
|
+
fetch('/api/agents').then(r => r.json()).then(data => {
|
|
104
|
+
const defaultId = data.defaultAgent || 'claude';
|
|
105
|
+
const agent = (data.agents || []).find((a: any) => a.id === defaultId);
|
|
106
|
+
if (agent?.path) agentCmd = agent.path;
|
|
107
|
+
}).catch(() => {}),
|
|
108
|
+
]).finally(() => { if (!disposed) connect(); });
|
|
104
109
|
|
|
105
110
|
term.onData((data) => {
|
|
106
111
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
import type { TaskLogEntry } from '@/src/types';
|
|
5
|
+
|
|
6
|
+
const ConversationTerminalView = lazy(() => import('./ConversationTerminalView'));
|
|
7
|
+
|
|
8
|
+
// ─── Task stream hook ─────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function useTaskStreamInline(taskId: string | undefined, isRunning: boolean) {
|
|
11
|
+
const [log, setLog] = useState<TaskLogEntry[]>([]);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!taskId || !isRunning) { setLog([]); return; }
|
|
14
|
+
const es = new EventSource(`/api/tasks/${taskId}/stream`);
|
|
15
|
+
es.onmessage = (event) => {
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(event.data);
|
|
18
|
+
if (data.type === 'log') setLog(prev => [...prev, data.entry]);
|
|
19
|
+
else if (data.type === 'complete' && data.task) setLog(data.task.log);
|
|
20
|
+
} catch {}
|
|
21
|
+
};
|
|
22
|
+
es.onerror = () => es.close();
|
|
23
|
+
return () => es.close();
|
|
24
|
+
}, [taskId, isRunning]);
|
|
25
|
+
return log;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Live log renderer ────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function InlineLiveLog({ log }: { log: TaskLogEntry[] }) {
|
|
31
|
+
const endRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [log]);
|
|
33
|
+
if (log.length === 0) return <span className="text-[9px] text-[var(--text-secondary)] italic">Starting...</span>;
|
|
34
|
+
return (
|
|
35
|
+
<div className="max-h-[120px] overflow-y-auto text-[8px] font-mono leading-relaxed space-y-0.5">
|
|
36
|
+
{log.slice(-30).map((entry, i) => (
|
|
37
|
+
<div key={i} className={
|
|
38
|
+
entry.type === 'result' ? 'text-green-400' :
|
|
39
|
+
entry.subtype === 'error' ? 'text-red-400' :
|
|
40
|
+
entry.type === 'system' ? 'text-yellow-400/70' :
|
|
41
|
+
'text-[var(--text-secondary)]'
|
|
42
|
+
}>
|
|
43
|
+
{entry.type === 'assistant' && entry.subtype === 'tool_use'
|
|
44
|
+
? `⚙ ${entry.tool || 'tool'}: ${entry.content.slice(0, 60)}...`
|
|
45
|
+
: entry.content.slice(0, 120)}{entry.content.length > 120 ? '...' : ''}
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
<div ref={endRef} />
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── DAG node card ────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
|
|
56
|
+
const isRunning = node.status === 'running';
|
|
57
|
+
const log = useTaskStreamInline(node.taskId, isRunning);
|
|
58
|
+
const statusIcon = node.status === 'done' ? '✅' : node.status === 'failed' ? '❌' : node.status === 'running' ? '🔄' : node.status === 'skipped' ? '⏭' : '⏳';
|
|
59
|
+
return (
|
|
60
|
+
<div className={`border rounded p-2 ${
|
|
61
|
+
isRunning ? 'border-yellow-500/40 bg-yellow-500/5' :
|
|
62
|
+
node.status === 'done' ? 'border-green-500/20 bg-green-500/5' :
|
|
63
|
+
node.status === 'failed' ? 'border-red-500/20 bg-red-500/5' :
|
|
64
|
+
'border-[var(--border)]'
|
|
65
|
+
}`}>
|
|
66
|
+
<div className="flex items-center gap-1.5 text-[9px]">
|
|
67
|
+
<span>{statusIcon}</span>
|
|
68
|
+
<span className="font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
69
|
+
{node.taskId && <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>}
|
|
70
|
+
<span className="text-[var(--text-secondary)] ml-auto">{node.status}</span>
|
|
71
|
+
</div>
|
|
72
|
+
{isRunning && <div className="mt-1.5"><InlineLiveLog log={log} /></div>}
|
|
73
|
+
{node.error && <div className="text-[8px] text-red-400 mt-1">{node.error}</div>}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Main component ───────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline: any; onRefresh: () => void }) {
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (pipeline.status !== 'running') return;
|
|
83
|
+
const timer = setInterval(onRefresh, 3000);
|
|
84
|
+
return () => clearInterval(timer);
|
|
85
|
+
}, [pipeline.status, onRefresh]);
|
|
86
|
+
|
|
87
|
+
const isConversation = pipeline.type === 'conversation' && pipeline.conversation;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="bg-[var(--bg-tertiary)]/50">
|
|
91
|
+
{isConversation ? (
|
|
92
|
+
<div style={{ height: 450 }}>
|
|
93
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full text-[9px] text-[var(--text-secondary)]">Loading...</div>}>
|
|
94
|
+
<ConversationTerminalView pipeline={pipeline} />
|
|
95
|
+
</Suspense>
|
|
96
|
+
</div>
|
|
97
|
+
) : (
|
|
98
|
+
<div className="px-3 py-2 space-y-1.5">
|
|
99
|
+
{pipeline.nodeOrder.map((nodeId: string) => (
|
|
100
|
+
<InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} />
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
{pipeline.status !== 'running' && (
|
|
105
|
+
<div className={`text-[8px] text-center py-1 ${pipeline.status === 'done' ? 'text-green-400' : 'text-red-400'}`}>
|
|
106
|
+
Pipeline {pipeline.status}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -19,6 +19,8 @@ export default function MobileView() {
|
|
|
19
19
|
const [debugLevel, setDebugLevel] = useState<'off' | 'simple' | 'verbose'>('off');
|
|
20
20
|
const debugLevelRef = useRef<'off' | 'simple' | 'verbose'>('off');
|
|
21
21
|
const [hasSession, setHasSession] = useState(false);
|
|
22
|
+
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string }[]>([]);
|
|
23
|
+
const [selectedAgent, setSelectedAgent] = useState('claude');
|
|
22
24
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
23
25
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
26
|
const abortRef = useRef<AbortController | null>(null);
|
|
@@ -31,6 +33,12 @@ export default function MobileView() {
|
|
|
31
33
|
fetch('/api/tunnel').then(r => r.json())
|
|
32
34
|
.then(data => { setTunnelUrl(data.url || null); })
|
|
33
35
|
.catch(() => {});
|
|
36
|
+
fetch('/api/agents').then(r => r.json())
|
|
37
|
+
.then(data => {
|
|
38
|
+
const agents = (data.agents || []).filter((a: any) => a.enabled && a.detected !== false);
|
|
39
|
+
setAvailableAgents(agents);
|
|
40
|
+
setSelectedAgent(data.defaultAgent || 'claude');
|
|
41
|
+
}).catch(() => {});
|
|
34
42
|
}, []);
|
|
35
43
|
|
|
36
44
|
// Auto-scroll
|
|
@@ -116,6 +124,7 @@ export default function MobileView() {
|
|
|
116
124
|
message: text,
|
|
117
125
|
projectPath: selectedProject.path,
|
|
118
126
|
resume: false,
|
|
127
|
+
agent: selectedAgent,
|
|
119
128
|
}),
|
|
120
129
|
signal: abort.signal,
|
|
121
130
|
});
|
|
@@ -248,6 +257,17 @@ export default function MobileView() {
|
|
|
248
257
|
}}
|
|
249
258
|
className="text-sm px-3 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]"
|
|
250
259
|
>↻</button>
|
|
260
|
+
{availableAgents.length > 1 && (
|
|
261
|
+
<select
|
|
262
|
+
value={selectedAgent}
|
|
263
|
+
onChange={e => setSelectedAgent(e.target.value)}
|
|
264
|
+
className="bg-[#0d1117] border border-[#30363d] rounded px-1 py-1 text-[10px] text-[#e6edf3] w-16"
|
|
265
|
+
>
|
|
266
|
+
{availableAgents.map(a => (
|
|
267
|
+
<option key={a.id} value={a.id}>{a.name.split(' ')[0]}</option>
|
|
268
|
+
))}
|
|
269
|
+
</select>
|
|
270
|
+
)}
|
|
251
271
|
</>
|
|
252
272
|
)}
|
|
253
273
|
{tunnelUrl && (
|
|
@@ -4,9 +4,10 @@ import { useState, useEffect, useCallback } from 'react';
|
|
|
4
4
|
|
|
5
5
|
interface MonitorData {
|
|
6
6
|
processes: {
|
|
7
|
-
nextjs: { running: boolean; pid: string };
|
|
8
|
-
terminal: { running: boolean; pid: string };
|
|
9
|
-
telegram: { running: boolean; pid: string };
|
|
7
|
+
nextjs: { running: boolean; pid: string; startedAt?: string };
|
|
8
|
+
terminal: { running: boolean; pid: string; startedAt?: string };
|
|
9
|
+
telegram: { running: boolean; pid: string; startedAt?: string };
|
|
10
|
+
workspace: { running: boolean; pid: string; startedAt?: string };
|
|
10
11
|
tunnel: { running: boolean; pid: string; url: string };
|
|
11
12
|
};
|
|
12
13
|
sessions: { name: string; created: string; attached: boolean; windows: number }[];
|
|
@@ -47,13 +48,17 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
|
|
|
47
48
|
{ label: 'Next.js', ...data.processes.nextjs },
|
|
48
49
|
{ label: 'Terminal Server', ...data.processes.terminal },
|
|
49
50
|
{ label: 'Telegram Bot', ...data.processes.telegram },
|
|
51
|
+
{ label: 'Workspace Daemon', ...data.processes.workspace },
|
|
50
52
|
{ label: 'Tunnel', ...data.processes.tunnel },
|
|
51
53
|
].map(p => (
|
|
52
54
|
<div key={p.label} className="flex items-center gap-2 text-xs">
|
|
53
55
|
<span className={p.running ? 'text-green-400' : 'text-gray-500'}>●</span>
|
|
54
56
|
<span className="text-[var(--text-primary)] w-28">{p.label}</span>
|
|
55
57
|
{p.running ? (
|
|
56
|
-
|
|
58
|
+
<>
|
|
59
|
+
<span className="text-[var(--text-secondary)] font-mono text-[10px]">pid: {p.pid}</span>
|
|
60
|
+
{(p as any).startedAt && <span className="text-gray-500 font-mono text-[9px]">{(p as any).startedAt}</span>}
|
|
61
|
+
</>
|
|
57
62
|
) : (
|
|
58
63
|
<span className="text-gray-500 text-[10px]">stopped</span>
|
|
59
64
|
)}
|
|
@@ -26,6 +26,7 @@ interface TaskData {
|
|
|
26
26
|
scheduledAt?: string;
|
|
27
27
|
mode?: TaskMode;
|
|
28
28
|
watchConfig?: WatchConfig;
|
|
29
|
+
agent?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export default function NewTaskModal({
|
|
@@ -64,6 +65,19 @@ export default function NewTaskModal({
|
|
|
64
65
|
const [delayMinutes, setDelayMinutes] = useState(30);
|
|
65
66
|
const [scheduledTime, setScheduledTime] = useState(editTask?.scheduledAt ? new Date(editTask.scheduledAt).toISOString().slice(0, 16) : '');
|
|
66
67
|
|
|
68
|
+
// Agent selection
|
|
69
|
+
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
|
|
70
|
+
const [selectedAgent, setSelectedAgent] = useState('');
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
fetch('/api/agents').then(r => r.json())
|
|
74
|
+
.then(data => {
|
|
75
|
+
const agents = (data.agents || []).filter((a: any) => a.enabled && a.detected !== false);
|
|
76
|
+
setAvailableAgents(agents);
|
|
77
|
+
setSelectedAgent(data.defaultAgent || 'claude');
|
|
78
|
+
}).catch(() => {});
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
67
81
|
useEffect(() => {
|
|
68
82
|
fetch('/api/projects').then(r => r.json()).then((p: Project[]) => {
|
|
69
83
|
setProjects(p);
|
|
@@ -132,6 +146,8 @@ export default function NewTaskModal({
|
|
|
132
146
|
data.watchConfig = wc;
|
|
133
147
|
}
|
|
134
148
|
|
|
149
|
+
if (selectedAgent) data.agent = selectedAgent;
|
|
150
|
+
|
|
135
151
|
onCreate(data);
|
|
136
152
|
};
|
|
137
153
|
|
|
@@ -162,6 +178,22 @@ export default function NewTaskModal({
|
|
|
162
178
|
</select>
|
|
163
179
|
</div>
|
|
164
180
|
|
|
181
|
+
{/* Agent */}
|
|
182
|
+
{availableAgents.length > 1 && (
|
|
183
|
+
<div>
|
|
184
|
+
<label className="text-[11px] text-[var(--text-secondary)] block mb-1">Agent</label>
|
|
185
|
+
<select
|
|
186
|
+
value={selectedAgent}
|
|
187
|
+
onChange={e => setSelectedAgent(e.target.value)}
|
|
188
|
+
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)]"
|
|
189
|
+
>
|
|
190
|
+
{availableAgents.map(a => (
|
|
191
|
+
<option key={a.id} value={a.id}>{a.name}</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
165
197
|
{/* Task Mode */}
|
|
166
198
|
<div>
|
|
167
199
|
<label className="text-[11px] text-[var(--text-secondary)] block mb-1">Mode</label>
|
|
@@ -36,6 +36,9 @@ function PipelineNode({ id, data }: NodeProps<Node<NodeData>>) {
|
|
|
36
36
|
<Handle type="target" position={Position.Top} className="!bg-[var(--accent)] !w-3 !h-3" />
|
|
37
37
|
|
|
38
38
|
<div className="px-3 py-2 border-b border-[#3a3a5a] flex items-center gap-2">
|
|
39
|
+
<span className={`text-[8px] px-1 py-0.5 rounded font-medium ${
|
|
40
|
+
(data as any).mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
|
|
41
|
+
}`}>{(data as any).mode === 'shell' ? 'shell' : ((data as any).agent || 'default')}</span>
|
|
39
42
|
<span className="text-xs font-semibold text-white">{data.label}</span>
|
|
40
43
|
<div className="ml-auto flex gap-1">
|
|
41
44
|
<button onClick={() => data.onEdit(id)} className="text-[9px] text-[var(--accent)] hover:text-white">edit</button>
|
|
@@ -62,15 +65,18 @@ const nodeTypes = { pipeline: PipelineNode };
|
|
|
62
65
|
|
|
63
66
|
// ─── Node Edit Modal ──────────────────────────────────────
|
|
64
67
|
|
|
65
|
-
function NodeEditModal({ node, projects, onSave, onClose }: {
|
|
66
|
-
node: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] };
|
|
68
|
+
function NodeEditModal({ node, projects, agents, onSave, onClose }: {
|
|
69
|
+
node: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] };
|
|
67
70
|
projects: { name: string; root: string }[];
|
|
68
|
-
|
|
71
|
+
agents: { id: string; name: string }[];
|
|
72
|
+
onSave: (data: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] }) => void;
|
|
69
73
|
onClose: () => void;
|
|
70
74
|
}) {
|
|
71
75
|
const [id, setId] = useState(node.id);
|
|
72
76
|
const [project, setProject] = useState(node.project);
|
|
73
77
|
const [prompt, setPrompt] = useState(node.prompt);
|
|
78
|
+
const [agent, setAgent] = useState(node.agent || '');
|
|
79
|
+
const [mode, setMode] = useState(node.mode || 'claude');
|
|
74
80
|
const [outputs, setOutputs] = useState(node.outputs);
|
|
75
81
|
|
|
76
82
|
return (
|
|
@@ -105,6 +111,34 @@ function NodeEditModal({ node, projects, onSave, onClose }: {
|
|
|
105
111
|
))}
|
|
106
112
|
</select>
|
|
107
113
|
</div>
|
|
114
|
+
<div className="flex gap-2">
|
|
115
|
+
<div className="flex-1">
|
|
116
|
+
<label className="text-[10px] text-gray-400 block mb-1">Mode</label>
|
|
117
|
+
<select
|
|
118
|
+
value={mode}
|
|
119
|
+
onChange={e => setMode(e.target.value)}
|
|
120
|
+
className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
|
|
121
|
+
>
|
|
122
|
+
<option value="claude">Agent</option>
|
|
123
|
+
<option value="shell">Shell</option>
|
|
124
|
+
</select>
|
|
125
|
+
</div>
|
|
126
|
+
{mode !== 'shell' && (
|
|
127
|
+
<div className="flex-1">
|
|
128
|
+
<label className="text-[10px] text-gray-400 block mb-1">Agent</label>
|
|
129
|
+
<select
|
|
130
|
+
value={agent}
|
|
131
|
+
onChange={e => setAgent(e.target.value)}
|
|
132
|
+
className="w-full text-xs bg-[#12122a] border border-[#3a3a5a] rounded px-2 py-1.5 text-white"
|
|
133
|
+
>
|
|
134
|
+
<option value="">Default</option>
|
|
135
|
+
{agents.map(a => (
|
|
136
|
+
<option key={a.id} value={a.id}>{a.name}</option>
|
|
137
|
+
))}
|
|
138
|
+
</select>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
108
142
|
<div>
|
|
109
143
|
<label className="text-[10px] text-gray-400 block mb-1">Prompt</label>
|
|
110
144
|
<textarea
|
|
@@ -147,7 +181,7 @@ function NodeEditModal({ node, projects, onSave, onClose }: {
|
|
|
147
181
|
<div className="px-4 py-3 border-t border-[#3a3a5a] flex gap-2 justify-end">
|
|
148
182
|
<button onClick={onClose} className="text-xs px-3 py-1 text-gray-400 hover:text-white">Cancel</button>
|
|
149
183
|
<button
|
|
150
|
-
onClick={() => onSave({ id, project, prompt, outputs: outputs.filter(o => o.name) })}
|
|
184
|
+
onClick={() => onSave({ id, project, prompt, agent: agent || undefined, mode, outputs: outputs.filter(o => o.name) })}
|
|
151
185
|
className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
152
186
|
>
|
|
153
187
|
Save
|
|
@@ -167,8 +201,9 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
167
201
|
}) {
|
|
168
202
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
|
|
169
203
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
170
|
-
const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
|
|
204
|
+
const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] } | null>(null);
|
|
171
205
|
const [workflowName, setWorkflowName] = useState('');
|
|
206
|
+
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string }[]>([]);
|
|
172
207
|
const [workflowDesc, setWorkflowDesc] = useState('');
|
|
173
208
|
const [varsProject, setVarsProject] = useState('');
|
|
174
209
|
const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
|
|
@@ -178,6 +213,9 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
178
213
|
fetch('/api/projects').then(r => r.json())
|
|
179
214
|
.then((p: { name: string; root: string }[]) => { if (Array.isArray(p)) setProjects(p); })
|
|
180
215
|
.catch(() => {});
|
|
216
|
+
fetch('/api/agents').then(r => r.json())
|
|
217
|
+
.then(data => setAvailableAgents((data.agents || []).filter((a: any) => a.enabled)))
|
|
218
|
+
.catch(() => {});
|
|
181
219
|
}, []);
|
|
182
220
|
|
|
183
221
|
// Load initial YAML if provided
|
|
@@ -271,7 +309,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
271
309
|
setEdges(eds => eds.filter(e => e.source !== id && e.target !== id));
|
|
272
310
|
}, [setNodes, setEdges]);
|
|
273
311
|
|
|
274
|
-
const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] }) => {
|
|
312
|
+
const handleSaveNode = useCallback((data: { id: string; project: string; prompt: string; agent?: string; mode?: string; outputs: { name: string; extract: string }[] }) => {
|
|
275
313
|
setNodes(nds => nds.map(n => {
|
|
276
314
|
if (n.id === editingNode?.id) {
|
|
277
315
|
return {
|
|
@@ -282,6 +320,8 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
282
320
|
label: data.id,
|
|
283
321
|
project: data.project,
|
|
284
322
|
prompt: data.prompt,
|
|
323
|
+
agent: data.agent,
|
|
324
|
+
mode: data.mode,
|
|
285
325
|
outputs: data.outputs,
|
|
286
326
|
},
|
|
287
327
|
};
|
|
@@ -315,6 +355,8 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
315
355
|
project: node.data.project,
|
|
316
356
|
prompt: node.data.prompt,
|
|
317
357
|
};
|
|
358
|
+
if ((node.data as any).mode === 'shell') nodeDef.mode = 'shell';
|
|
359
|
+
if ((node.data as any).agent) nodeDef.agent = (node.data as any).agent;
|
|
318
360
|
if (deps.length > 0) nodeDef.depends_on = deps;
|
|
319
361
|
if (node.data.outputs.length > 0) nodeDef.outputs = node.data.outputs;
|
|
320
362
|
workflow.nodes[node.id] = nodeDef;
|
|
@@ -392,6 +434,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
392
434
|
<NodeEditModal
|
|
393
435
|
node={editingNode}
|
|
394
436
|
projects={projects}
|
|
437
|
+
agents={availableAgents}
|
|
395
438
|
onSave={handleSaveNode}
|
|
396
439
|
onClose={() => setEditingNode(null)}
|
|
397
440
|
/>
|