helixevo 0.2.23 → 0.2.25
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,7 +3,6 @@ import { spawn, type ChildProcess } from 'child_process'
|
|
|
3
3
|
|
|
4
4
|
export const dynamic = 'force-dynamic'
|
|
5
5
|
|
|
6
|
-
// Allowed commands — whitelist to prevent arbitrary execution
|
|
7
6
|
const ALLOWED_COMMANDS: Record<string, { cmd: string; args: string[]; timeout: number }> = {
|
|
8
7
|
'status': { cmd: 'helixevo', args: ['status'], timeout: 15000 },
|
|
9
8
|
'health': { cmd: 'helixevo', args: ['health', '--verbose'], timeout: 120000 },
|
|
@@ -19,93 +18,103 @@ const ALLOWED_COMMANDS: Record<string, { cmd: string; args: string[]; timeout: n
|
|
|
19
18
|
'report': { cmd: 'helixevo', args: ['report', '--days', '7'], timeout: 30000 },
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
// Track the currently running process so it can be stopped
|
|
23
21
|
let activeProcess: ChildProcess | null = null
|
|
24
22
|
let activeCommand: string | null = null
|
|
25
23
|
|
|
24
|
+
// POST — stream output via SSE
|
|
26
25
|
export async function POST(request: Request) {
|
|
27
26
|
const body = await request.json()
|
|
28
27
|
const { command } = body as { command: string }
|
|
29
28
|
|
|
30
29
|
const entry = ALLOWED_COMMANDS[command]
|
|
31
30
|
if (!entry) {
|
|
32
|
-
return NextResponse.json({
|
|
33
|
-
success: false,
|
|
34
|
-
error: `Unknown command: ${command}`,
|
|
35
|
-
}, { status: 400 })
|
|
31
|
+
return NextResponse.json({ success: false, error: `Unknown command: ${command}` }, { status: 400 })
|
|
36
32
|
}
|
|
37
33
|
|
|
38
|
-
// Kill any existing process
|
|
34
|
+
// Kill any existing process
|
|
39
35
|
if (activeProcess && !activeProcess.killed) {
|
|
40
36
|
activeProcess.kill('SIGTERM')
|
|
41
37
|
activeProcess = null
|
|
42
38
|
activeCommand = null
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
output += data.toString()
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
child.on('close', (code, signal) => {
|
|
71
|
-
clearTimeout(timeout)
|
|
72
|
-
activeProcess = null
|
|
73
|
-
activeCommand = null
|
|
74
|
-
|
|
75
|
-
if (signal === 'SIGTERM' && !timedOut) {
|
|
76
|
-
// Stopped by user
|
|
77
|
-
resolve(NextResponse.json({
|
|
78
|
-
success: false,
|
|
79
|
-
stopped: true,
|
|
80
|
-
output: output + '\n\n[Stopped by user]',
|
|
81
|
-
}))
|
|
82
|
-
} else if (timedOut) {
|
|
83
|
-
resolve(NextResponse.json({
|
|
84
|
-
success: false,
|
|
85
|
-
output: output + '\n\n[Timed out]',
|
|
86
|
-
}, { status: 500 }))
|
|
87
|
-
} else {
|
|
88
|
-
resolve(NextResponse.json({
|
|
89
|
-
success: code === 0,
|
|
90
|
-
command: `${entry.cmd} ${entry.args.join(' ')}`,
|
|
91
|
-
output: output || 'No output',
|
|
92
|
-
}, code === 0 ? {} : { status: 500 }))
|
|
41
|
+
const encoder = new TextEncoder()
|
|
42
|
+
let timedOut = false
|
|
43
|
+
|
|
44
|
+
const stream = new ReadableStream({
|
|
45
|
+
start(controller) {
|
|
46
|
+
const child = spawn(entry.cmd, entry.args, {
|
|
47
|
+
env: { ...process.env },
|
|
48
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
activeProcess = child
|
|
52
|
+
activeCommand = command
|
|
53
|
+
|
|
54
|
+
const timeout = setTimeout(() => {
|
|
55
|
+
timedOut = true
|
|
56
|
+
child.kill('SIGTERM')
|
|
57
|
+
}, entry.timeout)
|
|
58
|
+
|
|
59
|
+
const send = (event: string, data: string) => {
|
|
60
|
+
try {
|
|
61
|
+
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
|
|
62
|
+
} catch {}
|
|
93
63
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
64
|
+
|
|
65
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
66
|
+
send('output', chunk.toString())
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
70
|
+
send('output', chunk.toString())
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
child.on('close', (code, signal) => {
|
|
74
|
+
clearTimeout(timeout)
|
|
75
|
+
activeProcess = null
|
|
76
|
+
activeCommand = null
|
|
77
|
+
|
|
78
|
+
if (signal === 'SIGTERM' && !timedOut) {
|
|
79
|
+
send('done', JSON.stringify({ success: false, stopped: true }))
|
|
80
|
+
} else if (timedOut) {
|
|
81
|
+
send('done', JSON.stringify({ success: false, timedOut: true }))
|
|
82
|
+
} else {
|
|
83
|
+
send('done', JSON.stringify({ success: code === 0 }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try { controller.close() } catch {}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
child.on('error', (err) => {
|
|
90
|
+
clearTimeout(timeout)
|
|
91
|
+
activeProcess = null
|
|
92
|
+
activeCommand = null
|
|
93
|
+
send('output', `Error: ${err.message}`)
|
|
94
|
+
send('done', JSON.stringify({ success: false }))
|
|
95
|
+
try { controller.close() } catch {}
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
cancel() {
|
|
100
|
+
if (activeProcess && !activeProcess.killed) {
|
|
101
|
+
activeProcess.kill('SIGTERM')
|
|
102
|
+
activeProcess = null
|
|
103
|
+
activeCommand = null
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return new Response(stream, {
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'text/event-stream',
|
|
111
|
+
'Cache-Control': 'no-cache',
|
|
112
|
+
'Connection': 'keep-alive',
|
|
113
|
+
},
|
|
105
114
|
})
|
|
106
115
|
}
|
|
107
116
|
|
|
108
|
-
// DELETE = stop
|
|
117
|
+
// DELETE = stop
|
|
109
118
|
export async function DELETE() {
|
|
110
119
|
if (activeProcess && !activeProcess.killed) {
|
|
111
120
|
const cmd = activeCommand
|
|
@@ -117,7 +126,7 @@ export async function DELETE() {
|
|
|
117
126
|
return NextResponse.json({ stopped: false, message: 'No running command' })
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
// GET = check
|
|
129
|
+
// GET = check status
|
|
121
130
|
export async function GET() {
|
|
122
131
|
return NextResponse.json({
|
|
123
132
|
running: activeProcess !== null && !activeProcess.killed,
|
package/dashboard/app/page.tsx
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
1
2
|
import { getDashboardSummary, loadFrontier, loadHistory, loadGraph, loadFailures, listProjects } from '@/lib/data'
|
|
2
3
|
import { OverviewActions } from '@/components/overview-actions'
|
|
3
4
|
|
|
4
5
|
export const dynamic = 'force-dynamic'
|
|
5
6
|
|
|
6
|
-
function StatCard({ value, label, color, sub, accent }: {
|
|
7
|
+
function StatCard({ value, label, color, sub, accent, href }: {
|
|
8
|
+
value: string | number; label: string; color: string; sub?: string; accent?: string; href: string
|
|
9
|
+
}) {
|
|
7
10
|
return (
|
|
8
|
-
<
|
|
9
|
-
<div style={{
|
|
10
|
-
<div>
|
|
11
|
-
<div
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
<Link href={href} style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
12
|
+
<div className="stat-card" style={{ cursor: 'pointer' }}>
|
|
13
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
14
|
+
<div>
|
|
15
|
+
<div className="stat-value" style={{ color }}>{value}</div>
|
|
16
|
+
<div className="stat-label">{label}</div>
|
|
17
|
+
{sub && <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}>{sub}</div>}
|
|
18
|
+
</div>
|
|
19
|
+
{accent && (
|
|
20
|
+
<div style={{
|
|
21
|
+
width: 36, height: 36, borderRadius: 10,
|
|
22
|
+
background: `${color}12`, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
23
|
+
color, fontSize: 16,
|
|
24
|
+
}}>{accent}</div>
|
|
25
|
+
)}
|
|
14
26
|
</div>
|
|
15
|
-
{accent && (
|
|
16
|
-
<div style={{
|
|
17
|
-
width: 36, height: 36, borderRadius: 10,
|
|
18
|
-
background: `${color}12`, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
19
|
-
color, fontSize: 16,
|
|
20
|
-
}}>{accent}</div>
|
|
21
|
-
)}
|
|
22
27
|
</div>
|
|
23
|
-
</
|
|
28
|
+
</Link>
|
|
24
29
|
)
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -56,18 +61,18 @@ export default function Overview() {
|
|
|
56
61
|
/>
|
|
57
62
|
</div>
|
|
58
63
|
|
|
59
|
-
{/* Stats */}
|
|
64
|
+
{/* Stats — all clickable, linking to relevant pages */}
|
|
60
65
|
<div className="grid-5" style={{ marginBottom: 28 }}>
|
|
61
|
-
<StatCard value={s.skills.total} label="Total Skills" color="var(--purple)" sub={`${s.skills.evolved} evolved`} accent="◆" />
|
|
62
|
-
<StatCard value={s.evolution.accepted} label="Accepted" color="var(--green)" sub={`${s.evolution.rejected} rejected`} accent="✓" />
|
|
63
|
-
<StatCard value={s.failures.unresolved} label="Unresolved" color="var(--yellow)" sub={`of ${s.failures.total} total`} accent="!" />
|
|
64
|
-
<StatCard value={s.buffer.discoveries} label="Discoveries" color="var(--blue)" sub={`${s.buffer.drafts} drafts`} accent="◎" />
|
|
65
|
-
<StatCard value={frontier.programs.length} label="Frontier" color="var(--text-secondary)" sub={`/${frontier.capacity} capacity`} accent="▲" />
|
|
66
|
+
<StatCard value={s.skills.total} label="Total Skills" color="var(--purple)" sub={`${s.skills.evolved} evolved`} accent="◆" href="/network" />
|
|
67
|
+
<StatCard value={s.evolution.accepted} label="Accepted" color="var(--green)" sub={`${s.evolution.rejected} rejected`} accent="✓" href="/evolution" />
|
|
68
|
+
<StatCard value={s.failures.unresolved} label="Unresolved" color="var(--yellow)" sub={`of ${s.failures.total} total`} accent="!" href="#unresolved" />
|
|
69
|
+
<StatCard value={s.buffer.discoveries} label="Discoveries" color="var(--blue)" sub={`${s.buffer.drafts} drafts`} accent="◎" href="/research" />
|
|
70
|
+
<StatCard value={frontier.programs.length} label="Frontier" color="var(--text-secondary)" sub={`/${frontier.capacity} capacity`} accent="▲" href="/frontier" />
|
|
66
71
|
</div>
|
|
67
72
|
|
|
68
73
|
{/* Unresolved Failures */}
|
|
69
74
|
{failures.filter(f => !f.resolved).length > 0 && (
|
|
70
|
-
<div className="card" style={{ marginBottom: 20 }}>
|
|
75
|
+
<div id="unresolved" className="card" style={{ marginBottom: 20 }}>
|
|
71
76
|
<div className="card-body">
|
|
72
77
|
<div className="card-header-label">
|
|
73
78
|
Unresolved Corrections ({failures.filter(f => !f.resolved).length})
|
|
@@ -110,98 +115,104 @@ export default function Overview() {
|
|
|
110
115
|
|
|
111
116
|
<div className="grid-2">
|
|
112
117
|
{/* Frontier */}
|
|
113
|
-
<
|
|
114
|
-
<div className="card
|
|
115
|
-
<div className="card-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
118
|
+
<Link href="/frontier" style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
119
|
+
<div className="card" style={{ cursor: 'pointer', height: '100%' }}>
|
|
120
|
+
<div className="card-body">
|
|
121
|
+
<div className="card-header-label">Pareto Frontier</div>
|
|
122
|
+
{frontier.programs.map((p, i) => (
|
|
123
|
+
<div key={`${p.id}-${i}`} style={{
|
|
124
|
+
padding: '12px 0',
|
|
125
|
+
borderBottom: i < frontier.programs.length - 1 ? '1px solid var(--border-subtle)' : 'none',
|
|
126
|
+
}}>
|
|
127
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
128
|
+
<span style={{ fontSize: 13, fontWeight: 600 }}>{p.id}</span>
|
|
129
|
+
<span style={{
|
|
130
|
+
fontSize: 20, fontWeight: 800, lineHeight: 1,
|
|
131
|
+
color: p.score >= 0.8 ? 'var(--green)' : 'var(--yellow)',
|
|
132
|
+
}}>
|
|
133
|
+
{(p.score * 100).toFixed(0)}
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div style={{ display: 'flex', gap: 6, fontSize: 11 }}>
|
|
137
|
+
<span className="score-pill" style={{ color: 'var(--green)' }}>T:{(p.scores.taskCompletion * 10).toFixed(0)}</span>
|
|
138
|
+
<span className="score-pill" style={{ color: 'var(--blue)' }}>A:{(p.scores.correctionAlignment * 10).toFixed(0)}</span>
|
|
139
|
+
<span className="score-pill" style={{ color: 'var(--purple)' }}>S:{(p.scores.sideEffectFree * 10).toFixed(0)}</span>
|
|
140
|
+
</div>
|
|
141
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4, lineHeight: 1.4 }}>
|
|
142
|
+
{p.changesDescription.slice(0, 80)}
|
|
143
|
+
</div>
|
|
137
144
|
</div>
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
140
147
|
</div>
|
|
141
|
-
</
|
|
148
|
+
</Link>
|
|
142
149
|
|
|
143
150
|
{/* Recent Evolution */}
|
|
144
|
-
<
|
|
145
|
-
<div className="card
|
|
146
|
-
<div className="card-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
{iter.proposals.map(p => (
|
|
157
|
-
<div key={p.id} style={{
|
|
158
|
-
display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 4,
|
|
159
|
-
padding: '4px 0',
|
|
160
|
-
}}>
|
|
161
|
-
<span style={{
|
|
162
|
-
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
|
163
|
-
background: p.outcome === 'accepted' ? 'var(--green)' : 'var(--red)',
|
|
164
|
-
}} />
|
|
165
|
-
<span style={{ fontWeight: 500 }}>{p.targetSkill}</span>
|
|
166
|
-
<span className="badge badge-gray" style={{ fontSize: 9 }}>{p.action}</span>
|
|
151
|
+
<Link href="/evolution" style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
152
|
+
<div className="card" style={{ cursor: 'pointer', height: '100%' }}>
|
|
153
|
+
<div className="card-body">
|
|
154
|
+
<div className="card-header-label">Recent Evolution</div>
|
|
155
|
+
{recent.map((iter, idx) => (
|
|
156
|
+
<div key={`${iter.id}-${idx}`} style={{
|
|
157
|
+
padding: '10px 0',
|
|
158
|
+
borderBottom: '1px solid var(--border-subtle)',
|
|
159
|
+
}}>
|
|
160
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 8 }}>
|
|
161
|
+
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{iter.id}</span>
|
|
162
|
+
<span style={{ color: 'var(--text-muted)' }}>{new Date(iter.timestamp).toLocaleDateString()}</span>
|
|
167
163
|
</div>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
{iter.proposals.map(p => (
|
|
165
|
+
<div key={p.id} style={{
|
|
166
|
+
display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 4,
|
|
167
|
+
padding: '4px 0',
|
|
168
|
+
}}>
|
|
169
|
+
<span style={{
|
|
170
|
+
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
|
171
|
+
background: p.outcome === 'accepted' ? 'var(--green)' : 'var(--red)',
|
|
172
|
+
}} />
|
|
173
|
+
<span style={{ fontWeight: 500 }}>{p.targetSkill}</span>
|
|
174
|
+
<span className="badge badge-gray" style={{ fontSize: 9 }}>{p.action}</span>
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
171
180
|
</div>
|
|
172
|
-
</
|
|
181
|
+
</Link>
|
|
173
182
|
</div>
|
|
174
183
|
|
|
175
184
|
{/* All Skills Summary */}
|
|
176
|
-
<
|
|
177
|
-
<div className="card
|
|
178
|
-
<div className="card-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
<div
|
|
191
|
-
<div className="score-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
185
|
+
<Link href="/network" style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
186
|
+
<div className="card" style={{ marginTop: 16, cursor: 'pointer' }}>
|
|
187
|
+
<div className="card-body">
|
|
188
|
+
<div className="card-header-label">All Skills</div>
|
|
189
|
+
<div className="grid-2" style={{ gap: 6 }}>
|
|
190
|
+
{graph.nodes.sort((a, b) => b.score - a.score).map(n => (
|
|
191
|
+
<div key={n.id} style={{
|
|
192
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
193
|
+
padding: '8px 12px', borderRadius: 8, background: 'var(--bg-section)',
|
|
194
|
+
}}>
|
|
195
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
196
|
+
<span style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n.name}</span>
|
|
197
|
+
{n.generation > 0 && <span className="badge badge-green" style={{ fontSize: 9 }}>gen {n.generation}</span>}
|
|
198
|
+
</div>
|
|
199
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 90, flexShrink: 0 }}>
|
|
200
|
+
<div className="score-track" style={{ width: 50 }}>
|
|
201
|
+
<div className="score-fill" style={{
|
|
202
|
+
width: `${n.score * 100}%`,
|
|
203
|
+
background: n.score >= 0.8 ? 'var(--green)' : n.score >= 0.6 ? 'var(--yellow)' : 'var(--red)',
|
|
204
|
+
}} />
|
|
205
|
+
</div>
|
|
206
|
+
<span style={{ fontSize: 12, fontWeight: 600, color: n.score >= 0.8 ? 'var(--green)' : 'var(--yellow)' }}>
|
|
207
|
+
{(n.score * 100).toFixed(0)}
|
|
208
|
+
</span>
|
|
195
209
|
</div>
|
|
196
|
-
<span style={{ fontSize: 12, fontWeight: 600, color: n.score >= 0.8 ? 'var(--green)' : 'var(--yellow)' }}>
|
|
197
|
-
{(n.score * 100).toFixed(0)}
|
|
198
|
-
</span>
|
|
199
210
|
</div>
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
202
213
|
</div>
|
|
203
214
|
</div>
|
|
204
|
-
</
|
|
215
|
+
</Link>
|
|
205
216
|
</div>
|
|
206
217
|
)
|
|
207
218
|
}
|
|
@@ -59,12 +59,13 @@ const PIPELINE_STEPS = [
|
|
|
59
59
|
|
|
60
60
|
export default function ResearchClient({ buffer, projects }: Props) {
|
|
61
61
|
const [runState, setRunState] = useState<RunState>('idle')
|
|
62
|
-
const [output, setOutput] = useState<string
|
|
62
|
+
const [output, setOutput] = useState<string>('')
|
|
63
63
|
const abortRef = useRef<AbortController | null>(null)
|
|
64
|
+
const outputRef = useRef<HTMLPreElement | null>(null)
|
|
64
65
|
|
|
65
66
|
const handleRun = async () => {
|
|
66
67
|
setRunState('running')
|
|
67
|
-
setOutput(
|
|
68
|
+
setOutput('')
|
|
68
69
|
const controller = new AbortController()
|
|
69
70
|
abortRef.current = controller
|
|
70
71
|
|
|
@@ -75,21 +76,58 @@ export default function ResearchClient({ buffer, projects }: Props) {
|
|
|
75
76
|
body: JSON.stringify({ command: 'research' }),
|
|
76
77
|
signal: controller.signal,
|
|
77
78
|
})
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
setOutput(
|
|
81
|
-
setRunState('
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
|
|
80
|
+
if (!res.body) {
|
|
81
|
+
setOutput('No response body')
|
|
82
|
+
setRunState('error')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const reader = res.body.getReader()
|
|
87
|
+
const decoder = new TextDecoder()
|
|
88
|
+
let sseBuffer = ''
|
|
89
|
+
|
|
90
|
+
while (true) {
|
|
91
|
+
const { done, value } = await reader.read()
|
|
92
|
+
if (done) break
|
|
93
|
+
|
|
94
|
+
sseBuffer += decoder.decode(value, { stream: true })
|
|
95
|
+
const events = sseBuffer.split('\n\n')
|
|
96
|
+
sseBuffer = events.pop() ?? ''
|
|
97
|
+
|
|
98
|
+
for (const event of events) {
|
|
99
|
+
const lines = event.split('\n')
|
|
100
|
+
let eventType = ''
|
|
101
|
+
let eventData = ''
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (line.startsWith('event: ')) eventType = line.slice(7)
|
|
104
|
+
if (line.startsWith('data: ')) eventData = line.slice(6)
|
|
105
|
+
}
|
|
106
|
+
if (eventType === 'output' && eventData) {
|
|
107
|
+
try {
|
|
108
|
+
const text = JSON.parse(eventData) as string
|
|
109
|
+
setOutput(prev => prev + text)
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight
|
|
112
|
+
}, 10)
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
if (eventType === 'done' && eventData) {
|
|
116
|
+
try {
|
|
117
|
+
const result = JSON.parse(JSON.parse(eventData) as string) as { success: boolean; stopped?: boolean }
|
|
118
|
+
setRunState(result.stopped ? 'stopped' : result.success ? 'success' : 'error')
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
85
122
|
}
|
|
123
|
+
if (runState === 'running') setRunState('success')
|
|
86
124
|
} catch (err: unknown) {
|
|
87
125
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
88
126
|
try { await fetch('/api/run', { method: 'DELETE' }) } catch {}
|
|
89
|
-
setOutput('Stopped by user')
|
|
127
|
+
setOutput(prev => prev + '\n\n[Stopped by user]')
|
|
90
128
|
setRunState('stopped')
|
|
91
129
|
} else {
|
|
92
|
-
setOutput('Network error')
|
|
130
|
+
setOutput(prev => prev || 'Network error')
|
|
93
131
|
setRunState('error')
|
|
94
132
|
}
|
|
95
133
|
} finally {
|
|
@@ -220,29 +258,29 @@ export default function ResearchClient({ buffer, projects }: Props) {
|
|
|
220
258
|
</div>
|
|
221
259
|
|
|
222
260
|
{/* Output panel */}
|
|
223
|
-
{runState !== 'idle' &&
|
|
261
|
+
{runState !== 'idle' && (
|
|
224
262
|
<div className="card" style={{ marginBottom: 24, overflow: 'hidden' }}>
|
|
225
263
|
<div style={{
|
|
226
264
|
padding: '10px 16px',
|
|
227
|
-
background: runState === 'success' ? 'var(--green-light)' : runState === 'error' ? 'var(--red-light)' : 'var(--bg-section)',
|
|
265
|
+
background: runState === 'success' ? 'var(--green-light)' : runState === 'error' ? 'var(--red-light)' : runState === 'stopped' ? 'var(--yellow-light)' : 'var(--bg-section)',
|
|
228
266
|
borderBottom: '1px solid var(--border)',
|
|
229
267
|
fontSize: 12, fontWeight: 600,
|
|
230
|
-
color: runState === 'success' ? 'var(--green)' : runState === 'error' ? 'var(--red)' : 'var(--text-secondary)',
|
|
268
|
+
color: runState === 'success' ? 'var(--green)' : runState === 'error' ? 'var(--red)' : runState === 'stopped' ? 'var(--yellow)' : 'var(--text-secondary)',
|
|
231
269
|
}}>
|
|
232
270
|
{runState === 'running' && '● Running research pipeline...'}
|
|
233
271
|
{runState === 'success' && '✓ Research completed'}
|
|
234
272
|
{runState === 'error' && '✗ Research failed'}
|
|
235
273
|
{runState === 'stopped' && '■ Research stopped'}
|
|
236
274
|
</div>
|
|
237
|
-
<pre style={{
|
|
275
|
+
<pre ref={outputRef} style={{
|
|
238
276
|
padding: '14px 16px', margin: 0,
|
|
239
277
|
fontSize: 11, lineHeight: 1.55,
|
|
240
278
|
fontFamily: 'var(--font-mono)',
|
|
241
279
|
color: 'var(--text-secondary)',
|
|
242
|
-
maxHeight:
|
|
280
|
+
maxHeight: 400, overflow: 'auto',
|
|
243
281
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
|
244
282
|
}}>
|
|
245
|
-
{output}
|
|
283
|
+
{output || 'Starting research pipeline...'}
|
|
246
284
|
</pre>
|
|
247
285
|
</div>
|
|
248
286
|
)}
|
|
@@ -14,11 +14,11 @@ interface OverviewActionsProps {
|
|
|
14
14
|
// Small inline SVG flow diagrams for each action
|
|
15
15
|
function FlowDiagram({ steps, color }: { steps: string[]; color: string }) {
|
|
16
16
|
return (
|
|
17
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginTop: 8, marginBottom: 2 }}>
|
|
17
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginTop: 8, marginBottom: 2, flexWrap: 'wrap', rowGap: 4 }}>
|
|
18
18
|
{steps.map((step, i) => (
|
|
19
19
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
|
20
20
|
<div style={{
|
|
21
|
-
fontSize: 9, fontWeight: 600, padding: '2px
|
|
21
|
+
fontSize: 9, fontWeight: 600, padding: '2px 6px',
|
|
22
22
|
background: i === steps.length - 1 ? `${color}18` : 'var(--bg-section)',
|
|
23
23
|
color: i === steps.length - 1 ? color : 'var(--text-dim)',
|
|
24
24
|
borderRadius: 4, whiteSpace: 'nowrap',
|
|
@@ -27,9 +27,7 @@ function FlowDiagram({ steps, color }: { steps: string[]; color: string }) {
|
|
|
27
27
|
{step}
|
|
28
28
|
</div>
|
|
29
29
|
{i < steps.length - 1 && (
|
|
30
|
-
<
|
|
31
|
-
<path d="M0 4h10M8 1l3 3-3 3" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" opacity="0.5" />
|
|
32
|
-
</svg>
|
|
30
|
+
<span style={{ fontSize: 9, color: 'var(--text-muted)', padding: '0 2px' }}>→</span>
|
|
33
31
|
)}
|
|
34
32
|
</div>
|
|
35
33
|
))}
|
|
@@ -25,14 +25,15 @@ type RunState = 'idle' | 'running' | 'success' | 'error' | 'stopped'
|
|
|
25
25
|
export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActionsProps) {
|
|
26
26
|
const [running, setRunning] = useState<string | null>(null)
|
|
27
27
|
const [state, setState] = useState<RunState>('idle')
|
|
28
|
-
const [output, setOutput] = useState<string
|
|
28
|
+
const [output, setOutput] = useState<string>('')
|
|
29
29
|
const abortRef = useRef<AbortController | null>(null)
|
|
30
|
+
const outputRef = useRef<HTMLPreElement | null>(null)
|
|
30
31
|
|
|
31
32
|
const handleRun = async (action: Action) => {
|
|
32
33
|
if (action.disabled) return
|
|
33
34
|
setRunning(action.id)
|
|
34
35
|
setState('running')
|
|
35
|
-
setOutput(
|
|
36
|
+
setOutput('')
|
|
36
37
|
|
|
37
38
|
const controller = new AbortController()
|
|
38
39
|
abortRef.current = controller
|
|
@@ -44,21 +45,76 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
|
|
|
44
45
|
body: JSON.stringify({ command: action.command }),
|
|
45
46
|
signal: controller.signal,
|
|
46
47
|
})
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
setOutput(
|
|
50
|
-
setState('
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
|
|
49
|
+
if (!res.body) {
|
|
50
|
+
setOutput('No response body')
|
|
51
|
+
setState('error')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reader = res.body.getReader()
|
|
56
|
+
const decoder = new TextDecoder()
|
|
57
|
+
let buffer = ''
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read()
|
|
61
|
+
if (done) break
|
|
62
|
+
|
|
63
|
+
buffer += decoder.decode(value, { stream: true })
|
|
64
|
+
|
|
65
|
+
// Parse SSE events from buffer
|
|
66
|
+
const events = buffer.split('\n\n')
|
|
67
|
+
buffer = events.pop() ?? '' // keep incomplete event
|
|
68
|
+
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
const lines = event.split('\n')
|
|
71
|
+
let eventType = ''
|
|
72
|
+
let eventData = ''
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (line.startsWith('event: ')) eventType = line.slice(7)
|
|
76
|
+
if (line.startsWith('data: ')) eventData = line.slice(6)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (eventType === 'output' && eventData) {
|
|
80
|
+
try {
|
|
81
|
+
const text = JSON.parse(eventData) as string
|
|
82
|
+
setOutput(prev => prev + text)
|
|
83
|
+
// Auto-scroll to bottom
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
if (outputRef.current) {
|
|
86
|
+
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
|
87
|
+
}
|
|
88
|
+
}, 10)
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (eventType === 'done' && eventData) {
|
|
93
|
+
try {
|
|
94
|
+
const result = JSON.parse(JSON.parse(eventData) as string) as { success: boolean; stopped?: boolean; timedOut?: boolean }
|
|
95
|
+
if (result.stopped) {
|
|
96
|
+
setOutput(prev => prev + '\n\n[Stopped by user]')
|
|
97
|
+
setState('stopped')
|
|
98
|
+
} else if (result.timedOut) {
|
|
99
|
+
setOutput(prev => prev + '\n\n[Timed out]')
|
|
100
|
+
setState('error')
|
|
101
|
+
} else {
|
|
102
|
+
setState(result.success ? 'success' : 'error')
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
54
107
|
}
|
|
108
|
+
|
|
109
|
+
// If we reach here without a 'done' event, mark as complete
|
|
110
|
+
if (state === 'running') setState('success')
|
|
55
111
|
} catch (err: unknown) {
|
|
56
112
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
57
113
|
try { await fetch('/api/run', { method: 'DELETE' }) } catch {}
|
|
58
|
-
setOutput('Stopped by user')
|
|
114
|
+
setOutput(prev => prev + '\n\n[Stopped by user]')
|
|
59
115
|
setState('stopped')
|
|
60
116
|
} else {
|
|
61
|
-
setOutput('Network error')
|
|
117
|
+
setOutput(prev => prev || 'Network error')
|
|
62
118
|
setState('error')
|
|
63
119
|
}
|
|
64
120
|
} finally {
|
|
@@ -227,7 +283,7 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
|
|
|
227
283
|
</div>
|
|
228
284
|
</div>
|
|
229
285
|
|
|
230
|
-
<pre style={{
|
|
286
|
+
<pre ref={outputRef} style={{
|
|
231
287
|
padding: '12px 14px', margin: 0,
|
|
232
288
|
fontSize: 11, lineHeight: 1.5,
|
|
233
289
|
fontFamily: 'var(--font-mono)',
|
|
@@ -235,7 +291,7 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
|
|
|
235
291
|
maxHeight: 400, overflow: 'auto',
|
|
236
292
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
|
237
293
|
}}>
|
|
238
|
-
{output
|
|
294
|
+
{output || (state === 'running' ? 'Starting...' : 'No output')}
|
|
239
295
|
</pre>
|
|
240
296
|
</div>
|
|
241
297
|
)}
|
package/package.json
CHANGED