groove-dev 0.8.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 +197 -0
- package/LICENSE +40 -0
- package/README.md +115 -0
- package/docs/GUI_DESIGN_SPEC.md +402 -0
- package/favicon.png +0 -0
- package/groove-logo-short.png +0 -0
- package/groove-logo.png +0 -0
- package/package.json +70 -0
- package/packages/cli/bin/groove.js +98 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/client.js +25 -0
- package/packages/cli/src/commands/agents.js +38 -0
- package/packages/cli/src/commands/approve.js +50 -0
- package/packages/cli/src/commands/config.js +35 -0
- package/packages/cli/src/commands/kill.js +15 -0
- package/packages/cli/src/commands/nuke.js +19 -0
- package/packages/cli/src/commands/providers.js +40 -0
- package/packages/cli/src/commands/rotate.js +16 -0
- package/packages/cli/src/commands/spawn.js +91 -0
- package/packages/cli/src/commands/start.js +31 -0
- package/packages/cli/src/commands/status.js +38 -0
- package/packages/cli/src/commands/stop.js +15 -0
- package/packages/cli/src/commands/team.js +77 -0
- package/packages/daemon/package.json +18 -0
- package/packages/daemon/src/adaptive.js +237 -0
- package/packages/daemon/src/api.js +533 -0
- package/packages/daemon/src/classifier.js +126 -0
- package/packages/daemon/src/credentials.js +121 -0
- package/packages/daemon/src/firstrun.js +93 -0
- package/packages/daemon/src/index.js +208 -0
- package/packages/daemon/src/introducer.js +238 -0
- package/packages/daemon/src/journalist.js +600 -0
- package/packages/daemon/src/lockmanager.js +58 -0
- package/packages/daemon/src/pm.js +108 -0
- package/packages/daemon/src/process.js +361 -0
- package/packages/daemon/src/providers/aider.js +72 -0
- package/packages/daemon/src/providers/base.js +38 -0
- package/packages/daemon/src/providers/claude-code.js +167 -0
- package/packages/daemon/src/providers/codex.js +68 -0
- package/packages/daemon/src/providers/gemini.js +62 -0
- package/packages/daemon/src/providers/index.js +38 -0
- package/packages/daemon/src/providers/ollama.js +94 -0
- package/packages/daemon/src/registry.js +89 -0
- package/packages/daemon/src/rotator.js +185 -0
- package/packages/daemon/src/router.js +132 -0
- package/packages/daemon/src/state.js +34 -0
- package/packages/daemon/src/supervisor.js +178 -0
- package/packages/daemon/src/teams.js +203 -0
- package/packages/daemon/src/terminal/base.js +27 -0
- package/packages/daemon/src/terminal/generic.js +27 -0
- package/packages/daemon/src/terminal/tmux.js +64 -0
- package/packages/daemon/src/tokentracker.js +124 -0
- package/packages/daemon/src/validate.js +122 -0
- package/packages/daemon/templates/api-builder.json +18 -0
- package/packages/daemon/templates/fullstack.json +18 -0
- package/packages/daemon/templates/monorepo.json +24 -0
- package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
- package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
- package/packages/gui/dist/favicon.png +0 -0
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/dist/index.html +13 -0
- package/packages/gui/index.html +12 -0
- package/packages/gui/package.json +22 -0
- package/packages/gui/public/favicon.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/App.jsx +215 -0
- package/packages/gui/src/components/AgentActions.jsx +347 -0
- package/packages/gui/src/components/AgentChat.jsx +479 -0
- package/packages/gui/src/components/AgentNode.jsx +117 -0
- package/packages/gui/src/components/AgentPanel.jsx +115 -0
- package/packages/gui/src/components/AgentStats.jsx +333 -0
- package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
- package/packages/gui/src/components/EmptyState.jsx +100 -0
- package/packages/gui/src/components/SpawnPanel.jsx +515 -0
- package/packages/gui/src/components/TeamSelector.jsx +162 -0
- package/packages/gui/src/main.jsx +9 -0
- package/packages/gui/src/stores/groove.js +247 -0
- package/packages/gui/src/theme.css +67 -0
- package/packages/gui/src/views/AgentTree.jsx +148 -0
- package/packages/gui/src/views/CommandCenter.jsx +620 -0
- package/packages/gui/src/views/JournalistFeed.jsx +149 -0
- package/packages/gui/vite.config.js +19 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// GROOVE GUI — Teams View (full-area, rendered in main content)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import React, { useState, useEffect } from 'react';
|
|
5
|
+
import { useGrooveStore } from '../stores/groove';
|
|
6
|
+
|
|
7
|
+
export default function TeamSelector() {
|
|
8
|
+
const [teams, setTeams] = useState([]);
|
|
9
|
+
const [activeTeam, setActiveTeam] = useState(null);
|
|
10
|
+
const [saving, setSaving] = useState(false);
|
|
11
|
+
const [saveName, setSaveName] = useState('');
|
|
12
|
+
const showStatus = useGrooveStore((s) => s.showStatus);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
fetchTeams();
|
|
16
|
+
const interval = setInterval(fetchTeams, 5000);
|
|
17
|
+
return () => clearInterval(interval);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
async function fetchTeams() {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch('/api/teams');
|
|
23
|
+
const data = await res.json();
|
|
24
|
+
setTeams(data.teams);
|
|
25
|
+
setActiveTeam(data.activeTeam);
|
|
26
|
+
} catch { /* ignore */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function handleLoad(name) {
|
|
30
|
+
try {
|
|
31
|
+
await fetch(`/api/teams/${encodeURIComponent(name)}/load`, { method: 'POST' });
|
|
32
|
+
showStatus(`loaded team "${name}"`);
|
|
33
|
+
fetchTeams();
|
|
34
|
+
} catch {
|
|
35
|
+
showStatus('failed to load team');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function handleSave() {
|
|
40
|
+
if (!saveName.trim()) return;
|
|
41
|
+
try {
|
|
42
|
+
await fetch('/api/teams', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ name: saveName.trim() }),
|
|
46
|
+
});
|
|
47
|
+
showStatus(`saved team "${saveName}"`);
|
|
48
|
+
setSaveName('');
|
|
49
|
+
setSaving(false);
|
|
50
|
+
fetchTeams();
|
|
51
|
+
} catch {
|
|
52
|
+
showStatus('failed to save team');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handleDelete(name) {
|
|
57
|
+
await fetch(`/api/teams/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
58
|
+
showStatus(`deleted "${name}"`);
|
|
59
|
+
fetchTeams();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div style={styles.container}>
|
|
64
|
+
<div style={styles.title}>SAVED TEAMS</div>
|
|
65
|
+
|
|
66
|
+
{teams.length === 0 ? (
|
|
67
|
+
<div style={styles.empty}>No saved teams</div>
|
|
68
|
+
) : (
|
|
69
|
+
teams.map((t) => (
|
|
70
|
+
<div
|
|
71
|
+
key={t.name}
|
|
72
|
+
style={{
|
|
73
|
+
...styles.item,
|
|
74
|
+
background: t.name === activeTeam ? 'var(--bg-hover)' : 'transparent',
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div style={{ flex: 1 }}>
|
|
78
|
+
<div style={styles.teamName}>{'>'} {t.name}</div>
|
|
79
|
+
<div style={styles.teamMeta}>{t.agents} agents</div>
|
|
80
|
+
</div>
|
|
81
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
82
|
+
<button onClick={() => handleLoad(t.name)} style={styles.loadBtn}>Load</button>
|
|
83
|
+
<button onClick={() => handleDelete(t.name)} style={styles.deleteBtn}>x</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
))
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<div style={styles.divider} />
|
|
90
|
+
|
|
91
|
+
{saving ? (
|
|
92
|
+
<div style={styles.saveRow}>
|
|
93
|
+
<input
|
|
94
|
+
style={styles.saveInput}
|
|
95
|
+
placeholder="Team name..."
|
|
96
|
+
value={saveName}
|
|
97
|
+
onChange={(e) => setSaveName(e.target.value)}
|
|
98
|
+
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
|
99
|
+
autoFocus
|
|
100
|
+
/>
|
|
101
|
+
<button onClick={handleSave} style={styles.saveBtn}>Save</button>
|
|
102
|
+
<button onClick={() => setSaving(false)} style={styles.cancelBtn}>x</button>
|
|
103
|
+
</div>
|
|
104
|
+
) : (
|
|
105
|
+
<button onClick={() => setSaving(true)} style={styles.saveTeamBtn}>
|
|
106
|
+
Save Current as Team
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const styles = {
|
|
114
|
+
container: {
|
|
115
|
+
padding: 24, maxWidth: 600, margin: '0 auto',
|
|
116
|
+
},
|
|
117
|
+
title: {
|
|
118
|
+
fontSize: 11, fontWeight: 600, color: 'var(--text-dim)',
|
|
119
|
+
textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 16,
|
|
120
|
+
},
|
|
121
|
+
item: {
|
|
122
|
+
padding: '10px 12px',
|
|
123
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
124
|
+
borderBottom: '1px solid var(--border)',
|
|
125
|
+
},
|
|
126
|
+
teamName: { fontSize: 12, color: 'var(--text-bright)', fontWeight: 600 },
|
|
127
|
+
teamMeta: { fontSize: 11, color: 'var(--text-dim)', marginTop: 2 },
|
|
128
|
+
loadBtn: {
|
|
129
|
+
background: 'transparent', border: 'none',
|
|
130
|
+
color: 'var(--accent)', fontSize: 11, cursor: 'pointer',
|
|
131
|
+
fontFamily: 'var(--font)', fontWeight: 600,
|
|
132
|
+
},
|
|
133
|
+
deleteBtn: {
|
|
134
|
+
background: 'none', border: 'none', color: 'var(--text-dim)',
|
|
135
|
+
fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font)',
|
|
136
|
+
},
|
|
137
|
+
empty: { padding: 20, color: 'var(--text-dim)', fontSize: 12, textAlign: 'center' },
|
|
138
|
+
divider: { borderTop: '1px solid var(--border)', margin: '12px 0' },
|
|
139
|
+
saveTeamBtn: {
|
|
140
|
+
background: 'transparent', border: '1px solid var(--accent)',
|
|
141
|
+
borderRadius: 2, padding: '6px 14px',
|
|
142
|
+
color: 'var(--accent)', fontSize: 12, cursor: 'pointer',
|
|
143
|
+
fontFamily: 'var(--font)', fontWeight: 600,
|
|
144
|
+
},
|
|
145
|
+
saveRow: {
|
|
146
|
+
display: 'flex', gap: 6, alignItems: 'center',
|
|
147
|
+
},
|
|
148
|
+
saveInput: {
|
|
149
|
+
flex: 1, background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
|
150
|
+
borderRadius: 2, padding: '6px 8px', color: 'var(--text-primary)', fontSize: 12,
|
|
151
|
+
fontFamily: 'var(--font)', outline: 'none',
|
|
152
|
+
},
|
|
153
|
+
saveBtn: {
|
|
154
|
+
padding: '6px 12px', background: 'transparent', border: '1px solid var(--accent)',
|
|
155
|
+
borderRadius: 2, color: 'var(--accent)', fontSize: 12, cursor: 'pointer',
|
|
156
|
+
fontFamily: 'var(--font)',
|
|
157
|
+
},
|
|
158
|
+
cancelBtn: {
|
|
159
|
+
background: 'none', border: 'none', color: 'var(--text-dim)',
|
|
160
|
+
fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font)',
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// GROOVE GUI — Zustand Store + WebSocket
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { create } from 'zustand';
|
|
5
|
+
|
|
6
|
+
const WS_URL = `ws://${window.location.hostname}:${window.location.port || 31415}`;
|
|
7
|
+
const API_BASE = '';
|
|
8
|
+
|
|
9
|
+
export const useGrooveStore = create((set, get) => ({
|
|
10
|
+
// Connection state
|
|
11
|
+
agents: [],
|
|
12
|
+
connected: false,
|
|
13
|
+
ws: null,
|
|
14
|
+
|
|
15
|
+
// UI state — unified panel model
|
|
16
|
+
activeTab: 'agents', // 'agents' | 'stats' | 'teams' | 'approvals'
|
|
17
|
+
detailPanel: null, // null | { type: 'agent', agentId } | { type: 'spawn' } | { type: 'journalist' }
|
|
18
|
+
activityLog: {},
|
|
19
|
+
statusMessage: null, // inline status text (replaces toast notifications)
|
|
20
|
+
commandHistory: [], // last 50 commands for command bar
|
|
21
|
+
chatHistory: {}, // { [agentId]: [{ from, text, timestamp, isQuery }] }
|
|
22
|
+
tokenTimeline: {}, // { [agentId]: [{ t: timestamp, v: tokensUsed }] }
|
|
23
|
+
dashTelemetry: {}, // { [agentId]: [{ t, v, name }] } — persists across tab switches
|
|
24
|
+
|
|
25
|
+
// Connection
|
|
26
|
+
connect() {
|
|
27
|
+
if (get().ws) return;
|
|
28
|
+
|
|
29
|
+
const ws = new WebSocket(WS_URL);
|
|
30
|
+
|
|
31
|
+
ws.onopen = () => set({ connected: true, ws });
|
|
32
|
+
|
|
33
|
+
ws.onmessage = (event) => {
|
|
34
|
+
const msg = JSON.parse(event.data);
|
|
35
|
+
|
|
36
|
+
switch (msg.type) {
|
|
37
|
+
case 'state': {
|
|
38
|
+
// Track token timeline for live charts
|
|
39
|
+
const timeline = { ...get().tokenTimeline };
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const agent of msg.data) {
|
|
42
|
+
if (!timeline[agent.id]) timeline[agent.id] = [];
|
|
43
|
+
const arr = timeline[agent.id];
|
|
44
|
+
const last = arr[arr.length - 1];
|
|
45
|
+
// Only record if tokens changed or every 5s for heartbeat
|
|
46
|
+
if (!last || agent.tokensUsed !== last.v || now - last.t > 5000) {
|
|
47
|
+
arr.push({ t: now, v: agent.tokensUsed || 0 });
|
|
48
|
+
// Keep last 200 points
|
|
49
|
+
if (arr.length > 200) timeline[agent.id] = arr.slice(-200);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
set({ agents: msg.data, tokenTimeline: timeline });
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'agent:output': {
|
|
57
|
+
const { agentId, data } = msg;
|
|
58
|
+
const log = { ...get().activityLog };
|
|
59
|
+
if (!log[agentId]) log[agentId] = [];
|
|
60
|
+
log[agentId] = [...log[agentId].slice(-200), {
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
text: typeof data.data === 'string' ? data.data : JSON.stringify(data.data),
|
|
63
|
+
type: data.type,
|
|
64
|
+
}];
|
|
65
|
+
set({ activityLog: log });
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'agent:exit': {
|
|
70
|
+
const agent = get().agents.find((a) => a.id === msg.agentId);
|
|
71
|
+
const name = agent?.name || msg.agentId;
|
|
72
|
+
const text = msg.status === 'completed' ? `${name} completed`
|
|
73
|
+
: msg.status === 'killed' ? `${name} killed`
|
|
74
|
+
: `${name} crashed (exit ${msg.code})`;
|
|
75
|
+
get().showStatus(text);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case 'rotation:start':
|
|
80
|
+
get().showStatus(`rotating ${msg.agentName}...`);
|
|
81
|
+
break;
|
|
82
|
+
|
|
83
|
+
case 'rotation:complete': {
|
|
84
|
+
get().showStatus(`rotated ${msg.agentName} (saved ${msg.tokensSaved} tokens)`);
|
|
85
|
+
const panel = get().detailPanel;
|
|
86
|
+
if (panel?.type === 'agent' && panel.agentId === msg.oldAgentId && msg.newAgentId) {
|
|
87
|
+
// Copy chat history and timeline BEFORE switching to new agent
|
|
88
|
+
// (this fires before the HTTP response in instructAgent, preventing empty chat)
|
|
89
|
+
set((s) => {
|
|
90
|
+
const chatHistory = { ...s.chatHistory };
|
|
91
|
+
const tokenTimeline = { ...s.tokenTimeline };
|
|
92
|
+
const oldChat = chatHistory[msg.oldAgentId] || [];
|
|
93
|
+
const oldTimeline = tokenTimeline[msg.oldAgentId] || [];
|
|
94
|
+
if (oldChat.length > 0) chatHistory[msg.newAgentId] = [...oldChat];
|
|
95
|
+
if (oldTimeline.length > 0) tokenTimeline[msg.newAgentId] = [...oldTimeline];
|
|
96
|
+
return {
|
|
97
|
+
chatHistory,
|
|
98
|
+
tokenTimeline,
|
|
99
|
+
detailPanel: { type: 'agent', agentId: msg.newAgentId },
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'rotation:failed':
|
|
107
|
+
get().showStatus(`rotation failed: ${msg.error}`);
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case 'journalist:cycle':
|
|
111
|
+
break; // Journalist feed polls separately
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
ws.onclose = () => {
|
|
116
|
+
set({ connected: false, ws: null });
|
|
117
|
+
setTimeout(() => get().connect(), 2000);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
ws.onerror = () => ws.close();
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// Agent actions
|
|
124
|
+
async spawnAgent(config) {
|
|
125
|
+
const res = await fetch(`${API_BASE}/api/agents`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
body: JSON.stringify(config),
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const err = await res.json().catch(() => ({}));
|
|
132
|
+
throw new Error(err.error || 'Spawn failed');
|
|
133
|
+
}
|
|
134
|
+
const agent = await res.json();
|
|
135
|
+
get().showStatus(`spawned ${agent.name}`);
|
|
136
|
+
return agent;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async killAgent(id, purge = false) {
|
|
140
|
+
await fetch(`${API_BASE}/api/agents/${id}?purge=${purge}`, { method: 'DELETE' });
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async rotateAgent(id) {
|
|
144
|
+
const res = await fetch(`${API_BASE}/api/agents/${id}/rotate`, { method: 'POST' });
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const err = await res.json().catch(() => ({}));
|
|
147
|
+
throw new Error(err.error || 'Rotation failed');
|
|
148
|
+
}
|
|
149
|
+
return res.json();
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async fetchProviders() {
|
|
153
|
+
const res = await fetch(`${API_BASE}/api/providers`);
|
|
154
|
+
return res.json();
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
// UI actions — unified panel control
|
|
158
|
+
setActiveTab(tab) { set({ activeTab: tab }); },
|
|
159
|
+
|
|
160
|
+
openDetail(descriptor) { set({ detailPanel: descriptor }); },
|
|
161
|
+
closeDetail() { set({ detailPanel: null }); },
|
|
162
|
+
|
|
163
|
+
selectAgent(id) { set({ detailPanel: { type: 'agent', agentId: id } }); },
|
|
164
|
+
clearSelection() { set({ detailPanel: null }); },
|
|
165
|
+
|
|
166
|
+
showStatus(text) {
|
|
167
|
+
set({ statusMessage: text });
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (get().statusMessage === text) set({ statusMessage: null });
|
|
170
|
+
}, 4000);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// Agent interaction
|
|
174
|
+
addChatMessage(agentId, from, text, isQuery = false) {
|
|
175
|
+
set((s) => {
|
|
176
|
+
const history = { ...s.chatHistory };
|
|
177
|
+
if (!history[agentId]) history[agentId] = [];
|
|
178
|
+
history[agentId] = [...history[agentId].slice(-100), {
|
|
179
|
+
from, text, timestamp: Date.now(), isQuery,
|
|
180
|
+
}];
|
|
181
|
+
return { chatHistory: history };
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async instructAgent(id, message) {
|
|
186
|
+
const agent = get().agents.find((a) => a.id === id);
|
|
187
|
+
const isAlive = agent && (agent.status === 'running' || agent.status === 'starting');
|
|
188
|
+
|
|
189
|
+
get().addChatMessage(id, 'user', message, false);
|
|
190
|
+
get().addChatMessage(id, 'system', isAlive ? 'sending instruction...' : 'continuing conversation...');
|
|
191
|
+
const res = await fetch(`${API_BASE}/api/agents/${id}/instruct`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({ message }),
|
|
195
|
+
});
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const err = await res.json().catch(() => ({}));
|
|
198
|
+
get().addChatMessage(id, 'system', `failed: ${err.error || 'unknown error'}`);
|
|
199
|
+
throw new Error(err.error || 'Instruction failed');
|
|
200
|
+
}
|
|
201
|
+
const newAgent = await res.json();
|
|
202
|
+
// Carry chat history from old agent to new (same conversation, new ID)
|
|
203
|
+
const oldChat = get().chatHistory[id] || [];
|
|
204
|
+
if (oldChat.length > 0) {
|
|
205
|
+
set((s) => {
|
|
206
|
+
const history = { ...s.chatHistory };
|
|
207
|
+
history[newAgent.id] = [...oldChat];
|
|
208
|
+
return { chatHistory: history };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Also carry token timeline for continuity in stats
|
|
212
|
+
const oldTimeline = get().tokenTimeline[id] || [];
|
|
213
|
+
if (oldTimeline.length > 0) {
|
|
214
|
+
set((s) => {
|
|
215
|
+
const timeline = { ...s.tokenTimeline };
|
|
216
|
+
timeline[newAgent.id] = [...oldTimeline];
|
|
217
|
+
return { tokenTimeline: timeline };
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
get().selectAgent(newAgent.id);
|
|
221
|
+
get().addChatMessage(newAgent.id, 'system', 'agent resumed with context');
|
|
222
|
+
return newAgent;
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async queryAgent(id, message) {
|
|
226
|
+
get().addChatMessage(id, 'user', message, true);
|
|
227
|
+
const res = await fetch(`${API_BASE}/api/agents/${id}/query`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({ message }),
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) {
|
|
233
|
+
const err = await res.json().catch(() => ({}));
|
|
234
|
+
get().addChatMessage(id, 'system', `query failed: ${err.error || 'unknown error'}`);
|
|
235
|
+
throw new Error(err.error || 'Query failed');
|
|
236
|
+
}
|
|
237
|
+
const data = await res.json();
|
|
238
|
+
get().addChatMessage(id, 'agent', data.response);
|
|
239
|
+
return data;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
addCommand(text) {
|
|
243
|
+
set((s) => ({
|
|
244
|
+
commandHistory: [...s.commandHistory.slice(-49), text],
|
|
245
|
+
}));
|
|
246
|
+
},
|
|
247
|
+
}));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* GROOVE GUI Theme — One Dark Pro
|
|
2
|
+
FSL-1.1-Apache-2.0 — see LICENSE */
|
|
3
|
+
|
|
4
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap');
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
/* Backgrounds */
|
|
8
|
+
--bg-base: #24282f;
|
|
9
|
+
--bg-chrome: #282c34;
|
|
10
|
+
--bg-surface: #2c313a;
|
|
11
|
+
--bg-hover: #333842;
|
|
12
|
+
--bg-active: #3a3f4b;
|
|
13
|
+
|
|
14
|
+
/* Text */
|
|
15
|
+
--text-primary: #abb2bf;
|
|
16
|
+
--text-bright: #e6e6e6;
|
|
17
|
+
--text-dim: #5c6370;
|
|
18
|
+
--text-muted: #3e4451;
|
|
19
|
+
--border: #4b5263;
|
|
20
|
+
|
|
21
|
+
/* Status */
|
|
22
|
+
--accent: #33afbc;
|
|
23
|
+
--green: #4ae168;
|
|
24
|
+
--amber: #e5c07b;
|
|
25
|
+
--red: #e06c75;
|
|
26
|
+
--purple: #c678dd;
|
|
27
|
+
--blue: #61afef;
|
|
28
|
+
|
|
29
|
+
/* Typography */
|
|
30
|
+
--font: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
34
|
+
|
|
35
|
+
body {
|
|
36
|
+
font-family: var(--font);
|
|
37
|
+
background: var(--bg-base);
|
|
38
|
+
color: var(--text-primary);
|
|
39
|
+
font-size: 12px;
|
|
40
|
+
-webkit-font-smoothing: antialiased;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#root { width: 100vw; height: 100vh; }
|
|
44
|
+
|
|
45
|
+
::-webkit-scrollbar { width: 6px; }
|
|
46
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
47
|
+
::-webkit-scrollbar-thumb { background: var(--bg-hover); border-radius: 2px; }
|
|
48
|
+
::selection { background: rgba(51, 175, 188, 0.25); }
|
|
49
|
+
|
|
50
|
+
@keyframes pulse {
|
|
51
|
+
0%, 100% { opacity: 1; }
|
|
52
|
+
50% { opacity: 0.5; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@keyframes neuralFlow {
|
|
56
|
+
0% { transform: translateX(-50%); }
|
|
57
|
+
100% { transform: translateX(0%); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Thin animated edges for React Flow */
|
|
61
|
+
.react-flow__edge-path {
|
|
62
|
+
stroke-width: 1 !important;
|
|
63
|
+
}
|
|
64
|
+
.react-flow__edge.animated path {
|
|
65
|
+
stroke-dasharray: 6 4 !important;
|
|
66
|
+
stroke-width: 1 !important;
|
|
67
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// GROOVE GUI — Agent Tree View (React Flow)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
|
5
|
+
import { ReactFlow, Background, useReactFlow, ReactFlowProvider } from '@xyflow/react';
|
|
6
|
+
import '@xyflow/react/dist/style.css';
|
|
7
|
+
import { useGrooveStore } from '../stores/groove';
|
|
8
|
+
import AgentNode from '../components/AgentNode';
|
|
9
|
+
|
|
10
|
+
const nodeTypes = { agent: AgentNode };
|
|
11
|
+
|
|
12
|
+
const MAX_PER_ROW = 4;
|
|
13
|
+
const NODE_X_SPACING = 220;
|
|
14
|
+
const NODE_Y_SPACING = 140;
|
|
15
|
+
|
|
16
|
+
function AgentTreeInner() {
|
|
17
|
+
const agents = useGrooveStore((s) => s.agents);
|
|
18
|
+
const detailPanel = useGrooveStore((s) => s.detailPanel);
|
|
19
|
+
const selectAgent = useGrooveStore((s) => s.selectAgent);
|
|
20
|
+
const clearSelection = useGrooveStore((s) => s.clearSelection);
|
|
21
|
+
const { fitView } = useReactFlow();
|
|
22
|
+
|
|
23
|
+
const selectedAgentId = detailPanel?.type === 'agent' ? detailPanel.agentId : null;
|
|
24
|
+
const prevCountRef = useRef(0);
|
|
25
|
+
|
|
26
|
+
const { nodes, edges } = useMemo(() => {
|
|
27
|
+
const running = agents.filter((a) => a.status === 'running' || a.status === 'starting');
|
|
28
|
+
const done = agents.filter((a) => a.status !== 'running' && a.status !== 'starting');
|
|
29
|
+
|
|
30
|
+
const runningNodes = running.map((agent, i) => ({
|
|
31
|
+
id: agent.id,
|
|
32
|
+
type: 'agent',
|
|
33
|
+
position: {
|
|
34
|
+
x: (i % MAX_PER_ROW) * NODE_X_SPACING,
|
|
35
|
+
y: 80 + Math.floor(i / MAX_PER_ROW) * NODE_Y_SPACING,
|
|
36
|
+
},
|
|
37
|
+
data: { ...agent, selected: agent.id === selectedAgentId },
|
|
38
|
+
draggable: true,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const runningRows = Math.ceil(running.length / MAX_PER_ROW) || 1;
|
|
42
|
+
const doneStartY = 80 + runningRows * NODE_Y_SPACING + 50;
|
|
43
|
+
|
|
44
|
+
const doneNodes = done.map((agent, i) => ({
|
|
45
|
+
id: agent.id,
|
|
46
|
+
type: 'agent',
|
|
47
|
+
position: {
|
|
48
|
+
x: (i % MAX_PER_ROW) * NODE_X_SPACING,
|
|
49
|
+
y: doneStartY + Math.floor(i / MAX_PER_ROW) * NODE_Y_SPACING,
|
|
50
|
+
},
|
|
51
|
+
data: { ...agent, selected: agent.id === selectedAgentId },
|
|
52
|
+
draggable: true,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const allAgentNodes = [...runningNodes, ...doneNodes];
|
|
56
|
+
|
|
57
|
+
const maxPerRow = Math.min(Math.max(running.length, done.length, 1), MAX_PER_ROW);
|
|
58
|
+
const totalWidth = maxPerRow * NODE_X_SPACING;
|
|
59
|
+
|
|
60
|
+
// GROOVE root node — clean, rounded, matching
|
|
61
|
+
const grooveNode = {
|
|
62
|
+
id: 'groove-root',
|
|
63
|
+
type: 'default',
|
|
64
|
+
position: { x: (totalWidth - NODE_X_SPACING) / 2 + 25, y: 0 },
|
|
65
|
+
data: { label: 'GROOVE' },
|
|
66
|
+
selectable: false,
|
|
67
|
+
draggable: false,
|
|
68
|
+
style: {
|
|
69
|
+
background: '#282c34',
|
|
70
|
+
color: '#33afbc',
|
|
71
|
+
border: '1px solid #33afbc',
|
|
72
|
+
borderRadius: 8,
|
|
73
|
+
fontWeight: 800,
|
|
74
|
+
fontSize: 10,
|
|
75
|
+
letterSpacing: 4,
|
|
76
|
+
padding: '8px 20px 7px',
|
|
77
|
+
fontFamily: "'JetBrains Mono', 'SF Mono', Consolas, monospace",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Bezier spline edges — the brand
|
|
82
|
+
const edges = allAgentNodes.map((node) => {
|
|
83
|
+
const agent = agents.find((a) => a.id === node.id);
|
|
84
|
+
const isRunning = agent?.status === 'running';
|
|
85
|
+
return {
|
|
86
|
+
id: `groove-${node.id}`,
|
|
87
|
+
source: 'groove-root',
|
|
88
|
+
target: node.id,
|
|
89
|
+
type: 'default', // Bezier curve (spline)
|
|
90
|
+
style: {
|
|
91
|
+
stroke: isRunning ? '#5c6370' : '#2c313a',
|
|
92
|
+
strokeWidth: 1,
|
|
93
|
+
},
|
|
94
|
+
animated: isRunning,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { nodes: [grooveNode, ...allAgentNodes], edges };
|
|
99
|
+
}, [agents, selectedAgentId]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const currentCount = agents.length;
|
|
103
|
+
if (currentCount !== prevCountRef.current) {
|
|
104
|
+
setTimeout(() => fitView({ padding: 0.3, maxZoom: 1.4, duration: 200 }), 50);
|
|
105
|
+
}
|
|
106
|
+
prevCountRef.current = currentCount;
|
|
107
|
+
}, [agents.length, fitView]);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
setTimeout(() => fitView({ padding: 0.3, maxZoom: 1.4, duration: 0 }), 100);
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const onNodeClick = useCallback((event, node) => {
|
|
114
|
+
if (node.id === 'groove-root') return;
|
|
115
|
+
selectAgent(node.id);
|
|
116
|
+
}, [selectAgent]);
|
|
117
|
+
|
|
118
|
+
const onPaneClick = useCallback(() => {
|
|
119
|
+
clearSelection();
|
|
120
|
+
}, [clearSelection]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<ReactFlow
|
|
124
|
+
nodes={nodes}
|
|
125
|
+
edges={edges}
|
|
126
|
+
nodeTypes={nodeTypes}
|
|
127
|
+
onNodeClick={onNodeClick}
|
|
128
|
+
onPaneClick={onPaneClick}
|
|
129
|
+
proOptions={{ hideAttribution: true }}
|
|
130
|
+
nodesConnectable={false}
|
|
131
|
+
elementsSelectable={true}
|
|
132
|
+
minZoom={0.3}
|
|
133
|
+
maxZoom={2}
|
|
134
|
+
fitView
|
|
135
|
+
fitViewOptions={{ padding: 0.3, maxZoom: 1.4 }}
|
|
136
|
+
>
|
|
137
|
+
<Background color="#3e4451" gap={20} size={1} />
|
|
138
|
+
</ReactFlow>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default function AgentTree() {
|
|
143
|
+
return (
|
|
144
|
+
<ReactFlowProvider>
|
|
145
|
+
<AgentTreeInner />
|
|
146
|
+
</ReactFlowProvider>
|
|
147
|
+
);
|
|
148
|
+
}
|