groove-dev 0.27.142 → 0.27.144

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.
Files changed (187) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
  4. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
  7. package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
  8. package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
  9. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  10. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  11. package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
  12. package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
  13. package/node_modules/@groove-dev/daemon/src/routes/agents.js +889 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
  15. package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
  16. package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
  17. package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
  18. package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
  19. package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
  21. package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
  22. package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
  23. package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
  24. package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
  25. package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
  26. package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
  27. package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
  28. package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
  29. package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  31. package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  32. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  33. package/node_modules/@groove-dev/gui/package.json +1 -1
  34. package/{packages/gui/src/app.jsx → node_modules/@groove-dev/gui/src/App.jsx} +0 -2
  35. package/node_modules/@groove-dev/gui/src/app.css +35 -0
  36. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
  37. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +144 -31
  38. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
  39. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
  40. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
  41. package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
  42. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
  43. package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
  44. package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
  45. package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
  46. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
  47. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
  48. package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  49. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
  50. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
  51. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
  52. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  53. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
  54. package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
  55. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
  56. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
  57. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +195 -14
  58. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +286 -102
  59. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
  60. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
  61. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
  62. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
  63. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
  64. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
  65. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
  66. package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
  67. package/node_modules/@groove-dev/gui/src/lib/status.js +24 -24
  68. package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
  69. package/node_modules/@groove-dev/gui/src/stores/groove.js +34 -3144
  70. package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
  71. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +452 -0
  72. package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
  73. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +227 -0
  74. package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
  75. package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
  76. package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
  77. package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
  78. package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
  79. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
  80. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
  81. package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
  82. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
  83. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
  84. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +17 -6
  85. package/node_modules/@groove-dev/gui/src/views/models.jsx +410 -509
  86. package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
  87. package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
  88. package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
  89. package/package.json +1 -1
  90. package/packages/cli/package.json +1 -1
  91. package/packages/daemon/package.json +1 -1
  92. package/packages/daemon/src/api.js +1086 -6532
  93. package/packages/daemon/src/gateways/manager.js +35 -1
  94. package/packages/daemon/src/index.js +3 -0
  95. package/packages/daemon/src/journalist.js +23 -13
  96. package/packages/daemon/src/mlx-server.js +365 -0
  97. package/packages/daemon/src/model-lab.js +308 -12
  98. package/packages/daemon/src/pm.js +1 -1
  99. package/packages/daemon/src/process.js +2 -2
  100. package/packages/daemon/src/providers/local.js +36 -8
  101. package/packages/daemon/src/registry.js +21 -5
  102. package/packages/daemon/src/routes/agents.js +889 -0
  103. package/packages/daemon/src/routes/coordination.js +318 -0
  104. package/packages/daemon/src/routes/files.js +751 -0
  105. package/packages/daemon/src/routes/integrations.js +485 -0
  106. package/packages/daemon/src/routes/network.js +1784 -0
  107. package/packages/daemon/src/routes/providers.js +755 -0
  108. package/packages/daemon/src/routes/schedules.js +110 -0
  109. package/packages/daemon/src/routes/teams.js +650 -0
  110. package/packages/daemon/src/scheduler.js +456 -24
  111. package/packages/daemon/src/teams.js +1 -1
  112. package/packages/daemon/src/validate.js +38 -1
  113. package/packages/daemon/templates/mlx-setup.json +12 -0
  114. package/packages/daemon/templates/tgi-setup.json +1 -1
  115. package/packages/daemon/templates/vllm-setup.json +1 -1
  116. package/packages/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  117. package/packages/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  118. package/packages/gui/dist/index.html +2 -2
  119. package/packages/gui/package.json +1 -1
  120. package/{node_modules/@groove-dev/gui/src/app.jsx → packages/gui/src/App.jsx} +0 -2
  121. package/packages/gui/src/app.css +35 -0
  122. package/packages/gui/src/components/agents/agent-config.jsx +1 -128
  123. package/packages/gui/src/components/agents/agent-feed.jsx +144 -31
  124. package/packages/gui/src/components/agents/agent-node.jsx +8 -13
  125. package/packages/gui/src/components/agents/code-review.jsx +159 -122
  126. package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
  127. package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
  128. package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
  129. package/packages/gui/src/components/automations/automation-card.jsx +274 -0
  130. package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
  131. package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
  132. package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
  133. package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
  134. package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  135. package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
  136. package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
  137. package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
  138. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  139. package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
  140. package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
  141. package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
  142. package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
  143. package/packages/gui/src/components/lab/parameter-panel.jsx +195 -14
  144. package/packages/gui/src/components/lab/runtime-config.jsx +286 -102
  145. package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
  146. package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
  147. package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
  148. package/packages/gui/src/components/network/network-health.jsx +2 -2
  149. package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
  150. package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
  151. package/packages/gui/src/components/ui/sheet.jsx +5 -2
  152. package/packages/gui/src/lib/cron.js +64 -0
  153. package/packages/gui/src/lib/status.js +24 -24
  154. package/packages/gui/src/lib/theme-hex.js +1 -0
  155. package/packages/gui/src/stores/groove.js +34 -3144
  156. package/packages/gui/src/stores/helpers.js +10 -0
  157. package/packages/gui/src/stores/slices/agents-slice.js +452 -0
  158. package/packages/gui/src/stores/slices/automations-slice.js +96 -0
  159. package/packages/gui/src/stores/slices/chat-slice.js +227 -0
  160. package/packages/gui/src/stores/slices/editor-slice.js +285 -0
  161. package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
  162. package/packages/gui/src/stores/slices/network-slice.js +361 -0
  163. package/packages/gui/src/stores/slices/preview-slice.js +109 -0
  164. package/packages/gui/src/stores/slices/providers-slice.js +897 -0
  165. package/packages/gui/src/stores/slices/teams-slice.js +413 -0
  166. package/packages/gui/src/stores/slices/ui-slice.js +98 -0
  167. package/packages/gui/src/views/agents.jsx +5 -5
  168. package/packages/gui/src/views/dashboard.jsx +12 -13
  169. package/packages/gui/src/views/marketplace.jsx +191 -3
  170. package/packages/gui/src/views/model-lab.jsx +17 -6
  171. package/packages/gui/src/views/models.jsx +410 -509
  172. package/packages/gui/src/views/network.jsx +3 -3
  173. package/packages/gui/src/views/settings.jsx +81 -94
  174. package/packages/gui/src/views/teams.jsx +40 -483
  175. package/SECURITY_SWEEP.md +0 -228
  176. package/TRAINING_DATA_v4.md +0 -6
  177. package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +0 -984
  178. package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +0 -1
  179. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -322
  180. package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
  181. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
  182. package/packages/gui/dist/assets/index-Bjd91ufV.js +0 -984
  183. package/packages/gui/dist/assets/index-BqdwIFn4.css +0 -1
  184. package/packages/gui/src/components/agents/agent-chat.jsx +0 -322
  185. package/packages/gui/src/views/preview.jsx +0 -6
  186. package/packages/gui/src/views/subscription-panel.jsx +0 -327
  187. package/test.py +0 -571
@@ -1,262 +1,57 @@
1
- // GROOVE GUI v2 — Zustand Store
1
+ // GROOVE GUI v2 — Zustand Store (composed from slices)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import { create } from 'zustand';
5
5
  import { api } from '../lib/api';
6
+ import { persistJSON } from './helpers.js';
7
+ import { createUiSlice } from './slices/ui-slice.js';
8
+ import { createAgentsSlice } from './slices/agents-slice.js';
9
+ import { createTeamsSlice } from './slices/teams-slice.js';
10
+ import { createChatSlice } from './slices/chat-slice.js';
11
+ import { createEditorSlice } from './slices/editor-slice.js';
12
+ import { createProvidersSlice } from './slices/providers-slice.js';
13
+ import { createNetworkSlice } from './slices/network-slice.js';
14
+ import { createPreviewSlice } from './slices/preview-slice.js';
15
+ import { createMarketplaceSlice } from './slices/marketplace-slice.js';
16
+ import { createAutomationsSlice } from './slices/automations-slice.js';
6
17
 
7
18
  const WS_URL = `ws://${window.location.hostname}:${window.location.port || 31415}`;
8
19
 
9
- let toastCounter = 0;
10
20
  let plannerPollInterval = null;
11
- const _modeChangePending = new Set();
12
-
13
- function loadJSON(key, fallback = {}) {
14
- try { return JSON.parse(localStorage.getItem(key) || JSON.stringify(fallback)); }
15
- catch { return fallback; }
16
- }
17
-
18
- function persistJSON(key, value) {
19
- try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* quota */ }
20
- }
21
21
 
22
22
  // Clear stale persisted data on version change
23
23
  const STORE_VERSION = '0.22.28';
24
- if (loadJSON('groove:storeVersion') !== STORE_VERSION) {
24
+ if (localStorage.getItem('groove:storeVersion') !== JSON.stringify(STORE_VERSION)) {
25
25
  localStorage.removeItem('groove:chatHistory');
26
26
  localStorage.removeItem('groove:activityLog');
27
27
  persistJSON('groove:storeVersion', STORE_VERSION);
28
28
  }
29
29
 
30
30
  export const useGrooveStore = create((set, get) => ({
31
+ // ── Spread all slices ───────────────────────────────────────
32
+ ...createUiSlice(set, get),
33
+ ...createAgentsSlice(set, get),
34
+ ...createTeamsSlice(set, get),
35
+ ...createChatSlice(set, get),
36
+ ...createEditorSlice(set, get),
37
+ ...createProvidersSlice(set, get),
38
+ ...createNetworkSlice(set, get),
39
+ ...createPreviewSlice(set, get),
40
+ ...createMarketplaceSlice(set, get),
41
+ ...createAutomationsSlice(set, get),
42
+
31
43
  // ── Connection ────────────────────────────────────────────
32
- agents: [],
33
44
  connected: false,
34
- hydrated: false, // true after first WS state message — gates the UI to prevent flicker
45
+ hydrated: false,
35
46
  ws: null,
36
47
  daemonHost: null,
37
48
  tunneled: false,
38
49
  remoteHomedir: null,
39
50
 
40
- // ── Teams ─────────────────────────────────────────────────
41
- teams: [],
42
- archivedTeams: [],
43
- activeTeamId: localStorage.getItem('groove:activeTeamId') || null,
44
-
45
- // ── Gateways ──────────────────────────────────────────────
46
- gateways: [],
47
-
48
- // ── Providers ────────────────────────────────────────────
49
- _providerRefreshTick: 0,
50
-
51
- // ── Local Models (Ollama) ─────────────────────────────────
52
- ollamaStatus: { installed: false, serverRunning: false, hardware: null },
53
- ollamaInstalledModels: [],
54
- ollamaRunningModels: [],
55
- ollamaCatalog: [],
56
- ollamaPullProgress: {},
57
-
58
- // ── Federation ────────────────────────────────────────────
59
- federation: {
60
- peers: [],
61
- whitelist: [],
62
- connections: [],
63
- pouchLog: [],
64
- ambassadors: [],
65
- selectedPeerId: null,
66
- },
67
-
68
- // ── Preview ───────────────────────────────────────────────
69
- previewState: { url: null, teamId: null, kind: null, deviceSize: 'desktop', screenshotMode: false },
70
- showPreviewInAgents: false,
71
- previewChat: [],
72
- previewIterating: false,
73
- teamPreviews: {}, // teamId -> { url, kind, active } — survives team switches
74
-
75
- // ── Team Launch Config (set during planner spawn, cascades to team) ──
76
- teamLaunchConfig: null, // { provider, model, reasoningEffort, temperature, verbosity, mode }
77
-
78
- // ── Team Builder ──────────────────────────────────────────
79
- teamBuilderOpen: false,
80
- teamBuilderRoles: [],
81
- teamBuilderSettings: { provider: null, model: null, reasoningEffort: 50, temperature: 0.5 },
82
- teamBuilderTask: '',
83
- teamTemplates: { builtIn: [], custom: [] },
84
-
85
- // ── Navigation ────────────────────────────────────────────
86
- activeView: 'agents', // 'agents' | 'editor' | 'dashboard' | 'marketplace' | 'teams' | 'settings' | 'preview'
87
- detailPanel: null, // null | { type: 'agent', agentId } | { type: 'spawn' } | { type: 'journalist' }
88
- teamDetailPanels: {}, // { [teamId]: detailPanel } — persists panel state per team
89
- commandPaletteOpen: false,
90
- quickConnectOpen: false,
91
- upgradeModalOpen: false,
92
-
93
- // ── Node expansion (click-to-open persistent panels) ───────
94
- expandedNodes: loadJSON('groove:expandedNodes'),
95
-
96
- // ── Layout persistence ────────────────────────────────────
97
- detailPanelWidth: Number(localStorage.getItem('groove:detailWidth')) || 480,
98
- terminalVisible: localStorage.getItem('groove:terminalVisible') === 'true',
99
- terminalHeight: Number(localStorage.getItem('groove:terminalHeight')) || 260,
100
- terminalFullHeight: false,
101
-
102
- // ── Agent data ────────────────────────────────────────────
103
- activityLog: loadJSON('groove:activityLog'),
104
- chatHistory: loadJSON('groove:chatHistory'),
105
- chatInputs: {}, // Per-agent draft input text — persists across tab switches
106
- tokenTimeline: {},
107
-
108
- // ── Conversations (Chat view) ────────────────────────────
109
- conversations: [],
110
- activeConversationId: localStorage.getItem('groove:activeConversationId') || null,
111
- conversationMessages: loadJSON('groove:conversationMessages'),
112
- sendingMessage: false,
113
- streamingConversationId: null,
114
- conversationRoles: loadJSON('groove:conversationRoles'),
115
- conversationReasoningEffort: loadJSON('groove:conversationReasoningEffort'),
116
- conversationVerbosity: loadJSON('groove:conversationVerbosity'),
117
-
118
- // ── Keeper (tagged memory) ─────────────────────────────────
119
- keeperItems: [],
120
- keeperTree: [],
121
- keeperEditing: null, // { tag, content, isNew, readOnly } — drives the editor modal
122
- keeperInstructOpen: false,
123
-
124
- // ── Approvals ─────────────────────────────────────────────
125
- pendingApprovals: [],
126
- resolvedApprovals: [],
127
-
128
- // ── Recommended Team ──────────────────────────────────────
129
- recommendedTeam: null, // { name, agents: [...] } from planner
130
- _delegatingTeamIds: new Set(),
131
-
132
- // ── Journalist ────────────────────────────────────────────
133
- journalistStatus: null, // { cycleCount, lastCycleTime, history, lastSynthesis }
134
-
135
- // ── Network (Early Access) ────────────────────────────────
136
- networkUnlocked: false,
137
- networkInstalled: false,
138
- networkInstallProgress: { installing: false, step: null, message: null, percent: 0, error: null },
139
- networkNode: { active: false, status: 'disconnected', nodeId: null, layers: null, model: null, sessions: 0, hardware: null },
140
- networkStatus: { nodes: [], coverage: 0, totalLayers: 0, models: [], activeSessions: 0 },
141
- networkStatusReachable: false,
142
- networkEvents: [],
143
- networkVersion: { installed: null, latest: null, updateAvailable: false },
144
- networkUpdateProgress: { updating: false, step: null, message: null, percent: 0, error: null },
145
- networkCompute: { totalRamMb: 0, totalVramMb: 0, totalCpuCores: 0, totalBandwidthMbps: 0, activeNodes: 0, totalNodes: 0, avgLoad: 0 },
146
- networkSnapshots: [],
147
- networkTokenTiming: null,
148
- networkBenchmarks: [],
149
- networkTraces: [],
150
- networkPerfSnapshots: [],
151
- networkNodeTelemetry: {},
152
- networkWallet: { connected: false, address: null, balance: '0.00', token: 'GROOVE', chain: 'base-l2' },
153
- networkEarnings: { today: 0, thisWeek: 0, allTime: 0, history: [] },
154
-
155
- // ── Training Data ──────────────────────────────────────────
156
- trainingOptIn: false,
157
- trainingStats: null,
158
- dataSharingDismissed: false,
159
- dataSharingModalOpen: false,
160
-
161
- // ── Marketplace Auth ───────────────────────────────────────
162
- marketplaceUser: null, // { id, displayName, avatar, ... } or null
163
- marketplaceAuthenticated: false,
164
- edition: 'community', // 'community' | 'pro' — runtime edition from /edition
165
- subscription: {
166
- plan: 'community',
167
- status: 'none',
168
- active: false,
169
- features: [],
170
- seats: 1,
171
- periodEnd: null,
172
- cancelAtPeriodEnd: false,
173
- },
174
-
175
- // ── Version / Auto-Update ──────────────────────────────────
176
- version: null,
177
- updateReady: null,
178
- updateProgress: null,
179
- updateModalOpen: false,
180
-
181
- // ── Toasts ────────────────────────────────────────────────
182
- toasts: [],
183
-
184
- // ── Project Directory ───────────────────────────────────────
185
- projectDir: null,
186
- recentProjects: [],
187
- showProjectPicker: false,
188
-
189
- // ── Tunnels ────────────────────────────────────────────────
190
- savedTunnels: [],
191
- tunnelConnectStep: null,
192
-
193
- // ── GitHub Repo Import ────────────────────────────────────
194
- importedRepos: [],
195
- importInProgress: false,
196
-
197
- // ── Editor state ──────────────────────────────────────────
198
- editorFiles: {},
199
- editorActiveFile: null,
200
- editorOpenTabs: [],
201
- editorTreeCache: {},
202
- editorChangedFiles: {},
203
- editorRecentSaves: {},
204
- editorSidebarWidth: Number(localStorage.getItem('groove:editorSidebarWidth')) || 240,
205
- editorTheme: localStorage.getItem('groove:editorTheme') || 'vscodeDark',
206
-
207
- // ── Workspace Mode ────────────────────────────────────────
208
- workspaceMode: localStorage.getItem('groove:workspaceMode') === 'true',
209
- workspaceAgentId: null,
210
- workspaceSnapshots: {},
211
- workspaceReviewMode: false,
212
- workspaceReviewFiles: [],
213
-
214
- // ── Editor (Cursor-style) ────────────────────────────────
215
- editorSelectedAgent: null,
216
- editorPendingSnippet: null,
217
- editorViewMode: 'code',
218
- editorAiPanelOpen: false,
219
- editorAiPanelWidth: Number(localStorage.getItem('groove:editorAiPanelWidth')) || 360,
220
- editorGitStatus: null,
221
- editorGitBranch: null,
222
- editorGitDiff: null,
223
- editorQuickSearchOpen: false,
224
-
225
- // ── Model Lab ──────────────────────────────────────────────
226
- labRuntimes: loadJSON('groove:labRuntimes', []),
227
- labActiveRuntime: null,
228
- labModels: [],
229
- labActiveModel: null,
230
- labPresets: loadJSON('groove:labPresets', []),
231
- labActivePreset: null,
232
- labSessions: [],
233
- labActiveSession: null,
234
- labMetrics: { ttft: null, tokensPerSec: null, tokensPerSecHistory: [], memory: null, totalTokens: 0, generationTime: null },
235
- labParameters: loadJSON('groove:labParameters', {
236
- temperature: 0.7, topP: 0.9, topK: 40, repeatPenalty: 1.1,
237
- maxTokens: 2048, frequencyPenalty: 0, presencePenalty: 0,
238
- }),
239
- labSystemPrompt: localStorage.getItem('groove:labSystemPrompt') || '',
240
- labStreaming: false,
241
- labAbortController: null,
242
- labLocalModels: [],
243
- labLaunching: null,
244
- labLlamaInstalled: null,
245
- labLaunchPhase: null,
246
- labLaunchError: null,
247
- labAssistantAgentId: localStorage.getItem('groove:labAssistantAgentId') || null,
248
- labAssistantMode: false,
249
- labAssistantBackend: localStorage.getItem('groove:labAssistantBackend') || null,
250
-
251
- // ── Onboarding ────────────────────────────────────────────
252
- onboardingComplete: localStorage.getItem('groove:onboardingComplete') === 'true',
253
-
254
- // ── Connection ────────────────────────────────────────────
255
-
256
51
  connect() {
257
52
  if (get().ws) return;
258
53
  const ws = new WebSocket(WS_URL);
259
- set({ ws }); // Claim slot immediately to prevent StrictMode double-connect
54
+ set({ ws });
260
55
 
261
56
  ws.onopen = () => {
262
57
  set({ connected: true });
@@ -325,15 +120,8 @@ export const useGrooveStore = create((set, get) => ({
325
120
  if (arr.length > 200) timeline[agent.id] = arr.slice(-200);
326
121
  }
327
122
  }
328
- // Prune stale tokenTimeline (high-volume metrics, safe to drop).
329
- // chatHistory and activityLog are NOT pruned here — they must survive
330
- // the gap between registry.remove() and rotation:complete so the
331
- // rotation handler can migrate them to the new agent ID. Explicit
332
- // cleanup happens in killAgent(purge=true) and rotation:complete.
333
123
  const st = get();
334
124
  for (const id of Object.keys(timeline)) if (!liveIds.has(id)) delete timeline[id];
335
- // Only replace agents array if something meaningful changed
336
- // (prevents React Flow tree flicker on every lastActivity update)
337
125
  const prev = st.agents;
338
126
  const changed = msg.data.length !== prev.length || msg.data.some((a, i) => {
339
127
  const p = prev[i];
@@ -342,7 +130,6 @@ export const useGrooveStore = create((set, get) => ({
342
130
  });
343
131
  set({ agents: changed ? msg.data : prev, tokenTimeline: timeline, hydrated: true });
344
132
 
345
- // Poll for recommended-team.json while a planner is running
346
133
  const hasRunningPlanner = msg.data.some((a) => a.role === 'planner' && a.status === 'running');
347
134
  if (hasRunningPlanner && !plannerPollInterval && !get().recommendedTeam) {
348
135
  plannerPollInterval = setInterval(() => {
@@ -376,7 +163,6 @@ export const useGrooveStore = create((set, get) => ({
376
163
  if (upd) { found++; return upd; }
377
164
  return a;
378
165
  });
379
- // New agents not yet in the list
380
166
  if (found < changed.length) {
381
167
  for (const a of changed) {
382
168
  if (!agents.some((ex) => ex.id === a.id)) agents.push(a);
@@ -402,7 +188,6 @@ export const useGrooveStore = create((set, get) => ({
402
188
  case 'agent:output': {
403
189
  const { agentId, data } = msg;
404
190
 
405
- // Separate text content from tool calls
406
191
  let chatText = '';
407
192
  let activityText = '';
408
193
  if (typeof data.data === 'string') {
@@ -415,7 +200,6 @@ export const useGrooveStore = create((set, get) => ({
415
200
  }).join('\n');
416
201
  }
417
202
 
418
- // Update agent metrics in real-time (contextUsage, tokensUsed)
419
203
  if (data.contextUsage !== undefined || data.tokensUsed !== undefined) {
420
204
  const agents = get().agents.map((a) => {
421
205
  if (a.id !== agentId) return a;
@@ -427,17 +211,12 @@ export const useGrooveStore = create((set, get) => ({
427
211
  set({ agents });
428
212
  }
429
213
 
430
- // Text responses → chat bubbles
431
- // Skip pure token-level stream chunks (subtype='stream') — too granular
432
- // Show: subtype='assistant' (Claude Code), subtype='text' (agent loop), type='result',
433
- // and plain activity events with string data (Gemini/Codex/Ollama CLI)
434
214
  const isTokenStream = data.subtype === 'stream';
435
215
  const showAsChat = chatText && chatText.trim() && !isTokenStream && (
436
216
  data.subtype === 'assistant' || data.subtype === 'text' || data.type === 'result' ||
437
217
  (data.type === 'activity' && typeof data.data === 'string')
438
218
  );
439
219
  if (showAsChat) {
440
- // Clear thinking indicator only when actual text renders as a chat bubble
441
220
  if (get().thinkingAgents.has(agentId)) {
442
221
  set((s) => {
443
222
  const next = new Set(s.thinkingAgents);
@@ -453,17 +232,13 @@ export const useGrooveStore = create((set, get) => ({
453
232
  const last = arr[arr.length - 1];
454
233
  const isRecent = last && last.from === 'agent' && (Date.now() - last.timestamp) < 8000;
455
234
 
456
- // Skip duplicate text — Claude Code sends 'assistant' then 'result' with same content
457
235
  const isDupe = isRecent && (last.text === trimmed || last.text.endsWith(trimmed));
458
236
 
459
237
  if (!isDupe) {
460
238
  if (isRecent) {
461
- // Append to the last agent message (streaming from any provider)
462
- // Claude Code blocks use \n\n separator; plain text uses space
463
239
  const sep = data.subtype === 'assistant' ? '\n\n' : ' ';
464
240
  arr[arr.length - 1] = { ...last, text: last.text + sep + trimmed, timestamp: Date.now() };
465
241
  } else {
466
- // New message bubble
467
242
  arr.push({ from: 'agent', text: trimmed, timestamp: Date.now() });
468
243
  }
469
244
 
@@ -472,7 +247,6 @@ export const useGrooveStore = create((set, get) => ({
472
247
  persistJSON('groove:chatHistory', history);
473
248
  }
474
249
 
475
- // Mirror to conversation messages if this agent belongs to a conversation
476
250
  const conv = get().conversations.find((c) => c.agentId === agentId);
477
251
  if (conv) {
478
252
  const convMsgs = { ...get().conversationMessages };
@@ -495,7 +269,6 @@ export const useGrooveStore = create((set, get) => ({
495
269
  }
496
270
  }
497
271
 
498
- // Tool calls → activity log (shown in streaming bar, not as chat bubbles)
499
272
  if (activityText && activityText.trim()) {
500
273
  const log = { ...get().activityLog };
501
274
  if (!log[agentId]) log[agentId] = [];
@@ -509,7 +282,6 @@ export const useGrooveStore = create((set, get) => ({
509
282
  persistJSON('groove:activityLog', log);
510
283
  }
511
284
 
512
- // Open-on-write: auto-open files the agent writes in workspace mode
513
285
  if (get().workspaceMode && Array.isArray(data.data)) {
514
286
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'write_file', 'edit_file', 'create_file']);
515
287
  for (const block of data.data) {
@@ -534,7 +306,6 @@ export const useGrooveStore = create((set, get) => ({
534
306
  const type = msg.status === 'completed' ? 'success' : isKill ? 'info' : 'warning';
535
307
  get().addToast(type, text, msg.error ? msg.error.slice(0, 200) : undefined);
536
308
 
537
- // Clear thinking indicator — agent is no longer active
538
309
  if (get().thinkingAgents.has(msg.agentId)) {
539
310
  set((s) => {
540
311
  const next = new Set(s.thinkingAgents);
@@ -543,17 +314,14 @@ export const useGrooveStore = create((set, get) => ({
543
314
  });
544
315
  }
545
316
 
546
- // Clear conversation streaming state
547
317
  const exitConv = get().conversations.find((c) => c.agentId === msg.agentId);
548
318
  if (exitConv && get().streamingConversationId === exitConv.id) {
549
319
  set({ sendingMessage: false, streamingConversationId: null });
550
320
  }
551
321
 
552
- // Log crash error to agent chat so user can see what happened
553
322
  if (msg.error && msg.agentId) {
554
323
  get().addChatMessage(msg.agentId, 'system', `Crashed: ${msg.error}`);
555
324
  }
556
- // Clear workspace if the exiting agent was the workspace target
557
325
  if (get().workspaceAgentId === msg.agentId) {
558
326
  const teamAgents = get().agents.filter(
559
327
  (a) => a.id !== msg.agentId && a.teamId === get().activeTeamId,
@@ -562,7 +330,6 @@ export const useGrooveStore = create((set, get) => ({
562
330
  set({ workspaceAgentId: next?.id || null });
563
331
  }
564
332
 
565
- // Check for recommended team when planner completes
566
333
  if (agent?.role === 'planner' && msg.status === 'completed') {
567
334
  setTimeout(() => get().checkRecommendedTeam(), 1000);
568
335
  }
@@ -679,9 +446,6 @@ export const useGrooveStore = create((set, get) => ({
679
446
  break;
680
447
 
681
448
  case 'rotation:complete': {
682
- // Migrate all agent-keyed state to the new ID so chat history,
683
- // activity log, and token timeline carry forward seamlessly.
684
- // The broadcast sends `agentId` (new) and `oldAgentId` (old).
685
449
  const newId = msg.agentId;
686
450
  const oldId = msg.oldAgentId;
687
451
  if (!newId || !oldId) break;
@@ -728,7 +492,6 @@ export const useGrooveStore = create((set, get) => ({
728
492
  case 'file:changed': {
729
493
  const savedAt = get().editorRecentSaves[msg.path];
730
494
  if (savedAt && Date.now() - savedAt < 2000) break;
731
- // Auto-capture workspace snapshot for diff viewer
732
495
  if (get().workspaceMode && msg.path && !get().workspaceSnapshots[msg.path]) {
733
496
  const existing = get().editorFiles[msg.path];
734
497
  if (existing?.content) {
@@ -779,6 +542,13 @@ export const useGrooveStore = create((set, get) => ({
779
542
 
780
543
  case 'schedule:execute':
781
544
  get().addToast('info', `Scheduled agent spawned: ${msg.name || msg.role || 'agent'}`);
545
+ get().fetchAutomations();
546
+ break;
547
+
548
+ case 'schedule:created':
549
+ case 'schedule:updated':
550
+ case 'schedule:deleted':
551
+ get().fetchAutomations();
782
552
  break;
783
553
 
784
554
  case 'gateway:status':
@@ -957,7 +727,6 @@ export const useGrooveStore = create((set, get) => ({
957
727
  }
958
728
  set(nsUpdate);
959
729
 
960
- // Push snapshot for activity chart
961
730
  const wsNodes = nsData.nodes || [];
962
731
  const wsOwnId = get().networkNode.nodeId;
963
732
  const wsOwn = wsOwnId ? wsNodes.find((n) => (n.node_id || n.nodeId) === wsOwnId) : null;
@@ -1239,2883 +1008,4 @@ export const useGrooveStore = create((set, get) => ({
1239
1008
  };
1240
1009
  ws.onerror = () => ws.close();
1241
1010
  },
1242
-
1243
- // ── Navigation ────────────────────────────────────────────
1244
-
1245
- setActiveView(view) { set({ activeView: view }); },
1246
-
1247
- // ── Keeper (tagged memory) ────────────────────────────────
1248
-
1249
- async fetchKeeperItems() {
1250
- try {
1251
- const data = await api.get('/keeper');
1252
- const treeData = await api.get('/keeper/tree');
1253
- set({ keeperItems: data.items || [], keeperTree: treeData.tree || [] });
1254
- } catch { /* ignore */ }
1255
- },
1256
-
1257
- async saveKeeperItem(tag, content) {
1258
- try {
1259
- const item = await api.post('/keeper', { tag, content });
1260
- get().fetchKeeperItems();
1261
- get().addToast('success', `Saved #${item.tag}`);
1262
- return item;
1263
- } catch (err) {
1264
- get().addToast('error', 'Failed to save memory', err.message);
1265
- throw err;
1266
- }
1267
- },
1268
-
1269
- async appendKeeperItem(tag, content) {
1270
- try {
1271
- const item = await api.post('/keeper/append', { tag, content });
1272
- get().fetchKeeperItems();
1273
- get().addToast('success', `Appended to #${item.tag}`);
1274
- return item;
1275
- } catch (err) {
1276
- get().addToast('error', 'Failed to append', err.message);
1277
- throw err;
1278
- }
1279
- },
1280
-
1281
- async updateKeeperItem(tag, content) {
1282
- try {
1283
- const item = await api.patch(`/keeper/${tag}`, { content });
1284
- get().fetchKeeperItems();
1285
- get().addToast('success', `Updated #${item.tag}`);
1286
- return item;
1287
- } catch (err) {
1288
- get().addToast('error', 'Failed to update memory', err.message);
1289
- throw err;
1290
- }
1291
- },
1292
-
1293
- async deleteKeeperItem(tag) {
1294
- try {
1295
- await api.delete(`/keeper/${tag}`);
1296
- get().fetchKeeperItems();
1297
- get().addToast('success', `Deleted #${tag}`);
1298
- } catch (err) {
1299
- get().addToast('error', 'Failed to delete memory', err.message);
1300
- }
1301
- },
1302
-
1303
- async getKeeperItem(tag) {
1304
- try {
1305
- return await api.get(`/keeper/${tag}`);
1306
- } catch {
1307
- return null;
1308
- }
1309
- },
1310
-
1311
- async searchKeeper(query) {
1312
- try {
1313
- const data = await api.get(`/keeper/search?q=${encodeURIComponent(query)}`);
1314
- return data.results || [];
1315
- } catch {
1316
- return [];
1317
- }
1318
- },
1319
-
1320
- setKeeperEditing(editing) {
1321
- set({ keeperEditing: editing });
1322
- },
1323
-
1324
- async _handleKeeperCommand(agentId, message, command) {
1325
- const rest = message.replace(/\[\w+[-\w]*\]/i, '').trim();
1326
- const tags = (rest.match(/#[\w/.-]+/g) || []).map(t => t.replace(/^#/, ''));
1327
-
1328
- const addSystemMsg = (text) => {
1329
- get().addChatMessage(agentId, 'system', text);
1330
- };
1331
-
1332
- try {
1333
- switch (command) {
1334
- case 'instruct': {
1335
- set({ keeperInstructOpen: true });
1336
- return true;
1337
- }
1338
-
1339
- case 'save': {
1340
- if (tags.length === 0) { addSystemMsg('Usage: save #tag your message here'); return true; }
1341
- const content = rest.replace(/#[\w/.-]+/g, '').trim();
1342
- if (!content) { addSystemMsg('Usage: save #tag your message here'); return true; }
1343
- await get().saveKeeperItem(tags[0], content);
1344
- addSystemMsg(`Saved to #${tags[0]}`);
1345
- return { passthrough: content };
1346
- }
1347
-
1348
- case 'append': {
1349
- if (tags.length === 0) { addSystemMsg('Usage: append #tag content to add'); return true; }
1350
- const content = rest.replace(/#[\w/.-]+/g, '').trim();
1351
- if (!content) { addSystemMsg('Usage: append #tag content to add'); return true; }
1352
- await get().appendKeeperItem(tags[0], content);
1353
- addSystemMsg(`Appended to #${tags[0]}`);
1354
- return { passthrough: content };
1355
- }
1356
-
1357
- case 'update': {
1358
- if (tags.length === 0) { addSystemMsg('Usage: [update] #tag'); return true; }
1359
- get().addChatMessage(agentId, 'user', message, false);
1360
- const existing = await get().getKeeperItem(tags[0]);
1361
- set({ keeperEditing: { tag: tags[0], content: existing?.content || '', isNew: !existing } });
1362
- return true;
1363
- }
1364
-
1365
- case 'delete': {
1366
- if (tags.length === 0) { addSystemMsg('Usage: [delete] #tag'); return true; }
1367
- get().addChatMessage(agentId, 'user', message, false);
1368
- await get().deleteKeeperItem(tags[0]);
1369
- addSystemMsg(`Deleted #${tags[0]}`);
1370
- return true;
1371
- }
1372
-
1373
- case 'view': {
1374
- if (tags.length === 0) { addSystemMsg('Usage: [view] #tag'); return true; }
1375
- get().addChatMessage(agentId, 'user', message, false);
1376
- const item = await get().getKeeperItem(tags[0]);
1377
- if (item) {
1378
- set({ keeperEditing: { tag: tags[0], content: item.content, isNew: false, readOnly: true } });
1379
- } else {
1380
- addSystemMsg(`#${tags[0]} not found`);
1381
- }
1382
- return true;
1383
- }
1384
-
1385
- case 'read': {
1386
- if (tags.length === 0) { addSystemMsg('Usage: [read] #tag1 #tag2 ...'); return true; }
1387
- get().addChatMessage(agentId, 'user', message, false);
1388
- const readBrief = await api.post('/keeper/pull', { tags });
1389
- if (readBrief?.brief) {
1390
- await api.post(`/agents/${encodeURIComponent(agentId)}/instruct`, {
1391
- message: `Here is context from my tagged memories:\n\n${readBrief.brief}`,
1392
- });
1393
- addSystemMsg(`Sent ${tags.map(t => '#' + t).join(', ')} to agent`);
1394
- } else {
1395
- addSystemMsg(`No memories found for ${tags.map(t => '#' + t).join(', ')}`);
1396
- }
1397
- return true;
1398
- }
1399
-
1400
- case 'doc': {
1401
- if (tags.length === 0) { addSystemMsg('Usage: [doc] #tag'); return true; }
1402
- get().addChatMessage(agentId, 'user', message, false);
1403
- addSystemMsg(`Generating doc for #${tags[0]}...`);
1404
- const history = get().chatHistory[agentId] || [];
1405
- const result = await api.post('/keeper/doc', { tag: tags[0], chatHistory: history, agentId });
1406
- if (result?.content) {
1407
- addSystemMsg(`Doc #${tags[0]} generated (${result.size}B)`);
1408
- set({ keeperEditing: { tag: tags[0], content: result.content, isNew: false } });
1409
- }
1410
- return true;
1411
- }
1412
-
1413
- case 'link': {
1414
- const linkMatch = rest.match(/^((?:#[\w/.-]+\s*)+)\s+(.+)$/);
1415
- if (!linkMatch || tags.length === 0) { addSystemMsg('Usage: [link] #tag path/to/doc'); return true; }
1416
- const docPath = linkMatch[2].trim();
1417
- get().addChatMessage(agentId, 'user', message, false);
1418
- await api.post('/keeper/link', { tag: tags[0], docPath });
1419
- addSystemMsg(`Linked #${tags[0]} → ${docPath}`);
1420
- return true;
1421
- }
1422
- }
1423
- } catch (err) {
1424
- addSystemMsg(`Keeper error: ${err.message}`);
1425
- return true;
1426
- }
1427
- return false;
1428
- },
1429
-
1430
- // ── Teams ─────────────────────────────────────────────────
1431
-
1432
- async fetchTeams() {
1433
- try {
1434
- const data = await api.get('/teams');
1435
- let teams = data.teams || [];
1436
- const defaultTeamId = data.defaultTeamId;
1437
- try {
1438
- const saved = JSON.parse(localStorage.getItem('groove:teamOrder') || '[]');
1439
- if (saved.length) {
1440
- const byId = Object.fromEntries(teams.map((t) => [t.id, t]));
1441
- const ordered = saved.filter((id) => byId[id]).map((id) => byId[id]);
1442
- const remaining = teams.filter((t) => !saved.includes(t.id));
1443
- teams = [...ordered, ...remaining];
1444
- }
1445
- } catch {}
1446
- const { activeTeamId } = get();
1447
- const ids = teams.map((t) => t.id);
1448
- const resolved = ids.includes(activeTeamId) ? activeTeamId : defaultTeamId;
1449
- set({ teams, activeTeamId: resolved });
1450
- if (resolved) localStorage.setItem('groove:activeTeamId', resolved);
1451
- } catch { /* ignore */ }
1452
- },
1453
-
1454
- switchTeam(id) {
1455
- const { activeTeamId, detailPanel, teamDetailPanels, teamPreviews } = get();
1456
- const updated = { ...teamDetailPanels };
1457
- if (activeTeamId) updated[activeTeamId] = detailPanel;
1458
- const restored = updated[id] || null;
1459
- const tp = teamPreviews[id];
1460
- const previewUpdate = tp
1461
- ? { previewState: { url: tp.url, teamId: id, kind: tp.kind, deviceSize: 'desktop', screenshotMode: false } }
1462
- : {};
1463
- set({ activeTeamId: id, detailPanel: restored, teamDetailPanels: updated, ...previewUpdate });
1464
- localStorage.setItem('groove:activeTeamId', id);
1465
- },
1466
-
1467
- async createTeam(name, workingDir, mode) {
1468
- try {
1469
- const body = { name };
1470
- if (workingDir) body.workingDir = workingDir;
1471
- if (mode) body.mode = mode;
1472
- const team = await api.post('/teams', body);
1473
- // Only set activeTeamId — the WS team:created handler adds to the teams array
1474
- set({ activeTeamId: team.id });
1475
- localStorage.setItem('groove:activeTeamId', team.id);
1476
- get().addToast('success', `Team "${name}" created`);
1477
- return team;
1478
- } catch (err) {
1479
- get().addToast('error', 'Failed to create team', err.message);
1480
- throw err;
1481
- }
1482
- },
1483
-
1484
- async archiveTeam(id) {
1485
- const team = get().teams.find((t) => t.id === id);
1486
- try {
1487
- await api.delete(`/teams/${encodeURIComponent(id)}`);
1488
- const wiped = team?.isDefault ? 'wiped' : 'archived';
1489
- get().addToast('success', `Team "${team?.name}" ${wiped}`, wiped === 'archived' ? 'Files preserved — restore anytime from Archived Teams' : undefined);
1490
- get().fetchArchivedTeams();
1491
- } catch (err) {
1492
- get().addToast('error', 'Failed to archive team', err.message);
1493
- }
1494
- },
1495
-
1496
- async deleteTeamPermanently(id) {
1497
- const team = get().teams.find((t) => t.id === id);
1498
- try {
1499
- await api.delete(`/teams/${encodeURIComponent(id)}?permanent=true`);
1500
- get().addToast('success', `Team "${team?.name}" permanently deleted`);
1501
- } catch (err) {
1502
- get().addToast('error', 'Failed to delete team', err.message);
1503
- }
1504
- },
1505
-
1506
- async deleteTeam(id) {
1507
- return get().archiveTeam(id);
1508
- },
1509
-
1510
- reorderTeams(fromIndex, toIndex) {
1511
- const teams = [...get().teams];
1512
- const [moved] = teams.splice(fromIndex, 1);
1513
- teams.splice(toIndex, 0, moved);
1514
- set({ teams });
1515
- try { localStorage.setItem('groove:teamOrder', JSON.stringify(teams.map((t) => t.id))); } catch {}
1516
- },
1517
-
1518
- async fetchArchivedTeams() {
1519
- try {
1520
- const data = await api.get('/teams/archived');
1521
- set({ archivedTeams: data.archived || data.teams || [] });
1522
- } catch { /* endpoint may not exist yet */ }
1523
- },
1524
-
1525
- async restoreTeam(archivedId) {
1526
- try {
1527
- await api.post(`/teams/archived/${encodeURIComponent(archivedId)}/restore`);
1528
- get().addToast('success', 'Team restored');
1529
- get().fetchArchivedTeams();
1530
- } catch (err) {
1531
- get().addToast('error', 'Failed to restore team', err.message);
1532
- }
1533
- },
1534
-
1535
- async purgeTeam(archivedId) {
1536
- try {
1537
- await api.delete(`/teams/archived/${encodeURIComponent(archivedId)}`);
1538
- get().addToast('info', 'Archived team permanently deleted');
1539
- get().fetchArchivedTeams();
1540
- } catch (err) {
1541
- get().addToast('error', 'Failed to purge team', err.message);
1542
- }
1543
- },
1544
-
1545
- async cloneTeam(id) {
1546
- const team = get().teams.find((t) => t.id === id);
1547
- if (!team) return;
1548
- const sourceAgents = get().agents.filter((a) => a.teamId === id);
1549
- try {
1550
- const newTeam = await api.post('/teams', { name: `${team.name} (copy)` });
1551
- set({ activeTeamId: newTeam.id });
1552
- localStorage.setItem('groove:activeTeamId', newTeam.id);
1553
- for (const agent of sourceAgents) {
1554
- await api.post('/agents', {
1555
- role: agent.role,
1556
- name: agent.name,
1557
- provider: agent.provider,
1558
- model: agent.model,
1559
- scope: agent.scope,
1560
- teamId: newTeam.id,
1561
- });
1562
- }
1563
- get().addToast('success', `Cloned "${team.name}" with ${sourceAgents.length} agent${sourceAgents.length !== 1 ? 's' : ''}`);
1564
- return newTeam;
1565
- } catch (err) {
1566
- get().addToast('error', 'Failed to clone team', err.message);
1567
- }
1568
- },
1569
-
1570
- async renameTeam(id, name) {
1571
- try {
1572
- const team = await api.patch(`/teams/${encodeURIComponent(id)}`, { name });
1573
- set((s) => ({ teams: s.teams.map((t) => (t.id === id ? team : t)) }));
1574
- return team;
1575
- } catch (err) {
1576
- get().addToast('error', 'Failed to rename team', err.message);
1577
- throw err;
1578
- }
1579
- },
1580
-
1581
- async promoteTeam(id) {
1582
- try {
1583
- const team = await api.post(`/teams/${encodeURIComponent(id)}/promote`);
1584
- set((s) => ({ teams: s.teams.filter((t) => t.id !== id) }));
1585
- get().addToast('success', 'Team promoted — files moved to project directory');
1586
- return team;
1587
- } catch (err) {
1588
- get().addToast('error', 'Failed to promote team', err.message);
1589
- throw err;
1590
- }
1591
- },
1592
-
1593
- openDetail(descriptor) {
1594
- const tid = get().activeTeamId;
1595
- set((s) => ({ detailPanel: descriptor, teamDetailPanels: { ...s.teamDetailPanels, [tid]: descriptor } }));
1596
- },
1597
- closeDetail() {
1598
- const tid = get().activeTeamId;
1599
- set((s) => ({ detailPanel: null, teamDetailPanels: { ...s.teamDetailPanels, [tid]: null } }));
1600
- },
1601
- selectAgent(id) {
1602
- const tid = get().activeTeamId;
1603
- const match = get().agents.find((a) => a.id === id);
1604
- if (tid && match && match.teamId && match.teamId !== tid) return;
1605
- const panel = { type: 'agent', agentId: id };
1606
- set((s) => ({ detailPanel: panel, teamDetailPanels: { ...s.teamDetailPanels, [tid]: panel } }));
1607
- },
1608
- clearSelection() {
1609
- const tid = get().activeTeamId;
1610
- set((s) => ({ detailPanel: null, teamDetailPanels: { ...s.teamDetailPanels, [tid]: null } }));
1611
- },
1612
- toggleCommandPalette() { set((s) => ({ commandPaletteOpen: !s.commandPaletteOpen })); },
1613
- toggleQuickConnect() { set((s) => ({ quickConnectOpen: !s.quickConnectOpen })); },
1614
- setUpgradeModalOpen: (open) => set({ upgradeModalOpen: open }),
1615
-
1616
- setDetailPanelWidth(w) {
1617
- set({ detailPanelWidth: w });
1618
- localStorage.setItem('groove:detailWidth', String(w));
1619
- },
1620
- setTerminalVisible(v) {
1621
- set({ terminalVisible: v });
1622
- localStorage.setItem('groove:terminalVisible', String(v));
1623
- },
1624
- setTerminalHeight(h) {
1625
- set({ terminalHeight: h });
1626
- localStorage.setItem('groove:terminalHeight', String(h));
1627
- },
1628
- setTerminalFullHeight(v) { set({ terminalFullHeight: v }); },
1629
-
1630
- toggleNodeExpanded(id) {
1631
- const expanded = { ...get().expandedNodes };
1632
- expanded[id] = !expanded[id];
1633
- if (!expanded[id]) delete expanded[id];
1634
- set({ expandedNodes: expanded });
1635
- persistJSON('groove:expandedNodes', expanded);
1636
- },
1637
-
1638
- // ── Preview ──────────────────────────────────────────────
1639
-
1640
- async fetchActivePreviews() {
1641
- try {
1642
- const data = await api.get('/preview');
1643
- const previews = data.previews || [];
1644
- if (previews.length > 0) {
1645
- const updates = {};
1646
- for (const p of previews) {
1647
- updates[p.teamId] = { url: `/api/preview/${p.teamId}/proxy/`, kind: p.kind, active: true };
1648
- }
1649
- const most = previews.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))[0];
1650
- set((s) => ({
1651
- teamPreviews: { ...s.teamPreviews, ...updates },
1652
- previewState: { url: `/api/preview/${most.teamId}/proxy/`, teamId: most.teamId, kind: most.kind, deviceSize: 'desktop', screenshotMode: false },
1653
- showPreviewInAgents: true,
1654
- }));
1655
- }
1656
- } catch {}
1657
- },
1658
-
1659
- openPreview(url, teamId, kind) {
1660
- set((s) => ({
1661
- previewState: { url, teamId, kind, deviceSize: 'desktop', screenshotMode: false },
1662
- teamPreviews: { ...s.teamPreviews, [teamId]: { url, kind, active: true } },
1663
- previewChat: [],
1664
- showPreviewInAgents: true,
1665
- }));
1666
- },
1667
- closePreview() {
1668
- set({ showPreviewInAgents: false });
1669
- },
1670
- stopPreview() {
1671
- const { previewState } = get();
1672
- if (previewState.teamId) {
1673
- api.delete(`/preview/${previewState.teamId}`).catch(() => {});
1674
- set((s) => ({
1675
- teamPreviews: {
1676
- ...s.teamPreviews,
1677
- [previewState.teamId]: { ...s.teamPreviews[previewState.teamId], active: false },
1678
- },
1679
- showPreviewInAgents: false,
1680
- }));
1681
- }
1682
- },
1683
- async relaunchPreview(teamId) {
1684
- try {
1685
- const result = await api.post(`/preview/${teamId}/launch`);
1686
- if (result.launched) {
1687
- const proxyUrl = `/api/preview/${teamId}/proxy/`;
1688
- set((s) => ({
1689
- previewState: { url: proxyUrl, teamId, kind: result.kind, deviceSize: 'desktop', screenshotMode: false },
1690
- teamPreviews: { ...s.teamPreviews, [teamId]: { url: proxyUrl, kind: result.kind, active: true } },
1691
- showPreviewInAgents: true,
1692
- }));
1693
- } else {
1694
- get().addToast('warning', 'Preview could not launch', result.reason ? String(result.reason).slice(0, 200) : 'Build or server failed');
1695
- }
1696
- } catch (err) {
1697
- get().addToast('error', 'Failed to launch preview', err.message);
1698
- }
1699
- },
1700
- togglePreviewInAgents() {
1701
- set((s) => ({ showPreviewInAgents: !s.showPreviewInAgents }));
1702
- },
1703
- setPreviewDevice(size) {
1704
- set((s) => ({ previewState: { ...s.previewState, deviceSize: size } }));
1705
- },
1706
- toggleScreenshotMode() {
1707
- set((s) => ({ previewState: { ...s.previewState, screenshotMode: !s.previewState.screenshotMode } }));
1708
- },
1709
- async iteratePreview(message, screenshotBase64) {
1710
- const { previewState } = get();
1711
- if (!previewState.teamId) return;
1712
-
1713
- const userMsg = { role: 'user', content: message, screenshot: screenshotBase64 || null, timestamp: Date.now() };
1714
- set((s) => ({ previewChat: [...s.previewChat, userMsg], previewIterating: true }));
1715
-
1716
- try {
1717
- const body = { message };
1718
- if (screenshotBase64) body.screenshot = screenshotBase64;
1719
- const res = await api.post(`/preview/${previewState.teamId}/iterate`, body);
1720
- const assistantMsg = { role: 'assistant', content: res.response || res.message || 'Changes routed to planner.', timestamp: Date.now() };
1721
- set((s) => ({ previewChat: [...s.previewChat, assistantMsg], previewIterating: false }));
1722
- } catch (err) {
1723
- const errMsg = { role: 'assistant', content: `Failed to iterate: ${err.message}`, timestamp: Date.now() };
1724
- set((s) => ({ previewChat: [...s.previewChat, errMsg], previewIterating: false }));
1725
- }
1726
- },
1727
- addPreviewChatMessage(role, content, screenshot) {
1728
- const msg = { role, content, screenshot: screenshot || null, timestamp: Date.now() };
1729
- set((s) => ({ previewChat: [...s.previewChat, msg] }));
1730
- },
1731
- clearPreviewChat() {
1732
- set({ previewChat: [] });
1733
- },
1734
-
1735
- // ── Toasts ────────────────────────────────────────────────
1736
-
1737
- addToast(type, message, detail, action, options = {}) {
1738
- const id = ++toastCounter;
1739
- const persistent = !!options.persistent;
1740
- const duration = options.duration;
1741
- const actions = options.actions;
1742
- set((s) => ({ toasts: [...s.toasts, { id, type, message, detail, action, actions, persistent, duration }] }));
1743
- },
1744
- removeToast(id) {
1745
- set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
1746
- },
1747
-
1748
- installUpdate() {
1749
- window.groove?.update?.installUpdate();
1750
- },
1751
- setUpdateModalOpen(open) {
1752
- set({ updateModalOpen: open });
1753
- },
1754
- checkForUpdate() {
1755
- window.groove?.update?.checkForUpdate();
1756
- },
1757
-
1758
- // ── Marketplace Auth ────────────────────────────────────────
1759
-
1760
- async checkMarketplaceAuth() {
1761
- try {
1762
- const data = await api.get('/auth/status');
1763
- set({
1764
- marketplaceAuthenticated: data.authenticated || false,
1765
- marketplaceUser: data.user || null,
1766
- });
1767
- try {
1768
- const edition = await api.get('/edition');
1769
- set({
1770
- edition: edition.edition || 'community',
1771
- subscription: {
1772
- plan: edition.plan || 'community',
1773
- status: edition.status || (edition.subscriptionActive ? 'active' : 'none'),
1774
- active: edition.subscriptionActive === true,
1775
- features: edition.features || [],
1776
- seats: edition.seats || 1,
1777
- periodEnd: edition.periodEnd || null,
1778
- cancelAtPeriodEnd: edition.cancelAtPeriodEnd || false,
1779
- },
1780
- });
1781
- } catch { /* edition endpoint may not exist */ }
1782
- } catch {
1783
- set({ marketplaceAuthenticated: false, marketplaceUser: null });
1784
- }
1785
- },
1786
-
1787
- async marketplaceLogin() {
1788
- try {
1789
- const data = await api.get('/auth/login-url');
1790
- if (data.url) window.open(data.url, '_blank');
1791
- // Poll for auth completion (user logs in via browser)
1792
- const poll = setInterval(async () => {
1793
- try {
1794
- const status = await api.get('/auth/status');
1795
- if (status.authenticated) {
1796
- clearInterval(poll);
1797
- set({ marketplaceAuthenticated: true, marketplaceUser: status.user });
1798
- get().addToast('success', `Signed in as ${status.user?.displayName || status.user?.id || 'user'}`);
1799
- try {
1800
- const edition = await api.get('/edition');
1801
- set({
1802
- edition: edition.edition || 'community',
1803
- subscription: {
1804
- plan: edition.plan || 'community',
1805
- status: edition.status || (edition.subscriptionActive ? 'active' : 'none'),
1806
- active: edition.subscriptionActive === true,
1807
- features: edition.features || [],
1808
- seats: edition.seats || 1,
1809
- periodEnd: edition.periodEnd || null,
1810
- cancelAtPeriodEnd: edition.cancelAtPeriodEnd || false,
1811
- },
1812
- });
1813
- } catch { /* edition endpoint may not exist */ }
1814
- setTimeout(async () => {
1815
- try {
1816
- const e = await api.get('/edition');
1817
- set({
1818
- edition: e.edition || 'community',
1819
- subscription: {
1820
- plan: e.plan || 'community',
1821
- status: e.status || (e.subscriptionActive ? 'active' : 'none'),
1822
- active: e.subscriptionActive === true,
1823
- features: e.features || [],
1824
- seats: e.seats || 1,
1825
- periodEnd: e.periodEnd || null,
1826
- cancelAtPeriodEnd: e.cancelAtPeriodEnd || false,
1827
- },
1828
- });
1829
- } catch { /* delayed re-fetch may fail */ }
1830
- }, 2000);
1831
- }
1832
- } catch { /* keep polling */ }
1833
- }, 2000);
1834
- // Stop polling after 5 minutes
1835
- setTimeout(() => clearInterval(poll), 300000);
1836
- } catch (err) {
1837
- get().addToast('error', 'Login failed', err.message);
1838
- }
1839
- },
1840
-
1841
- async marketplaceLogout() {
1842
- try {
1843
- await api.post('/auth/logout');
1844
- set({ marketplaceAuthenticated: false, marketplaceUser: null });
1845
- get().addToast('info', 'Signed out of marketplace');
1846
- } catch (err) {
1847
- get().addToast('error', 'Logout failed', err.message);
1848
- }
1849
- },
1850
-
1851
- async marketplaceCheckout(skillId) {
1852
- try {
1853
- const data = await api.post('/auth/checkout', { skillId });
1854
- if (data.url) window.open(data.url, '_blank');
1855
- return data;
1856
- } catch (err) {
1857
- get().addToast('error', 'Checkout failed', err.message);
1858
- throw err;
1859
- }
1860
- },
1861
-
1862
- // ── Subscription ────────────────────────────────────────────
1863
-
1864
- async fetchSubscriptionPlans() {
1865
- return api.get('/subscription/plans');
1866
- },
1867
-
1868
- async startCheckout(priceId) {
1869
- try {
1870
- const data = await api.post('/subscription/checkout', { priceId });
1871
- if (data.url) {
1872
- if (window.groove?.openExternal) {
1873
- window.groove.openExternal(data.url);
1874
- } else {
1875
- window.open(data.url, '_blank');
1876
- }
1877
- }
1878
- return data;
1879
- } catch (err) {
1880
- if (err.status === 401 || err.message?.includes('Not authenticated')) {
1881
- get().addToast('info', 'Please sign in to subscribe');
1882
- get().marketplaceLogin();
1883
- } else if (err.status === 409) {
1884
- get().addToast('info', 'Already subscribed', 'Use Manage Subscription to switch plans');
1885
- } else {
1886
- get().addToast('error', 'Checkout failed', err.message);
1887
- }
1888
- throw err;
1889
- }
1890
- },
1891
-
1892
- async openPortal() {
1893
- try {
1894
- const data = await api.post('/subscription/portal');
1895
- if (data.url) {
1896
- if (window.groove?.openExternal) {
1897
- window.groove.openExternal(data.url);
1898
- } else {
1899
- window.open(data.url, '_blank');
1900
- }
1901
- }
1902
- return data;
1903
- } catch (err) {
1904
- get().addToast('error', 'Portal failed', err.message);
1905
- throw err;
1906
- }
1907
- },
1908
-
1909
- async updateSeats(seats) {
1910
- try {
1911
- const data = await api.patch('/subscription', { seats });
1912
- set({ subscription: { ...get().subscription, ...data } });
1913
- get().addToast('success', `Updated to ${seats} seat${seats !== 1 ? 's' : ''}`);
1914
- return data;
1915
- } catch (err) {
1916
- get().addToast('error', 'Seat update failed', err.message);
1917
- throw err;
1918
- }
1919
- },
1920
-
1921
- // ── Approvals ──────────────────────────────────────────────
1922
-
1923
- async fetchApprovals() {
1924
- try {
1925
- const data = await api.get('/approvals');
1926
- set({
1927
- pendingApprovals: data.pending || [],
1928
- resolvedApprovals: data.resolved || [],
1929
- });
1930
- } catch { /* ignore */ }
1931
- },
1932
-
1933
- async approveRequest(id) {
1934
- try {
1935
- await api.post(`/approvals/${encodeURIComponent(id)}/approve`);
1936
- set((s) => ({ pendingApprovals: s.pendingApprovals.filter((a) => a.id !== id) }));
1937
- get().addToast('success', 'Approved');
1938
- } catch (err) {
1939
- get().addToast('error', 'Approve failed', err.message);
1940
- }
1941
- },
1942
-
1943
- async rejectRequest(id, reason = '') {
1944
- try {
1945
- await api.post(`/approvals/${encodeURIComponent(id)}/reject`, { reason });
1946
- set((s) => ({ pendingApprovals: s.pendingApprovals.filter((a) => a.id !== id) }));
1947
- get().addToast('info', 'Rejected');
1948
- } catch (err) {
1949
- get().addToast('error', 'Reject failed', err.message);
1950
- }
1951
- },
1952
-
1953
- // ── Recommended Team ──────────────────────────────────────
1954
-
1955
- async checkRecommendedTeam() {
1956
- try {
1957
- const data = await api.get('/recommended-team');
1958
- if (!data || !data.agents?.length) {
1959
- set({ recommendedTeam: null });
1960
- return;
1961
- }
1962
-
1963
- // Check if all recommended roles already exist in the planner's team.
1964
- // If so, auto-delegate instead of showing the "Launch Team" modal.
1965
- const teamId = data.teamId || null;
1966
-
1967
- if (teamId) {
1968
- const teamAgents = get().agents.filter((a) => a.teamId === teamId && a.role !== 'planner');
1969
- const phase1Roles = data.agents.filter((a) => !a.phase || a.phase === 1).map((a) => a.role);
1970
- const allExist = phase1Roles.every((role) => teamAgents.some((a) => a.role === role));
1971
-
1972
- if (allExist && phase1Roles.length > 0) {
1973
- // Guard: skip if already delegating for this team (poll race)
1974
- if (get()._delegatingTeamIds.has(teamId)) return;
1975
- set((s) => ({ recommendedTeam: null, _delegatingTeamIds: new Set([...s._delegatingTeamIds, teamId]) }));
1976
- try {
1977
- const tlc = get().teamLaunchConfig;
1978
- const result = await api.post('/recommended-team/launch', {
1979
- teamId,
1980
- ...(tlc?.provider && { teamProvider: tlc.provider }),
1981
- ...(tlc?.model && { teamModel: tlc.model }),
1982
- ...(tlc?.reasoningEffort != null && { teamReasoningEffort: tlc.reasoningEffort }),
1983
- ...(tlc?.temperature != null && { teamTemperature: tlc.temperature }),
1984
- });
1985
- const agents = result.agents || [];
1986
- const failures = result.failed || [];
1987
- const names = agents.map((a) => a.name).join(', ') || '';
1988
-
1989
- if (agents.length === 0 && failures.length > 0) {
1990
- get().addToast('error', 'Delegation failed', failures.map(f => f.role + ': ' + f.error).join(', '));
1991
- } else {
1992
- get().addToast('success', 'Planner delegated work', names ? `→ ${names}` : undefined);
1993
- if (failures.length > 0) {
1994
- get().addToast('warning', `${failures.length} agent(s) failed to spawn`, failures.map(f => f.role + ': ' + f.error).join(', '));
1995
- }
1996
- }
1997
- if (agents.length > 0) {
1998
- set((s) => ({
1999
- thinkingAgents: new Set([...s.thinkingAgents, ...agents.map((a) => a.id)]),
2000
- }));
2001
- }
2002
- } finally {
2003
- set((s) => {
2004
- const next = new Set(s._delegatingTeamIds);
2005
- next.delete(teamId);
2006
- return { _delegatingTeamIds: next };
2007
- });
2008
- }
2009
- return;
2010
- }
2011
- }
2012
-
2013
- // New agents needed — show the modal for approval
2014
- set({ recommendedTeam: { ...data, teamId: data.teamId || null } });
2015
- } catch {
2016
- set({ recommendedTeam: null });
2017
- }
2018
- },
2019
-
2020
- async launchRecommendedTeam(modifiedAgents) {
2021
- try {
2022
- const teamId = get().recommendedTeam?.teamId || null;
2023
- const tlc = get().teamLaunchConfig;
2024
- set({ recommendedTeam: null }); // Dismiss modal immediately
2025
- get().addToast('info', 'Launching team...');
2026
- const body = {
2027
- ...(modifiedAgents && { agents: modifiedAgents }),
2028
- ...(teamId && { teamId }),
2029
- ...(tlc?.provider && { teamProvider: tlc.provider }),
2030
- ...(tlc?.model && { teamModel: tlc.model }),
2031
- ...(tlc?.reasoningEffort != null && { teamReasoningEffort: tlc.reasoningEffort }),
2032
- ...(tlc?.temperature != null && { teamTemperature: tlc.temperature }),
2033
- ...(tlc?.verbosity != null && { teamVerbosity: tlc.verbosity }),
2034
- };
2035
- const result = await api.post('/recommended-team/launch', body);
2036
- const totalOk = (result.launched || 0) + (result.reused || 0);
2037
- const failures = result.failed || [];
2038
-
2039
- if (totalOk === 0 && failures.length > 0) {
2040
- get().addToast('error', 'Team launch failed', failures.map(f => f.role + ': ' + f.error).join(', '));
2041
- } else {
2042
- const sub = [
2043
- result.phase2Pending ? `${result.phase2Pending} QC queued` : '',
2044
- result.projectDir ? `→ ${result.projectDir}/` : '',
2045
- ].filter(Boolean).join(' · ');
2046
- get().addToast('success', `Launched ${totalOk} agents`, sub || undefined);
2047
- if (failures.length > 0) {
2048
- get().addToast('warning', `${failures.length} agent(s) failed to spawn`, failures.map(f => f.role + ': ' + f.error).join(', '));
2049
- }
2050
- }
2051
- // Set thinking indicator for all launched/reused agents
2052
- const launchedAgents = result.agents || [];
2053
- if (launchedAgents.length > 0) {
2054
- set((s) => ({
2055
- thinkingAgents: new Set([...s.thinkingAgents, ...launchedAgents.map((a) => a.id)]),
2056
- }));
2057
- }
2058
- // Clean up stale files — scoped to the launched team so plans in other
2059
- // teams' workspaces survive. The launch endpoint already unlinks the
2060
- // exact plan it read; this is a belt-and-suspenders sweep.
2061
- const launchedTeamId = body?.teamId || result?.teamId || null;
2062
- if (launchedTeamId) {
2063
- api.post('/cleanup', { teamId: launchedTeamId }).catch(() => {});
2064
- }
2065
- return result;
2066
- } catch (err) {
2067
- get().addToast('error', 'Launch failed', err.message);
2068
- throw err;
2069
- }
2070
- },
2071
-
2072
- // ── Team Builder ──────────────────────────────────────────
2073
-
2074
- openTeamBuilder() { set({ teamBuilderOpen: true }); },
2075
- closeTeamBuilder() {
2076
- set({
2077
- teamBuilderOpen: false,
2078
- teamBuilderRoles: [],
2079
- teamBuilderSettings: { provider: null, model: null, reasoningEffort: 50, temperature: 0.5 },
2080
- teamBuilderTask: '',
2081
- });
2082
- },
2083
- addTeamBuilderRole(role) {
2084
- set((s) => ({
2085
- teamBuilderRoles: [...s.teamBuilderRoles, {
2086
- role, name: '', provider: null, model: null,
2087
- reasoningEffort: null, temperature: null, prompt: '',
2088
- }],
2089
- }));
2090
- },
2091
- removeTeamBuilderRole(index) {
2092
- set((s) => ({ teamBuilderRoles: s.teamBuilderRoles.filter((_, i) => i !== index) }));
2093
- },
2094
- updateTeamBuilderRole(index, updates) {
2095
- set((s) => ({
2096
- teamBuilderRoles: s.teamBuilderRoles.map((r, i) => i === index ? { ...r, ...updates } : r),
2097
- }));
2098
- },
2099
- applyTemplate(template) {
2100
- set({
2101
- teamBuilderRoles: (template.roles || []).map((r) => ({
2102
- role: typeof r === 'string' ? r : r.role,
2103
- name: '', provider: null, model: null,
2104
- reasoningEffort: null, temperature: null, prompt: '',
2105
- })),
2106
- });
2107
- },
2108
- setTeamBuilderSettings(settings) {
2109
- set((s) => ({ teamBuilderSettings: { ...s.teamBuilderSettings, ...settings } }));
2110
- },
2111
- setTeamBuilderTask(task) { set({ teamBuilderTask: task }); },
2112
-
2113
- async fetchTeamTemplates() {
2114
- try {
2115
- const data = await api.get('/team-templates');
2116
- const builtIn = [];
2117
- const custom = [];
2118
- for (const [key, tmpl] of Object.entries(data || {})) {
2119
- const entry = { ...tmpl, name: key };
2120
- if (tmpl.builtIn) builtIn.push(entry);
2121
- else custom.push(entry);
2122
- }
2123
- set({ teamTemplates: { builtIn, custom } });
2124
- } catch { /* endpoint may not exist yet */ }
2125
- },
2126
-
2127
- async saveTeamTemplate(name) {
2128
- try {
2129
- const { teamBuilderRoles, teamBuilderSettings } = get();
2130
- await api.post('/team-templates', {
2131
- name,
2132
- roles: teamBuilderRoles.map((r) => r.role),
2133
- settings: teamBuilderSettings,
2134
- });
2135
- get().addToast('success', `Template "${name}" saved`);
2136
- get().fetchTeamTemplates();
2137
- } catch (err) {
2138
- get().addToast('error', 'Failed to save template', err.message);
2139
- }
2140
- },
2141
-
2142
- async deleteTeamTemplate(name) {
2143
- try {
2144
- await api.delete(`/team-templates/${encodeURIComponent(name)}`);
2145
- get().addToast('info', `Template "${name}" deleted`);
2146
- get().fetchTeamTemplates();
2147
- } catch (err) {
2148
- get().addToast('error', 'Failed to delete template', err.message);
2149
- }
2150
- },
2151
-
2152
- async launchTeamBuilder() {
2153
- const { teamBuilderRoles, teamBuilderSettings, teamBuilderTask, activeTeamId } = get();
2154
- if (teamBuilderRoles.length === 0) return;
2155
- set({ teamLaunchConfig: {
2156
- provider: teamBuilderSettings.provider || null,
2157
- model: teamBuilderSettings.model || null,
2158
- reasoningEffort: teamBuilderSettings.reasoningEffort,
2159
- temperature: teamBuilderSettings.temperature,
2160
- }});
2161
- get().closeTeamBuilder();
2162
- try {
2163
- const body = {
2164
- task: teamBuilderTask,
2165
- roles: teamBuilderRoles,
2166
- settings: teamBuilderSettings,
2167
- launchMode: 'plan-first',
2168
- teamId: activeTeamId,
2169
- };
2170
- const result = await api.post('/team-builder/launch', body);
2171
- get().addToast('success', 'Planner spawned — team will build automatically');
2172
- return result;
2173
- } catch (err) {
2174
- get().addToast('error', 'Team launch failed', err.message);
2175
- throw err;
2176
- }
2177
- },
2178
-
2179
- // ── GitHub Repo Import ────────────────────────────────────
2180
-
2181
- async fetchImportedRepos() {
2182
- try {
2183
- const repos = await api.get('/repos/imported');
2184
- set({ importedRepos: repos });
2185
- } catch { /* ignore */ }
2186
- },
2187
-
2188
- async previewRepo(repoUrl) {
2189
- return api.post('/repos/preview', { repoUrl });
2190
- },
2191
-
2192
- async importRepo(repoUrl, targetPath, createTeam, teamName) {
2193
- set({ importInProgress: true });
2194
- try {
2195
- const result = await api.post('/repos/import', { repoUrl, targetPath, createTeam, teamName });
2196
- get().fetchImportedRepos();
2197
- return result;
2198
- } finally {
2199
- set({ importInProgress: false });
2200
- }
2201
- },
2202
-
2203
- async softRemoveRepo(importId) {
2204
- await api.delete(`/repos/${encodeURIComponent(importId)}/remove`);
2205
- get().fetchImportedRepos();
2206
- },
2207
-
2208
- async hardNukeRepo(importId, deleteFiles = true) {
2209
- await api.delete(`/repos/${encodeURIComponent(importId)}/nuke?deleteFiles=${deleteFiles}`);
2210
- get().fetchImportedRepos();
2211
- },
2212
-
2213
- // ── Project Directory ────────────────────────────────────
2214
-
2215
- async fetchProjectDir() {
2216
- try {
2217
- const data = await api.get('/project-dir');
2218
- const isHome = /^\/home\/[^/]+$/.test(data.projectDir) || data.projectDir === '/root';
2219
- set({
2220
- projectDir: data.projectDir,
2221
- recentProjects: data.recentProjects || [],
2222
- showProjectPicker: isHome || (data.recentProjects || []).length === 0,
2223
- editorTreeCache: {},
2224
- });
2225
- } catch {}
2226
- },
2227
-
2228
- async setProjectDir(path) {
2229
- const data = await api.post('/project-dir', { path });
2230
- try { await api.post('/files/root', { root: data.projectDir }); } catch {}
2231
- set({
2232
- projectDir: data.projectDir,
2233
- recentProjects: data.recentProjects || [],
2234
- showProjectPicker: false,
2235
- editorTreeCache: {},
2236
- });
2237
- get().fetchTreeDir('');
2238
- },
2239
-
2240
- async removeRecentProject(path) {
2241
- try {
2242
- await api.delete('/projects/recent', { path });
2243
- } catch {}
2244
- get().fetchProjectDir();
2245
- },
2246
-
2247
- toggleProjectPicker() {
2248
- set((s) => ({ showProjectPicker: !s.showProjectPicker }));
2249
- },
2250
-
2251
- // ── Tunnels ──────────────────────────────────────────────
2252
-
2253
- async fetchTunnels() {
2254
- try {
2255
- const tunnels = await api.get('/tunnels');
2256
- set({ savedTunnels: Array.isArray(tunnels) ? tunnels : [] });
2257
- } catch {}
2258
- },
2259
-
2260
- async saveTunnel(config) {
2261
- const result = await api.post('/tunnels', config);
2262
- get().fetchTunnels();
2263
- return result;
2264
- },
2265
-
2266
- async updateTunnel(id, config) {
2267
- const result = await api.patch(`/tunnels/${encodeURIComponent(id)}`, config);
2268
- get().fetchTunnels();
2269
- return result;
2270
- },
2271
-
2272
- async deleteTunnel(id) {
2273
- await api.delete(`/tunnels/${encodeURIComponent(id)}`);
2274
- get().fetchTunnels();
2275
- },
2276
-
2277
- async testTunnel(id) {
2278
- return api.post(`/tunnels/${encodeURIComponent(id)}/test`);
2279
- },
2280
-
2281
- async connectTunnel(id) {
2282
- try {
2283
- const result = await api.post(`/tunnels/${encodeURIComponent(id)}/connect`);
2284
- get().fetchTunnels();
2285
- if (result.localPort && result.name) {
2286
- if (window.groove?.remote?.openWindow) {
2287
- window.groove.remote.openWindow(result.localPort, result.name);
2288
- } else {
2289
- window.open(`http://localhost:${result.localPort}?instance=${encodeURIComponent(result.name)}`, '_blank');
2290
- }
2291
- }
2292
- return result;
2293
- } finally {
2294
- set({ tunnelConnectStep: null });
2295
- }
2296
- },
2297
-
2298
- async upgradeTunnel(id) {
2299
- return api.post(`/tunnels/${encodeURIComponent(id)}/upgrade`);
2300
- },
2301
-
2302
- async disconnectTunnel(id) {
2303
- const tunnel = get().savedTunnels.find(t => t.id === id);
2304
- await api.post(`/tunnels/${encodeURIComponent(id)}/disconnect`);
2305
- get().fetchTunnels();
2306
- if (tunnel?.localPort && window.groove?.remote?.closeByPort) {
2307
- window.groove.remote.closeByPort(tunnel.localPort);
2308
- }
2309
- },
2310
-
2311
- async installTunnel(id) {
2312
- return api.post(`/tunnels/${encodeURIComponent(id)}/install`);
2313
- },
2314
-
2315
- async startTunnel(id) {
2316
- return api.post(`/tunnels/${encodeURIComponent(id)}/start`);
2317
- },
2318
-
2319
- // ── Journalist ────────────────────────────────────────────
2320
-
2321
- async fetchJournalist() {
2322
- try {
2323
- const data = await api.get('/journalist');
2324
- set({ journalistStatus: data });
2325
- return data;
2326
- } catch { return null; }
2327
- },
2328
-
2329
- async triggerJournalistCycle() {
2330
- try {
2331
- const data = await api.post('/journalist/cycle');
2332
- get().addToast('success', 'Synthesis cycle triggered');
2333
- set({ journalistStatus: data });
2334
- return data;
2335
- } catch (err) {
2336
- get().addToast('error', 'Synthesis failed', err.message);
2337
- throw err;
2338
- }
2339
- },
2340
-
2341
- // ── Agent Actions ─────────────────────────────────────────
2342
-
2343
- async spawnAgent(config) {
2344
- try {
2345
- const teamId = get().activeTeamId;
2346
- const agent = await api.post('/agents', { ...config, teamId });
2347
- get().addToast('success', `Spawned ${agent.name}`);
2348
- return agent;
2349
- } catch (err) {
2350
- let detail = err.message;
2351
- if (detail?.includes('workingDir must be within project directory')) {
2352
- const projDir = get().projectDir || 'unknown';
2353
- const workDir = config.workingDir || 'default';
2354
- detail = `workingDir "${workDir}" is outside project directory "${projDir}". Change the project directory or pick a subfolder within it.`;
2355
- }
2356
- get().addToast('error', 'Spawn failed', detail);
2357
- throw err;
2358
- }
2359
- },
2360
-
2361
- async killAgent(id, purge = false) {
2362
- try {
2363
- await api.delete(`/agents/${encodeURIComponent(id)}?purge=${purge}`);
2364
- if (purge) {
2365
- set((s) => {
2366
- const chatHistory = { ...s.chatHistory };
2367
- const activityLog = { ...s.activityLog };
2368
- const tokenTimeline = { ...s.tokenTimeline };
2369
- delete chatHistory[id];
2370
- delete activityLog[id];
2371
- delete tokenTimeline[id];
2372
- persistJSON('groove:chatHistory', chatHistory);
2373
- persistJSON('groove:activityLog', activityLog);
2374
- return { chatHistory, activityLog, tokenTimeline };
2375
- });
2376
- }
2377
- } catch (err) {
2378
- get().addToast('error', 'Kill failed', err.message);
2379
- }
2380
- },
2381
-
2382
- async rotateAgent(id) {
2383
- try {
2384
- return await api.post(`/agents/${encodeURIComponent(id)}/rotate`);
2385
- } catch (err) {
2386
- get().addToast('error', 'Rotation failed', err.message);
2387
- throw err;
2388
- }
2389
- },
2390
-
2391
- async fetchProviders() {
2392
- return api.get('/providers');
2393
- },
2394
-
2395
- // ── Local Models (Ollama) ─────────────────────────────────
2396
-
2397
- async fetchOllamaStatus() {
2398
- try {
2399
- const check = await api.post('/providers/ollama/check');
2400
- const updates = {
2401
- ollamaStatus: { installed: check.installed, serverRunning: check.serverRunning, hardware: check.hardware },
2402
- };
2403
- if (check.installed) {
2404
- try {
2405
- const models = await api.get('/providers/ollama/models');
2406
- updates.ollamaInstalledModels = models.installed || [];
2407
- updates.ollamaCatalog = models.catalog || [];
2408
- } catch {}
2409
- }
2410
- if (check.serverRunning) {
2411
- try {
2412
- const running = await api.get('/providers/ollama/running');
2413
- updates.ollamaRunningModels = running.models || [];
2414
- } catch {
2415
- updates.ollamaRunningModels = [];
2416
- }
2417
- } else {
2418
- updates.ollamaRunningModels = [];
2419
- }
2420
- set(updates);
2421
- return updates.ollamaStatus;
2422
- } catch {
2423
- return get().ollamaStatus;
2424
- }
2425
- },
2426
-
2427
- async startOllamaServer() {
2428
- try {
2429
- const result = await api.post('/providers/ollama/serve');
2430
- if (result.ok) {
2431
- get().addToast('success', 'Ollama server started');
2432
- await new Promise((r) => setTimeout(r, 2000));
2433
- await get().fetchOllamaStatus();
2434
- }
2435
- return result;
2436
- } catch (err) {
2437
- get().addToast('error', 'Could not start server', err.message);
2438
- throw err;
2439
- }
2440
- },
2441
-
2442
- async stopOllamaServer() {
2443
- try {
2444
- const result = await api.post('/providers/ollama/stop');
2445
- if (result.ok) {
2446
- get().addToast('info', 'Ollama server stopped');
2447
- set((s) => ({
2448
- ollamaStatus: { ...s.ollamaStatus, serverRunning: false },
2449
- ollamaRunningModels: [],
2450
- }));
2451
- }
2452
- return result;
2453
- } catch (err) {
2454
- get().addToast('error', 'Stop failed', err.message);
2455
- throw err;
2456
- }
2457
- },
2458
-
2459
- async restartOllamaServer() {
2460
- try {
2461
- const result = await api.post('/providers/ollama/restart');
2462
- if (result.ok) {
2463
- get().addToast('success', 'Ollama server restarted');
2464
- await new Promise((r) => setTimeout(r, 2000));
2465
- await get().fetchOllamaStatus();
2466
- }
2467
- return result;
2468
- } catch (err) {
2469
- get().addToast('error', 'Restart failed', err.message);
2470
- throw err;
2471
- }
2472
- },
2473
-
2474
- async pullOllamaModel(modelId) {
2475
- try {
2476
- set((s) => ({ ollamaPullProgress: { ...s.ollamaPullProgress, [modelId]: { status: 'pulling', progress: '' } } }));
2477
- await api.post('/providers/ollama/pull', { model: modelId });
2478
- set((s) => {
2479
- const progress = { ...s.ollamaPullProgress };
2480
- delete progress[modelId];
2481
- return { ollamaPullProgress: progress };
2482
- });
2483
- get().addToast('success', `${modelId} ready to use`);
2484
- get().fetchOllamaStatus();
2485
- } catch (err) {
2486
- set((s) => {
2487
- const progress = { ...s.ollamaPullProgress };
2488
- delete progress[modelId];
2489
- return { ollamaPullProgress: progress };
2490
- });
2491
- get().addToast('error', `Pull failed: ${err.message}`);
2492
- }
2493
- },
2494
-
2495
- async deleteOllamaModel(modelId) {
2496
- try {
2497
- await api.delete(`/providers/ollama/models/${encodeURIComponent(modelId)}`);
2498
- set((s) => ({ ollamaInstalledModels: s.ollamaInstalledModels.filter((m) => m.id !== modelId) }));
2499
- get().addToast('success', `Removed ${modelId}`);
2500
- } catch (err) {
2501
- get().addToast('error', `Delete failed: ${err.message}`);
2502
- }
2503
- },
2504
-
2505
- async loadOllamaModel(modelId) {
2506
- try {
2507
- await api.post('/providers/ollama/load', { model: modelId });
2508
- get().addToast('success', `${modelId} loaded into memory`);
2509
- get().fetchOllamaStatus();
2510
- } catch (err) {
2511
- get().addToast('error', `Could not load model: ${err.message}`);
2512
- }
2513
- },
2514
-
2515
- async unloadOllamaModel(modelId) {
2516
- try {
2517
- await api.post('/providers/ollama/unload', { model: modelId });
2518
- set((s) => ({ ollamaRunningModels: s.ollamaRunningModels.filter((m) => m.name !== modelId) }));
2519
- get().addToast('info', `${modelId} unloaded`);
2520
- } catch (err) {
2521
- get().addToast('error', `Unload failed: ${err.message}`);
2522
- }
2523
- },
2524
-
2525
- spawnFromModel(modelId) {
2526
- get().openDetail({ type: 'spawn', presetProvider: 'ollama', presetModel: modelId });
2527
- },
2528
-
2529
- // ── Onboarding ────────────────────────────────────────────
2530
-
2531
- async fetchOnboardingStatus() {
2532
- try {
2533
- const data = await api.get('/onboarding/status');
2534
- if (data?.complete) {
2535
- set({ onboardingComplete: true });
2536
- localStorage.setItem('groove:onboardingComplete', 'true');
2537
- }
2538
- return data;
2539
- } catch {
2540
- return null;
2541
- }
2542
- },
2543
-
2544
- dismissOnboarding() {
2545
- set({ onboardingComplete: true });
2546
- localStorage.setItem('groove:onboardingComplete', 'true');
2547
- api.post('/onboarding/dismiss').catch(() => {});
2548
- },
2549
-
2550
- // ── Provider Setup (Settings) ──────────────────────────────
2551
-
2552
- providerInstallProgress: {},
2553
-
2554
- async installProvider(providerId) {
2555
- const update = (patch) => set((s) => ({
2556
- providerInstallProgress: {
2557
- ...s.providerInstallProgress,
2558
- [providerId]: { ...s.providerInstallProgress[providerId], ...patch },
2559
- },
2560
- }));
2561
-
2562
- update({ installing: true, percent: 0, message: 'Starting install...', error: null, done: false });
2563
-
2564
- try {
2565
- const res = await fetch(`/api/providers/${encodeURIComponent(providerId)}/install`, {
2566
- method: 'POST',
2567
- headers: { 'Content-Type': 'application/json' },
2568
- });
2569
- if (!res.ok) {
2570
- const err = await res.text();
2571
- throw new Error(err || `Install failed (${res.status})`);
2572
- }
2573
-
2574
- let body;
2575
- try {
2576
- body = await res.text();
2577
- } catch (e) {
2578
- throw new Error(`Failed to read response: ${e.message}`);
2579
- }
2580
-
2581
- let lastError = null;
2582
- let completed = false;
2583
- for (const line of body.split('\n')) {
2584
- if (!line.trim()) continue;
2585
- try {
2586
- const ev = JSON.parse(line);
2587
- const isError = ev.status === 'error';
2588
- const isDone = ev.status === 'complete';
2589
- if (isError) lastError = ev.output || 'Install failed';
2590
- if (isDone) completed = true;
2591
- update({
2592
- percent: ev.progress ?? get().providerInstallProgress[providerId]?.percent ?? 0,
2593
- message: ev.output || get().providerInstallProgress[providerId]?.message,
2594
- error: isError ? (ev.output || 'Install failed') : null,
2595
- done: isDone,
2596
- installing: !isDone && !isError,
2597
- });
2598
- } catch { /* skip malformed line */ }
2599
- }
2600
-
2601
- if (lastError) throw new Error(lastError);
2602
- if (!completed) throw new Error(body.slice(0, 500) || 'Install ended without confirmation');
2603
-
2604
- update({ installing: false, percent: 100, message: 'Installed', error: null, done: true });
2605
- set({ _providerRefreshTick: Date.now() });
2606
- get().addToast('success', `${providerId} installed`);
2607
- } catch (err) {
2608
- update({ installing: false, percent: 0, message: null, error: err.message, done: false });
2609
- get().addToast('error', `Install failed: ${providerId}`, err.message);
2610
- throw err;
2611
- }
2612
- },
2613
-
2614
- async loginProvider(providerId, body) {
2615
- try {
2616
- const data = await api.post(`/providers/${encodeURIComponent(providerId)}/login`, body);
2617
- if (data?.url && !data?.browserOpened) window.open(data.url, '_blank');
2618
- return data;
2619
- } catch (err) {
2620
- get().addToast('error', `Login failed`, err.message);
2621
- throw err;
2622
- }
2623
- },
2624
-
2625
- async setProviderPath(providerId, path) {
2626
- try {
2627
- await api.post(`/providers/${encodeURIComponent(providerId)}/set-path`, { path });
2628
- get().addToast('success', `Custom path set for ${providerId}`);
2629
- } catch (err) {
2630
- get().addToast('error', 'Failed to set path', err.message);
2631
- throw err;
2632
- }
2633
- },
2634
-
2635
- async verifyProvider(providerId) {
2636
- try {
2637
- const data = await api.post(`/providers/${encodeURIComponent(providerId)}/verify`);
2638
- return data;
2639
- } catch (err) {
2640
- get().addToast('error', `Verification failed`, err.message);
2641
- throw err;
2642
- }
2643
- },
2644
-
2645
- async setDefaultProvider(provider, model) {
2646
- try {
2647
- await api.post('/onboarding/set-default', { provider, model });
2648
- get().addToast('success', `Default set to ${provider} (${model})`);
2649
- } catch (err) {
2650
- get().addToast('error', 'Failed to set default', err.message);
2651
- throw err;
2652
- }
2653
- },
2654
-
2655
- // ── Chat ──────────────────────────────────────────────────
2656
-
2657
- addChatMessage(agentId, from, text, isQuery = false) {
2658
- set((s) => {
2659
- const history = { ...s.chatHistory };
2660
- if (!history[agentId]) history[agentId] = [];
2661
- history[agentId] = [...history[agentId].slice(-100), { from, text, timestamp: Date.now(), isQuery }];
2662
- persistJSON('groove:chatHistory', history);
2663
- return { chatHistory: history };
2664
- });
2665
- },
2666
-
2667
- // Track which agents are thinking (sent a message, waiting for response)
2668
- thinkingAgents: new Set(),
2669
-
2670
- async stopAgent(id) {
2671
- try {
2672
- await api.post(`/agents/${encodeURIComponent(id)}/stop`);
2673
- // Clear thinking indicator
2674
- set((s) => {
2675
- const next = new Set(s.thinkingAgents);
2676
- next.delete(id);
2677
- return { thinkingAgents: next };
2678
- });
2679
- get().addToast('info', 'Stopped agent');
2680
- } catch (err) {
2681
- get().addToast('error', 'Stop failed', err.message);
2682
- }
2683
- },
2684
-
2685
- async instructAgent(id, message) {
2686
- // ── Keeper command interception ─────────────────────────
2687
- const keeperCmd = message.match(/\[(save|append|update|delete|view|doc|link|read|instruct)\]/i);
2688
- if (keeperCmd) {
2689
- const handled = await get()._handleKeeperCommand(id, message, keeperCmd[1].toLowerCase());
2690
- if (handled === true) return { status: 'keeper_handled' };
2691
- if (handled?.passthrough) {
2692
- message = handled.passthrough;
2693
- }
2694
- }
2695
-
2696
- get().addChatMessage(id, 'user', message, false);
2697
- set((s) => ({ thinkingAgents: new Set([...s.thinkingAgents, id]) }));
2698
-
2699
- // Auto-attach active file context when in workspace mode
2700
- let enriched = message;
2701
- if (get().workspaceMode && get().workspaceAgentId === id && get().editorActiveFile) {
2702
- const filePath = get().editorActiveFile;
2703
- enriched = `[Active file: ${filePath}]\n\n${message}`;
2704
- }
2705
-
2706
- const snapshot = {
2707
- chatHistory: [...(get().chatHistory[id] || [])],
2708
- activityLog: [...(get().activityLog[id] || [])],
2709
- tokenTimeline: [...(get().tokenTimeline[id] || [])],
2710
- };
2711
-
2712
- try {
2713
- const data = await api.post(`/agents/${encodeURIComponent(id)}/instruct`, { message: enriched });
2714
-
2715
- if (data.status === 'message_sent') {
2716
- return data;
2717
- }
2718
- if (data.status === 'message_queued') {
2719
- set((s) => {
2720
- const next = new Set(s.thinkingAgents);
2721
- next.delete(id);
2722
- return { thinkingAgents: next };
2723
- });
2724
- return data;
2725
- }
2726
-
2727
- // CLI agent: was stopped + resumed/rotated — transfer state to new agent ID
2728
- const newAgent = data;
2729
- for (const key of ['chatHistory', 'activityLog', 'tokenTimeline']) {
2730
- if (snapshot[key]?.length) {
2731
- set((s) => ({ [key]: { ...s[key], [newAgent.id]: [...snapshot[key]] } }));
2732
- }
2733
- }
2734
- set((s) => {
2735
- const next = new Set(s.thinkingAgents);
2736
- next.delete(id);
2737
- next.add(newAgent.id);
2738
- return { thinkingAgents: next };
2739
- });
2740
- if (get().chatHistory[newAgent.id]?.length) persistJSON('groove:chatHistory', get().chatHistory);
2741
- if (get().activityLog[newAgent.id]?.length) persistJSON('groove:activityLog', get().activityLog);
2742
- get().selectAgent(newAgent.id);
2743
- return newAgent;
2744
- } catch (err) {
2745
- set((s) => {
2746
- const next = new Set(s.thinkingAgents);
2747
- next.delete(id);
2748
- return { thinkingAgents: next };
2749
- });
2750
- get().addChatMessage(id, 'system', `failed: ${err.message}`);
2751
- throw err;
2752
- }
2753
- },
2754
-
2755
- async queryAgent(id, message) {
2756
- get().addChatMessage(id, 'user', message, true);
2757
- try {
2758
- const data = await api.post(`/agents/${encodeURIComponent(id)}/query`, { message });
2759
- get().addChatMessage(id, 'agent', data.response);
2760
- return data;
2761
- } catch (err) {
2762
- get().addChatMessage(id, 'system', `query failed: ${err.message}`);
2763
- throw err;
2764
- }
2765
- },
2766
-
2767
- // ── Conversations (Chat view) ────────────────────────────
2768
-
2769
- async fetchConversations() {
2770
- try {
2771
- const data = await api.get('/conversations');
2772
- set({ conversations: data.conversations || data || [] });
2773
- } catch { /* endpoint may not exist yet */ }
2774
- },
2775
-
2776
- async createConversation(provider, model, mode = 'api') {
2777
- try {
2778
- const conv = await api.post('/conversations', { provider, model, mode });
2779
- set((s) => ({
2780
- conversations: [conv, ...s.conversations.filter((c) => c.id !== conv.id)],
2781
- activeConversationId: conv.id,
2782
- }));
2783
- localStorage.setItem('groove:activeConversationId', conv.id);
2784
- return conv;
2785
- } catch (err) {
2786
- get().addToast('error', 'Failed to create conversation', err.message);
2787
- throw err;
2788
- }
2789
- },
2790
-
2791
- async setConversationMode(id, mode) {
2792
- if (_modeChangePending.has(id)) return;
2793
- _modeChangePending.add(id);
2794
- try {
2795
- const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { mode });
2796
- set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
2797
- } catch (err) {
2798
- get().addToast('error', 'Mode change failed', err.message);
2799
- } finally {
2800
- _modeChangePending.delete(id);
2801
- }
2802
- },
2803
-
2804
- async setConversationModel(id, provider, model) {
2805
- try {
2806
- const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { provider, model });
2807
- set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
2808
- } catch (err) {
2809
- get().addToast('error', 'Model change failed', err.message);
2810
- }
2811
- },
2812
-
2813
- async stopChatStreaming(conversationId) {
2814
- try {
2815
- await api.post(`/conversations/${encodeURIComponent(conversationId)}/stop`);
2816
- set({ sendingMessage: false, streamingConversationId: null });
2817
- } catch { /* ignore */ }
2818
- },
2819
-
2820
- async deleteConversation(id) {
2821
- try {
2822
- await api.delete(`/conversations/${encodeURIComponent(id)}`);
2823
- set((s) => {
2824
- const conversations = s.conversations.filter((c) => c.id !== id);
2825
- const conversationMessages = { ...s.conversationMessages };
2826
- delete conversationMessages[id];
2827
- persistJSON('groove:conversationMessages', conversationMessages);
2828
- const activeConversationId = s.activeConversationId === id
2829
- ? (conversations[0]?.id || null)
2830
- : s.activeConversationId;
2831
- localStorage.setItem('groove:activeConversationId', activeConversationId || '');
2832
- const conversationRoles = { ...s.conversationRoles };
2833
- delete conversationRoles[id];
2834
- persistJSON('groove:conversationRoles', conversationRoles);
2835
- const conversationReasoningEffort = { ...s.conversationReasoningEffort };
2836
- delete conversationReasoningEffort[id];
2837
- persistJSON('groove:conversationReasoningEffort', conversationReasoningEffort);
2838
- const conversationVerbosity = { ...s.conversationVerbosity };
2839
- delete conversationVerbosity[id];
2840
- persistJSON('groove:conversationVerbosity', conversationVerbosity);
2841
- return { conversations, conversationMessages, conversationRoles, conversationReasoningEffort, conversationVerbosity, activeConversationId };
2842
- });
2843
- } catch (err) {
2844
- get().addToast('error', 'Delete failed', err.message);
2845
- }
2846
- },
2847
-
2848
- async renameConversation(id, title) {
2849
- try {
2850
- const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { title });
2851
- set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
2852
- } catch (err) {
2853
- get().addToast('error', 'Rename failed', err.message);
2854
- }
2855
- },
2856
-
2857
- async pinConversation(id, pinned) {
2858
- try {
2859
- const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { pinned });
2860
- set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
2861
- } catch (err) {
2862
- get().addToast('error', 'Pin failed', err.message);
2863
- }
2864
- },
2865
-
2866
- setActiveConversation(id) {
2867
- set({ activeConversationId: id });
2868
- localStorage.setItem('groove:activeConversationId', id || '');
2869
- },
2870
-
2871
- setConversationRole(id, role) {
2872
- set((s) => {
2873
- const roles = { ...s.conversationRoles };
2874
- if (role) {
2875
- roles[id] = role;
2876
- } else {
2877
- delete roles[id];
2878
- }
2879
- persistJSON('groove:conversationRoles', roles);
2880
- return { conversationRoles: roles };
2881
- });
2882
- },
2883
-
2884
- setConversationReasoningEffort(id, effort) {
2885
- set((s) => {
2886
- const map = { ...s.conversationReasoningEffort };
2887
- map[id] = effort || 'medium';
2888
- persistJSON('groove:conversationReasoningEffort', map);
2889
- return { conversationReasoningEffort: map };
2890
- });
2891
- },
2892
-
2893
- setConversationVerbosity(id, verbosity) {
2894
- set((s) => {
2895
- const map = { ...s.conversationVerbosity };
2896
- map[id] = verbosity || 'medium';
2897
- persistJSON('groove:conversationVerbosity', map);
2898
- return { conversationVerbosity: map };
2899
- });
2900
- },
2901
-
2902
- async sendChatMessage(conversationId, message) {
2903
- const conv = get().conversations.find((c) => c.id === conversationId);
2904
- if (!conv) return;
2905
-
2906
- // Add user message to local state immediately
2907
- set((s) => {
2908
- const msgs = { ...s.conversationMessages };
2909
- if (!msgs[conversationId]) msgs[conversationId] = [];
2910
- msgs[conversationId] = [...msgs[conversationId], { from: 'user', text: message, timestamp: Date.now() }];
2911
- persistJSON('groove:conversationMessages', msgs);
2912
- return { conversationMessages: msgs, sendingMessage: true, streamingConversationId: conversationId };
2913
- });
2914
-
2915
- try {
2916
- const body = { message };
2917
- if (conv.mode === 'api' || !conv.mode) {
2918
- const history = get().conversationMessages[conversationId] || [];
2919
- body.history = history.slice(0, -1);
2920
-
2921
- const role = get().conversationRoles?.[conversationId];
2922
- const rules = ['Never use emojis in your responses.', 'Be professional, concise, and direct.'];
2923
- if (role) rules.unshift(`You are a professional ${role}. Respond with deep expertise in that domain.`);
2924
- const systemCtx = rules.join(' ');
2925
- body.history = [
2926
- { from: 'user', text: `Instructions: ${systemCtx}` },
2927
- { from: 'assistant', text: 'Understood.' },
2928
- ...body.history,
2929
- ];
2930
- }
2931
- const effort = get().conversationReasoningEffort?.[conversationId] || 'medium';
2932
- const verbosity = get().conversationVerbosity?.[conversationId] || 'medium';
2933
- if (conv.provider === 'codex') {
2934
- body.reasoning_effort = effort;
2935
- body.verbosity = verbosity;
2936
- }
2937
- await api.post(`/conversations/${encodeURIComponent(conversationId)}/message`, body);
2938
- } catch (err) {
2939
- set((s) => {
2940
- const msgs = { ...s.conversationMessages };
2941
- if (!msgs[conversationId]) msgs[conversationId] = [];
2942
- msgs[conversationId] = [...msgs[conversationId], { from: 'system', text: `Failed: ${err.message}`, timestamp: Date.now() }];
2943
- persistJSON('groove:conversationMessages', msgs);
2944
- return { conversationMessages: msgs, sendingMessage: false, streamingConversationId: null };
2945
- });
2946
- get().addToast('error', 'Message failed', err.message);
2947
- }
2948
- },
2949
-
2950
- async sendImageMessage(conversationId, prompt, { model, size, quality } = {}) {
2951
- const conv = get().conversations.find((c) => c.id === conversationId);
2952
- if (!conv) return;
2953
-
2954
- set((s) => {
2955
- const msgs = { ...s.conversationMessages };
2956
- if (!msgs[conversationId]) msgs[conversationId] = [];
2957
- msgs[conversationId] = [...msgs[conversationId], { from: 'user', text: prompt, timestamp: Date.now() }];
2958
- persistJSON('groove:conversationMessages', msgs);
2959
- return { conversationMessages: msgs, sendingMessage: true, streamingConversationId: conversationId };
2960
- });
2961
-
2962
- try {
2963
- await api.post(`/conversations/${encodeURIComponent(conversationId)}/generate-image`, { prompt, model, size, quality });
2964
- } catch (err) {
2965
- set((s) => {
2966
- const msgs = { ...s.conversationMessages };
2967
- if (!msgs[conversationId]) msgs[conversationId] = [];
2968
- msgs[conversationId] = [...msgs[conversationId], { from: 'system', text: `Image failed: ${err.message}`, timestamp: Date.now() }];
2969
- persistJSON('groove:conversationMessages', msgs);
2970
- return { conversationMessages: msgs, sendingMessage: false, streamingConversationId: null };
2971
- });
2972
- get().addToast('error', 'Image generation failed', err.message);
2973
- }
2974
- },
2975
-
2976
- // ── Editor ────────────────────────────────────────────────
2977
-
2978
- async openFile(path) {
2979
- if (get().editorFiles[path] || get().editorOpenTabs.includes(path)) {
2980
- set((s) => ({
2981
- editorActiveFile: path,
2982
- editorOpenTabs: s.editorOpenTabs.includes(path) ? s.editorOpenTabs : [...s.editorOpenTabs, path],
2983
- }));
2984
- return;
2985
- }
2986
- const ext = path.split('.').pop()?.toLowerCase();
2987
- const MEDIA = ['png','jpg','jpeg','gif','svg','webp','ico','bmp','avif','mp4','webm','mov','avi','mkv','ogv'];
2988
- if (MEDIA.includes(ext)) {
2989
- set((s) => ({ editorActiveFile: path, editorOpenTabs: [...s.editorOpenTabs, path] }));
2990
- return;
2991
- }
2992
- try {
2993
- const data = await api.get(`/files/read?path=${encodeURIComponent(path)}`);
2994
- if (data.binary) { get().addToast('warning', 'Binary file — cannot open'); return; }
2995
- set((s) => ({
2996
- editorFiles: { ...s.editorFiles, [path]: { content: data.content, originalContent: data.content, language: data.language, loadedAt: Date.now() } },
2997
- editorActiveFile: path,
2998
- editorOpenTabs: s.editorOpenTabs.includes(path) ? s.editorOpenTabs : [...s.editorOpenTabs, path],
2999
- }));
3000
- const ws = get().ws;
3001
- if (ws?.readyState === 1) ws.send(JSON.stringify({ type: 'editor:watch', path }));
3002
- } catch (err) {
3003
- get().addToast('error', 'Failed to open file', err.message);
3004
- }
3005
- },
3006
-
3007
- closeFile(path) {
3008
- set((s) => {
3009
- const tabs = s.editorOpenTabs.filter((t) => t !== path);
3010
- const files = { ...s.editorFiles };
3011
- delete files[path];
3012
- const changed = { ...s.editorChangedFiles };
3013
- delete changed[path];
3014
- let active = s.editorActiveFile;
3015
- if (active === path) {
3016
- const idx = s.editorOpenTabs.indexOf(path);
3017
- active = tabs[Math.min(idx, tabs.length - 1)] || null;
3018
- }
3019
- return { editorOpenTabs: tabs, editorFiles: files, editorChangedFiles: changed, editorActiveFile: active };
3020
- });
3021
- const ws = get().ws;
3022
- if (ws?.readyState === 1) ws.send(JSON.stringify({ type: 'editor:unwatch', path }));
3023
- },
3024
-
3025
- setActiveFile(path) { set({ editorActiveFile: path }); },
3026
-
3027
- setEditorSidebarWidth(width) {
3028
- set({ editorSidebarWidth: width });
3029
- localStorage.setItem('groove:editorSidebarWidth', String(width));
3030
- },
3031
- setEditorTheme(theme) {
3032
- set({ editorTheme: theme });
3033
- localStorage.setItem('groove:editorTheme', theme);
3034
- },
3035
-
3036
- updateFileContent(path, content) {
3037
- set((s) => ({ editorFiles: { ...s.editorFiles, [path]: { ...s.editorFiles[path], content } } }));
3038
- },
3039
-
3040
- async saveFile(path) {
3041
- const file = get().editorFiles[path];
3042
- if (!file) return;
3043
- try {
3044
- await api.post('/files/write', { path, content: file.content });
3045
- set((s) => ({
3046
- editorFiles: { ...s.editorFiles, [path]: { ...s.editorFiles[path], originalContent: file.content } },
3047
- editorChangedFiles: (() => { const c = { ...s.editorChangedFiles }; delete c[path]; return c; })(),
3048
- editorRecentSaves: { ...s.editorRecentSaves, [path]: Date.now() },
3049
- }));
3050
- get().addToast('success', 'File saved');
3051
- } catch (err) {
3052
- get().addToast('error', 'Save failed', err.message);
3053
- }
3054
- },
3055
-
3056
- async reloadFile(path) {
3057
- try {
3058
- const data = await api.get(`/files/read?path=${encodeURIComponent(path)}`);
3059
- if (data.binary) return;
3060
- set((s) => ({
3061
- editorFiles: { ...s.editorFiles, [path]: { content: data.content, originalContent: data.content, language: data.language, loadedAt: Date.now() } },
3062
- editorChangedFiles: (() => { const c = { ...s.editorChangedFiles }; delete c[path]; return c; })(),
3063
- }));
3064
- } catch { /* ignore */ }
3065
- },
3066
-
3067
- dismissFileChange(path) {
3068
- set((s) => { const c = { ...s.editorChangedFiles }; delete c[path]; return { editorChangedFiles: c }; });
3069
- },
3070
-
3071
- async fetchTreeDir(dirPath) {
3072
- try {
3073
- const data = await api.get(`/files/tree?path=${encodeURIComponent(dirPath)}`);
3074
- set((s) => ({ editorTreeCache: { ...s.editorTreeCache, [dirPath]: data.entries || [] } }));
3075
- const ws = get().ws;
3076
- if (ws?.readyState === 1) ws.send(JSON.stringify({ type: 'editor:watchdir', path: dirPath }));
3077
- } catch (err) {
3078
- console.error('[file-tree] fetchTreeDir failed for', dirPath, err.message);
3079
- set((s) => ({ editorTreeCache: { ...s.editorTreeCache, [dirPath]: [] } }));
3080
- }
3081
- },
3082
-
3083
- async createFile(relPath) {
3084
- try {
3085
- await api.post('/files/create', { path: relPath });
3086
- const parent = relPath.includes('/') ? relPath.split('/').slice(0, -1).join('/') : '';
3087
- await get().fetchTreeDir(parent);
3088
- get().addToast('success', 'File created');
3089
- return true;
3090
- } catch (err) {
3091
- get().addToast('error', 'Create failed', err.message);
3092
- return false;
3093
- }
3094
- },
3095
-
3096
- async createDir(relPath) {
3097
- try {
3098
- await api.post('/files/mkdir', { path: relPath });
3099
- const parent = relPath.includes('/') ? relPath.split('/').slice(0, -1).join('/') : '';
3100
- await get().fetchTreeDir(parent);
3101
- get().addToast('success', 'Folder created');
3102
- return true;
3103
- } catch (err) {
3104
- get().addToast('error', 'Create failed', err.message);
3105
- return false;
3106
- }
3107
- },
3108
-
3109
- async deleteFile(relPath) {
3110
- try {
3111
- await api.delete(`/files/delete?path=${encodeURIComponent(relPath)}`);
3112
- if (get().editorOpenTabs.includes(relPath)) get().closeFile(relPath);
3113
- const parent = relPath.includes('/') ? relPath.split('/').slice(0, -1).join('/') : '';
3114
- await get().fetchTreeDir(parent);
3115
- set((s) => { const cache = { ...s.editorTreeCache }; delete cache[relPath]; return { editorTreeCache: cache }; });
3116
- get().addToast('success', 'Deleted');
3117
- return true;
3118
- } catch (err) {
3119
- get().addToast('error', 'Delete failed', err.message);
3120
- return false;
3121
- }
3122
- },
3123
-
3124
- // ── Workspace Mode ────────────────────────────────────────
3125
-
3126
- setWorkspaceMode(on) {
3127
- set({ workspaceMode: on });
3128
- localStorage.setItem('groove:workspaceMode', String(on));
3129
- if (on) {
3130
- const teamAgents = get().agents.filter((a) => a.teamId === get().activeTeamId);
3131
- const current = get().workspaceAgentId;
3132
- const belongsToTeam = current && teamAgents.some((a) => a.id === current);
3133
- if (!belongsToTeam) {
3134
- const selected = get().detailPanel?.type === 'agent' ? get().detailPanel.agentId : null;
3135
- const selectedInTeam = selected && teamAgents.some((a) => a.id === selected);
3136
- const running = teamAgents.find((a) => a.status === 'running');
3137
- set({ workspaceAgentId: (selectedInTeam ? selected : null) || running?.id || teamAgents[0]?.id || null });
3138
- }
3139
- const agentId = get().workspaceAgentId;
3140
- if (agentId) get().selectAgent(agentId);
3141
- }
3142
- },
3143
-
3144
- setWorkspaceAgent(id) {
3145
- set({ workspaceAgentId: id });
3146
- if (id) get().selectAgent(id);
3147
- },
3148
-
3149
- captureSnapshot(path, content) {
3150
- set((s) => {
3151
- if (s.workspaceSnapshots[path]) return s;
3152
- const next = { ...s.workspaceSnapshots, [path]: content };
3153
- const keys = Object.keys(next);
3154
- if (keys.length > 200) {
3155
- delete next[keys[0]];
3156
- }
3157
- return { workspaceSnapshots: next };
3158
- });
3159
- },
3160
-
3161
- async toggleReviewMode() {
3162
- const st = get();
3163
- if (st.workspaceReviewMode) {
3164
- set({ workspaceReviewMode: false, workspaceReviewFiles: [] });
3165
- return;
3166
- }
3167
- const agentId = st.workspaceAgentId;
3168
- if (!agentId) return;
3169
- try {
3170
- const res = await api.get(`/agents/${agentId}/files-touched`);
3171
- const touched = res.data || [];
3172
- const files = touched
3173
- .filter((f) => f.writes > 0)
3174
- .map((f) => ({ path: f.path, status: 'pending', comment: '' }));
3175
- set({ workspaceReviewMode: true, workspaceReviewFiles: files });
3176
- } catch (err) {
3177
- console.error('Failed to fetch touched files for review:', err);
3178
- }
3179
- },
3180
-
3181
- approveFile(path) {
3182
- set((s) => ({
3183
- workspaceReviewFiles: s.workspaceReviewFiles.map((f) =>
3184
- f.path === path ? { ...f, status: 'approved' } : f,
3185
- ),
3186
- }));
3187
- },
3188
-
3189
- rejectFile(path) {
3190
- set((s) => ({
3191
- workspaceReviewFiles: s.workspaceReviewFiles.map((f) =>
3192
- f.path === path ? { ...f, status: 'rejected' } : f,
3193
- ),
3194
- }));
3195
- },
3196
-
3197
- commentFile(path, comment) {
3198
- set((s) => ({
3199
- workspaceReviewFiles: s.workspaceReviewFiles.map((f) =>
3200
- f.path === path ? { ...f, comment } : f,
3201
- ),
3202
- }));
3203
- },
3204
-
3205
- // ── Editor (Cursor-style) ──────────────────────────────────
3206
-
3207
- setEditorAgent(id) {
3208
- set({ editorSelectedAgent: id });
3209
- },
3210
-
3211
- setEditorViewMode(mode) {
3212
- set({ editorViewMode: mode });
3213
- },
3214
-
3215
- toggleAiPanel() {
3216
- set((s) => {
3217
- const open = !s.editorAiPanelOpen;
3218
- return { editorAiPanelOpen: open };
3219
- });
3220
- },
3221
-
3222
- setEditorAiPanelWidth(width) {
3223
- set({ editorAiPanelWidth: width });
3224
- localStorage.setItem('groove:editorAiPanelWidth', String(width));
3225
- },
3226
-
3227
- setEditorQuickSearchOpen(open) {
3228
- set({ editorQuickSearchOpen: open });
3229
- },
3230
-
3231
- attachSnippet(snippet) {
3232
- set({ editorPendingSnippet: snippet });
3233
- if (!get().editorAiPanelOpen) {
3234
- set({ editorAiPanelOpen: true });
3235
- }
3236
- },
3237
-
3238
- clearSnippet() {
3239
- set({ editorPendingSnippet: null });
3240
- },
3241
-
3242
- async sendCodeToAgent(agentId, instruction, filePath, lineStart, lineEnd, selectedCode) {
3243
- if (!agentId) return;
3244
- get().attachSnippet({
3245
- type: 'code',
3246
- instruction,
3247
- filePath,
3248
- lineStart,
3249
- lineEnd,
3250
- code: selectedCode,
3251
- });
3252
- },
3253
-
3254
- async fetchGitStatus() {
3255
- try {
3256
- const data = await api.get('/files/git-status');
3257
- set({ editorGitStatus: data });
3258
- return data;
3259
- } catch { return null; }
3260
- },
3261
-
3262
- async fetchGitBranch() {
3263
- try {
3264
- const data = await api.get('/files/git-branch');
3265
- set({ editorGitBranch: data });
3266
- return data;
3267
- } catch { return null; }
3268
- },
3269
-
3270
- async fetchGitDiff(path) {
3271
- try {
3272
- const url = path ? `/files/git-diff?path=${encodeURIComponent(path)}` : '/files/git-diff';
3273
- const data = await api.get(url);
3274
- set({ editorGitDiff: data });
3275
- return data;
3276
- } catch { return null; }
3277
- },
3278
-
3279
- // ── Federation ────────────────────────────────────────────
3280
-
3281
- async fetchFederationStatus() {
3282
- try {
3283
- const data = await api.get('/federation');
3284
- set((s) => ({
3285
- federation: {
3286
- ...s.federation,
3287
- peers: data.peers || [],
3288
- whitelist: data.whitelist || [],
3289
- connections: data.connections || [],
3290
- ambassadors: data.ambassadors?.ambassadors || data.ambassadors || [],
3291
- },
3292
- }));
3293
- return data;
3294
- } catch { return null; }
3295
- },
3296
-
3297
- async addToWhitelist(ip, port = 31415, name) {
3298
- try {
3299
- await api.post('/federation/whitelist', { ip, port, ...(name && { name }) });
3300
- get().addToast('success', `Added ${ip} to whitelist`);
3301
- get().fetchFederationStatus();
3302
- } catch (err) {
3303
- get().addToast('error', 'Whitelist failed', err.message);
3304
- throw err;
3305
- }
3306
- },
3307
-
3308
- async removeFromWhitelist(ip) {
3309
- try {
3310
- await api.delete(`/federation/whitelist/${encodeURIComponent(ip)}`);
3311
- get().addToast('info', `Removed ${ip}`);
3312
- get().fetchFederationStatus();
3313
- } catch (err) {
3314
- get().addToast('error', 'Remove failed', err.message);
3315
- }
3316
- },
3317
-
3318
- setSelectedPeer(peerId) {
3319
- set((s) => ({ federation: { ...s.federation, selectedPeerId: peerId } }));
3320
- },
3321
-
3322
- async fetchPouchLog(peerId) {
3323
- try {
3324
- const data = await api.get(`/federation/pouch/log${peerId ? `?peerId=${encodeURIComponent(peerId)}` : ''}`);
3325
- set((s) => ({ federation: { ...s.federation, pouchLog: data || [] } }));
3326
- } catch { /* ignore */ }
3327
- },
3328
-
3329
- async sendPouch(peerId, contract) {
3330
- try {
3331
- const result = await api.post('/federation/pouch/send', { peerId, contract });
3332
- get().addToast('success', 'Pouch sent');
3333
- return result;
3334
- } catch (err) {
3335
- get().addToast('error', 'Pouch send failed', err.message);
3336
- throw err;
3337
- }
3338
- },
3339
-
3340
- async disconnectPeer(peerId) {
3341
- try {
3342
- await api.delete(`/federation/peers/${encodeURIComponent(peerId)}`);
3343
- get().addToast('info', 'Peer disconnected');
3344
- get().fetchFederationStatus();
3345
- } catch (err) {
3346
- get().addToast('error', 'Disconnect failed', err.message);
3347
- }
3348
- },
3349
-
3350
- // ── Training Data ─────────────────────────────────────────
3351
-
3352
- async setTrainingOptIn(enabled) {
3353
- try {
3354
- await api.post('/training/opt-in', { enabled });
3355
- set({ trainingOptIn: enabled, dataSharingModalOpen: false });
3356
- if (!enabled) set({ trainingStats: null });
3357
- } catch (e) {
3358
- get().addToast('error', 'Failed to update training preference', e.body?.detail || e.message);
3359
- }
3360
- },
3361
-
3362
- async fetchTrainingStatus() {
3363
- try {
3364
- const data = await api.get('/training/status');
3365
- set({ trainingOptIn: data.optedIn, trainingStats: data });
3366
- } catch { /* endpoint may not exist on older daemons */ }
3367
- },
3368
-
3369
- async dismissDataSharingModal(permanent) {
3370
- if (permanent) {
3371
- try { await api.patch('/config', { dataSharingDismissed: true }); } catch {}
3372
- set({ dataSharingDismissed: true, dataSharingModalOpen: false });
3373
- } else {
3374
- set({ dataSharingModalOpen: false });
3375
- }
3376
- },
3377
-
3378
- // ── Network (Early Access) ────────────────────────────────
3379
-
3380
- async fetchBetaStatus() {
3381
- try {
3382
- const data = await api.get('/beta/status');
3383
- set({ networkUnlocked: !!data?.unlocked });
3384
- } catch { /* endpoint may not exist yet */ }
3385
- },
3386
-
3387
- async activateBeta(code) {
3388
- const data = await api.post('/beta/activate', { code });
3389
- if (!data?.unlocked) {
3390
- throw new Error(data?.message || 'Invalid invite code');
3391
- }
3392
- set({ networkUnlocked: true });
3393
- return data;
3394
- },
3395
-
3396
- async deactivateBeta() {
3397
- try {
3398
- await api.post('/beta/deactivate');
3399
- set({
3400
- networkUnlocked: false,
3401
- activeView: get().activeView === 'network' ? 'agents' : get().activeView,
3402
- });
3403
- } catch (err) {
3404
- get().addToast('error', 'Deactivate failed', err.message);
3405
- throw err;
3406
- }
3407
- },
3408
-
3409
- async fetchNetworkNodeStatus() {
3410
- try {
3411
- const data = await api.get('/network/node/status');
3412
- const update = { networkNode: { ...get().networkNode, ...(data || {}) } };
3413
- if (data && typeof data.installed === 'boolean') {
3414
- update.networkInstalled = data.installed;
3415
- }
3416
- set(update);
3417
- return data;
3418
- } catch { return null; }
3419
- },
3420
-
3421
- async fetchNetworkInstallStatus() {
3422
- try {
3423
- const data = await api.get('/network/install/status');
3424
- if (data && typeof data.installed === 'boolean') {
3425
- set({ networkInstalled: data.installed });
3426
- }
3427
- return data;
3428
- } catch { return null; }
3429
- },
3430
-
3431
- async installNetworkPackage() {
3432
- set({
3433
- networkInstallProgress: {
3434
- installing: true,
3435
- step: 'starting',
3436
- message: 'Starting install…',
3437
- percent: 0,
3438
- error: null,
3439
- },
3440
- });
3441
- try {
3442
- await api.post('/network/install');
3443
- } catch (err) {
3444
- set({
3445
- networkInstallProgress: {
3446
- installing: false,
3447
- step: 'error',
3448
- message: err.message,
3449
- percent: 0,
3450
- error: err.message,
3451
- },
3452
- });
3453
- get().addToast('error', 'Install failed', err.message);
3454
- }
3455
- },
3456
-
3457
- async uninstallNetworkPackage() {
3458
- try {
3459
- await api.post('/network/uninstall');
3460
- set({
3461
- networkInstalled: false,
3462
- networkNode: { active: false, status: 'disconnected', nodeId: null, layers: null, model: null, sessions: 0, hardware: null },
3463
- networkInstallProgress: { installing: false, step: null, message: null, percent: 0, error: null },
3464
- });
3465
- get().addToast('success', 'Network package uninstalled');
3466
- } catch (err) {
3467
- get().addToast('error', 'Uninstall failed', err.message);
3468
- throw err;
3469
- }
3470
- },
3471
-
3472
- async fetchNetworkStatus() {
3473
- try {
3474
- const data = await api.get('/network/status');
3475
- const update = {
3476
- networkStatus: { ...get().networkStatus, ...(data || {}) },
3477
- networkStatusReachable: true,
3478
- };
3479
- if (data?.compute) {
3480
- const c = data.compute;
3481
- update.networkCompute = {
3482
- totalRamMb: c.totalRamMb ?? c.total_ram_mb ?? 0,
3483
- totalVramMb: c.totalVramMb ?? c.total_vram_mb ?? 0,
3484
- totalCpuCores: c.totalCpuCores ?? c.total_cpu_cores ?? 0,
3485
- totalBandwidthMbps: c.totalBandwidthMbps ?? c.total_bandwidth_mbps ?? 0,
3486
- activeNodes: c.activeNodes ?? c.active_nodes ?? 0,
3487
- totalNodes: c.totalNodes ?? c.total_nodes ?? 0,
3488
- avgLoad: c.avgLoad ?? c.avg_load ?? 0,
3489
- };
3490
- } else if (Array.isArray(data?.nodes) && data.nodes.length > 0) {
3491
- const nodes = data.nodes;
3492
- const active = nodes.filter((n) => n.status === 'active');
3493
- update.networkCompute = {
3494
- totalRamMb: nodes.reduce((s, n) => s + (n.ram_mb || 0), 0),
3495
- totalVramMb: nodes.reduce((s, n) => s + (n.vram_mb || 0), 0),
3496
- totalCpuCores: nodes.reduce((s, n) => s + (n.cpu_cores || 0), 0),
3497
- totalBandwidthMbps: nodes.reduce((s, n) => s + (n.bandwidth_mbps || 0), 0),
3498
- activeNodes: active.length,
3499
- totalNodes: nodes.length,
3500
- avgLoad: active.length > 0 ? active.reduce((s, n) => s + (n.load || 0), 0) / active.length : 0,
3501
- };
3502
- }
3503
- set(update);
3504
-
3505
- // Push snapshot for activity chart
3506
- if (data) {
3507
- const ownId = get().networkNode.nodeId;
3508
- const nodes = data.nodes || [];
3509
- const ownNode = ownId ? nodes.find((n) => (n.node_id || n.nodeId) === ownId) : null;
3510
- const activeNodes = nodes.filter((n) => n.status === 'active');
3511
- const snap = {
3512
- t: Date.now(),
3513
- globalSessions: data.activeSessions || 0,
3514
- mySessions: ownNode?.active_sessions ?? ownNode?.sessions ?? 0,
3515
- nodeCount: activeNodes.length,
3516
- avgLoad: activeNodes.length > 0 ? activeNodes.reduce((s, n) => s + (n.load || 0), 0) / activeNodes.length : 0,
3517
- myLoad: ownNode?.load ?? 0,
3518
- totalVramMb: nodes.reduce((s, n) => s + (n.vram_mb || 0), 0),
3519
- totalRamMb: nodes.reduce((s, n) => s + (n.ram_mb || 0), 0),
3520
- };
3521
- let snapshots = [...get().networkSnapshots, snap];
3522
- if (snapshots.length > 100) snapshots = snapshots.slice(-100);
3523
- set({ networkSnapshots: snapshots });
3524
- }
3525
-
3526
- return data;
3527
- } catch {
3528
- set({ networkStatusReachable: false });
3529
- return null;
3530
- }
3531
- },
3532
-
3533
- async checkNetworkUpdate() {
3534
- try {
3535
- const data = await api.get('/network/update/check');
3536
- if (!data) return null;
3537
- set({
3538
- networkVersion: {
3539
- installed: data.installed ?? null,
3540
- latest: data.latest ?? null,
3541
- updateAvailable: !!data.updateAvailable,
3542
- },
3543
- });
3544
- return data;
3545
- } catch { return null; }
3546
- },
3547
-
3548
- async updateNetworkPackage() {
3549
- set({
3550
- networkUpdateProgress: {
3551
- updating: true,
3552
- step: 'starting',
3553
- message: 'Starting update…',
3554
- percent: 0,
3555
- error: null,
3556
- },
3557
- });
3558
- try {
3559
- await api.post('/network/update');
3560
- } catch (err) {
3561
- set({
3562
- networkUpdateProgress: {
3563
- updating: false,
3564
- step: 'error',
3565
- message: err.message,
3566
- percent: 0,
3567
- error: err.message,
3568
- },
3569
- });
3570
- get().addToast('error', 'Update failed', err.message);
3571
- }
3572
- },
3573
-
3574
- async startNetworkNode() {
3575
- set({ networkNode: { ...get().networkNode, status: 'connecting' } });
3576
- try {
3577
- const data = await api.post('/network/node/start');
3578
- set({ networkNode: { ...get().networkNode, active: true, ...(data || {}) } });
3579
- get().addToast('success', 'Node started', 'Connecting to the Groove network');
3580
- return data;
3581
- } catch (err) {
3582
- set({ networkNode: { ...get().networkNode, status: 'disconnected', active: false } });
3583
- get().addToast('error', 'Node start failed', err.message);
3584
- throw err;
3585
- }
3586
- },
3587
-
3588
- async stopNetworkNode() {
3589
- try {
3590
- await api.post('/network/node/stop');
3591
- set({ networkNode: { ...get().networkNode, active: false, status: 'disconnected' } });
3592
- get().addToast('info', 'Node stopped');
3593
- } catch (err) {
3594
- get().addToast('error', 'Node stop failed', err.message);
3595
- throw err;
3596
- }
3597
- },
3598
-
3599
- async fetchNetworkWallet() {
3600
- return get().networkWallet;
3601
- },
3602
- async fetchNetworkEarnings() {
3603
- return get().networkEarnings;
3604
- },
3605
-
3606
- async fetchNetworkBenchmarks() {
3607
- try {
3608
- const data = await api.get('/network/benchmarks');
3609
- if (Array.isArray(data)) set({ networkBenchmarks: data.slice(-100) });
3610
- return data;
3611
- } catch { return null; }
3612
- },
3613
-
3614
- async fetchNetworkTraces() {
3615
- try {
3616
- const data = await api.get('/network/traces');
3617
- if (Array.isArray(data)) set({ networkTraces: data });
3618
- return data;
3619
- } catch { return null; }
3620
- },
3621
-
3622
- async fetchNetworkTrace(filename) {
3623
- try {
3624
- return await api.get(`/network/traces/${encodeURIComponent(filename)}`);
3625
- } catch { return null; }
3626
- },
3627
-
3628
- async fetchLiveTrace(offset = 0) {
3629
- try {
3630
- return await api.get(`/network/traces/live?offset=${offset}`);
3631
- } catch { return null; }
3632
- },
3633
-
3634
- // ── Model Lab Actions ──────────────────────────────────────
3635
-
3636
- setLabParameter(key, value) {
3637
- const params = { ...get().labParameters, [key]: value };
3638
- set({ labParameters: params });
3639
- persistJSON('groove:labParameters', params);
3640
- },
3641
-
3642
- setLabSystemPrompt(text) {
3643
- set({ labSystemPrompt: text });
3644
- localStorage.setItem('groove:labSystemPrompt', text);
3645
- },
3646
-
3647
- async fetchLabRuntimes() {
3648
- try {
3649
- const raw = await api.get('/lab/runtimes');
3650
- const data = raw.map((rt) => ({
3651
- ...rt,
3652
- status: rt.online === true ? 'connected' : rt.online === false ? 'error' : rt.status,
3653
- }));
3654
- set({ labRuntimes: data });
3655
- persistJSON('groove:labRuntimes', data);
3656
- if (data.length > 0 && !get().labActiveRuntime) {
3657
- get().setLabActiveRuntime(data[0].id);
3658
- } else if (get().labActiveRuntime) {
3659
- get().fetchLabModels(get().labActiveRuntime);
3660
- }
3661
- } catch { /* backend may not have lab endpoints yet */ }
3662
- },
3663
-
3664
- async fetchLabLocalModels() {
3665
- try {
3666
- const data = await api.get('/lab/local-models');
3667
- set({ labLocalModels: data });
3668
- } catch { set({ labLocalModels: [] }); }
3669
- },
3670
-
3671
- async checkLlamaStatus() {
3672
- try {
3673
- const data = await api.get('/llama/status');
3674
- set({ labLlamaInstalled: !!data.installed });
3675
- } catch { set({ labLlamaInstalled: false }); }
3676
- },
3677
-
3678
- async launchLocalModel(modelId) {
3679
- set({ labLaunching: modelId, labLaunchPhase: 'starting', labLaunchError: null });
3680
- try {
3681
- const result = await api.post('/lab/launch-local', { modelId });
3682
- const runtimes = await api.get('/lab/runtimes');
3683
- set({ labRuntimes: runtimes });
3684
- persistJSON('groove:labRuntimes', runtimes);
3685
- get().setLabActiveRuntime(result.runtime.id);
3686
- set({ labActiveModel: result.model, labLaunching: null, labLaunchPhase: 'ready' });
3687
- get().addToast('success', `Launched ${result.model}`);
3688
- setTimeout(() => { if (get().labLaunchPhase === 'ready') set({ labLaunchPhase: null }); }, 3000);
3689
- return result;
3690
- } catch (err) {
3691
- set({ labLaunching: null, labLaunchPhase: 'error', labLaunchError: err.message });
3692
- get().addToast('error', 'Failed to launch model', err.message);
3693
- throw err;
3694
- }
3695
- },
3696
-
3697
- async addLabRuntime(runtime) {
3698
- try {
3699
- const created = await api.post('/lab/runtimes', runtime);
3700
- const runtimes = [...get().labRuntimes, created];
3701
- set({ labRuntimes: runtimes });
3702
- persistJSON('groove:labRuntimes', runtimes);
3703
- get().setLabActiveRuntime(created.id);
3704
- get().addToast('success', `Runtime "${runtime.name}" added`);
3705
- return created;
3706
- } catch (err) {
3707
- get().addToast('error', 'Failed to add runtime', err.message);
3708
- throw err;
3709
- }
3710
- },
3711
-
3712
- async removeLabRuntime(id) {
3713
- try {
3714
- await api.delete(`/lab/runtimes/${id}`);
3715
- const runtimes = get().labRuntimes.filter((r) => r.id !== id);
3716
- const active = get().labActiveRuntime === id ? null : get().labActiveRuntime;
3717
- set({ labRuntimes: runtimes, labActiveRuntime: active, labModels: active ? get().labModels : [] });
3718
- persistJSON('groove:labRuntimes', runtimes);
3719
- get().addToast('success', 'Runtime removed');
3720
- } catch (err) {
3721
- get().addToast('error', 'Failed to remove runtime', err.message);
3722
- }
3723
- },
3724
-
3725
- async testLabRuntime(id) {
3726
- try {
3727
- const result = await api.post(`/lab/runtimes/${id}/test`);
3728
- const runtimes = get().labRuntimes.map((r) =>
3729
- r.id === id ? { ...r, status: result.ok ? 'connected' : 'error', latency: result.latency } : r,
3730
- );
3731
- const updates = { labRuntimes: runtimes };
3732
- if (result.ok && result.models && get().labActiveRuntime === id) {
3733
- updates.labModels = result.models;
3734
- }
3735
- set(updates);
3736
- persistJSON('groove:labRuntimes', runtimes);
3737
- return result;
3738
- } catch (err) {
3739
- const runtimes = get().labRuntimes.map((r) =>
3740
- r.id === id ? { ...r, status: 'error' } : r,
3741
- );
3742
- set({ labRuntimes: runtimes });
3743
- persistJSON('groove:labRuntimes', runtimes);
3744
- return { ok: false, error: err.message };
3745
- }
3746
- },
3747
-
3748
- setLabActiveRuntime(id) {
3749
- set({ labActiveRuntime: id, labModels: [], labActiveModel: null });
3750
- if (id) get().fetchLabModels(id);
3751
- },
3752
-
3753
- setLabActiveModel(model) {
3754
- set({ labActiveModel: model });
3755
- },
3756
-
3757
- async fetchLabModels(runtimeId) {
3758
- try {
3759
- const data = await api.get(`/lab/runtimes/${runtimeId}/models`);
3760
- set({ labModels: data });
3761
- } catch { set({ labModels: [] }); }
3762
- },
3763
-
3764
- newLabSession() {
3765
- const id = `lab-${Date.now()}`;
3766
- const session = { id, messages: [], createdAt: Date.now() };
3767
- set((s) => ({
3768
- labSessions: [session, ...s.labSessions],
3769
- labActiveSession: id,
3770
- labMetrics: { ttft: null, tokensPerSec: null, tokensPerSecHistory: [], memory: null, totalTokens: 0, generationTime: null },
3771
- }));
3772
- return id;
3773
- },
3774
-
3775
- loadLabSession(id) {
3776
- set({ labActiveSession: id });
3777
- },
3778
-
3779
- async sendLabMessage(text) {
3780
- const st = get();
3781
- if (st.labStreaming) return;
3782
- let sessionId = st.labActiveSession;
3783
- if (!sessionId) sessionId = get().newLabSession();
3784
-
3785
- const userMsg = { role: 'user', content: text, timestamp: Date.now() };
3786
- set((s) => {
3787
- const sessions = s.labSessions.map((sess) =>
3788
- sess.id === sessionId ? { ...sess, messages: [...sess.messages, userMsg] } : sess,
3789
- );
3790
- return { labSessions: sessions, labStreaming: true };
3791
- });
3792
-
3793
- const assistantMsg = { role: 'assistant', content: '', timestamp: Date.now(), metrics: null };
3794
- set((s) => {
3795
- const sessions = s.labSessions.map((sess) =>
3796
- sess.id === sessionId ? { ...sess, messages: [...sess.messages, assistantMsg] } : sess,
3797
- );
3798
- return { labSessions: sessions };
3799
- });
3800
-
3801
- const abortController = new AbortController();
3802
- set({ labAbortController: abortController });
3803
-
3804
- const startTime = performance.now();
3805
- let firstTokenTime = null;
3806
- let tokenCount = 0;
3807
-
3808
- try {
3809
- const p = st.labParameters;
3810
- const parameters = {};
3811
- if (p.temperature !== undefined) parameters.temperature = p.temperature;
3812
- if (p.topP !== undefined) parameters.top_p = p.topP;
3813
- if (p.topK !== undefined) parameters.top_k = p.topK;
3814
- if (p.repeatPenalty !== undefined) parameters.repeat_penalty = p.repeatPenalty;
3815
- if (p.maxTokens !== undefined) parameters.max_tokens = p.maxTokens;
3816
- if (p.frequencyPenalty !== undefined) parameters.frequency_penalty = p.frequencyPenalty;
3817
- if (p.presencePenalty !== undefined) parameters.presence_penalty = p.presencePenalty;
3818
-
3819
- const messages = [];
3820
- if (st.labSystemPrompt) messages.push({ role: 'system', content: st.labSystemPrompt });
3821
- const sessionMsgs = get().labSessions.find((s) => s.id === sessionId)?.messages || [];
3822
- for (const m of sessionMsgs) {
3823
- if (m.role === 'assistant' && !m.content) continue;
3824
- messages.push({ role: m.role, content: m.content });
3825
- }
3826
-
3827
- const body = {
3828
- runtimeId: st.labActiveRuntime,
3829
- model: st.labActiveModel,
3830
- messages,
3831
- parameters,
3832
- sessionId,
3833
- };
3834
-
3835
- const res = await fetch('/api/lab/inference', {
3836
- method: 'POST',
3837
- headers: { 'Content-Type': 'application/json' },
3838
- body: JSON.stringify(body),
3839
- signal: abortController.signal,
3840
- });
3841
-
3842
- if (!res.ok) {
3843
- let errMsg;
3844
- try { errMsg = (await res.json()).error || `HTTP ${res.status}`; } catch { errMsg = `HTTP ${res.status}`; }
3845
- throw new Error(errMsg);
3846
- }
3847
-
3848
- const reader = res.body.getReader();
3849
- const decoder = new TextDecoder();
3850
- let buffer = '';
3851
- let fullContent = '';
3852
- let fullReasoning = '';
3853
-
3854
- while (true) {
3855
- const { done, value } = await reader.read();
3856
- if (done) break;
3857
- buffer += decoder.decode(value, { stream: true });
3858
- const lines = buffer.split('\n');
3859
- buffer = lines.pop() || '';
3860
-
3861
- for (const line of lines) {
3862
- if (!line.startsWith('data: ')) continue;
3863
- const payload = line.slice(6);
3864
- if (payload === '[DONE]') continue;
3865
- try {
3866
- const parsed = JSON.parse(payload);
3867
-
3868
- // Support both raw OpenAI format (piped) and legacy wrapper format
3869
- const delta = parsed.choices?.[0]?.delta;
3870
- const reasoningText = delta?.reasoning_content || (parsed.type === 'reasoning' ? parsed.content : null);
3871
- const contentText = delta?.content || (parsed.type === 'token' ? parsed.content : null);
3872
-
3873
- if (reasoningText) {
3874
- if (!firstTokenTime) firstTokenTime = performance.now();
3875
- tokenCount++;
3876
- fullReasoning += reasoningText;
3877
- set((s) => {
3878
- const sessions = s.labSessions.map((sess) => {
3879
- if (sess.id !== sessionId) return sess;
3880
- const msgs = [...sess.messages];
3881
- msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], reasoning: fullReasoning };
3882
- return { ...sess, messages: msgs };
3883
- });
3884
- return { labSessions: sessions };
3885
- });
3886
- }
3887
- if (contentText) {
3888
- if (!firstTokenTime) firstTokenTime = performance.now();
3889
- tokenCount++;
3890
- fullContent += contentText;
3891
- set((s) => {
3892
- const sessions = s.labSessions.map((sess) => {
3893
- if (sess.id !== sessionId) return sess;
3894
- const msgs = [...sess.messages];
3895
- msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content: fullContent };
3896
- return { ...sess, messages: msgs };
3897
- });
3898
- return { labSessions: sessions };
3899
- });
3900
- }
3901
-
3902
- // Handle done event (legacy wrapper) or finish_reason (raw OpenAI)
3903
- if (parsed.type === 'done' && parsed.metrics) {
3904
- const elapsed = performance.now() - startTime;
3905
- const ttft = firstTokenTime ? firstTokenTime - startTime : null;
3906
- const tps = tokenCount > 0 && elapsed > 0 ? (tokenCount / (elapsed / 1000)) : null;
3907
- const msgMetrics = { ttft, tokensPerSec: tps, tokens: tokenCount, generationTime: elapsed, ...parsed.metrics };
3908
-
3909
- set((s) => {
3910
- const tpsHist = [...s.labMetrics.tokensPerSecHistory, tps].slice(-10);
3911
- const sessions = s.labSessions.map((sess) => {
3912
- if (sess.id !== sessionId) return sess;
3913
- const msgs = [...sess.messages];
3914
- msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], metrics: msgMetrics };
3915
- return { ...sess, messages: msgs };
3916
- });
3917
- return {
3918
- labSessions: sessions,
3919
- labMetrics: {
3920
- ttft, tokensPerSec: tps, tokensPerSecHistory: tpsHist,
3921
- memory: parsed.metrics.memoryUsage || s.labMetrics.memory,
3922
- totalTokens: s.labMetrics.totalTokens + (parsed.metrics.totalTokens || tokenCount),
3923
- generationTime: parsed.metrics.generationTime || elapsed,
3924
- },
3925
- };
3926
- });
3927
- }
3928
- if (parsed.type === 'error') {
3929
- throw new Error(parsed.error || 'Inference error');
3930
- }
3931
- } catch (e) {
3932
- if (e.message && e.message !== 'Inference error' && !e.message.startsWith('HTTP ')) continue;
3933
- throw e;
3934
- }
3935
- }
3936
- }
3937
-
3938
- // Compute final metrics from client-side timing
3939
- const elapsed = performance.now() - startTime;
3940
- const ttft = firstTokenTime ? firstTokenTime - startTime : null;
3941
- const tps = tokenCount > 0 && elapsed > 0 ? (tokenCount / (elapsed / 1000)) : null;
3942
- if (tokenCount > 0) {
3943
- set((s) => {
3944
- const tpsHist = [...s.labMetrics.tokensPerSecHistory, tps].slice(-10);
3945
- const sessions = s.labSessions.map((sess) => {
3946
- if (sess.id !== sessionId) return sess;
3947
- const msgs = [...sess.messages];
3948
- const last = msgs[msgs.length - 1];
3949
- if (!last?.metrics) {
3950
- msgs[msgs.length - 1] = { ...last, metrics: { ttft, tokensPerSec: tps, tokens: tokenCount, generationTime: elapsed } };
3951
- }
3952
- return { ...sess, messages: msgs };
3953
- });
3954
- return {
3955
- labSessions: sessions,
3956
- labMetrics: { ...s.labMetrics, ttft, tokensPerSec: tps, tokensPerSecHistory: tpsHist, totalTokens: s.labMetrics.totalTokens + tokenCount, generationTime: elapsed },
3957
- };
3958
- });
3959
- }
3960
- } catch (err) {
3961
- if (err.name === 'AbortError') {
3962
- // User cancelled — keep whatever content was already streamed
3963
- } else {
3964
- set((s) => {
3965
- const sessions = s.labSessions.map((sess) => {
3966
- if (sess.id !== sessionId) return sess;
3967
- const msgs = [...sess.messages];
3968
- msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content: `Error: ${err.message}`, error: true };
3969
- return { ...sess, messages: msgs };
3970
- });
3971
- return { labSessions: sessions };
3972
- });
3973
- }
3974
- } finally {
3975
- set({ labStreaming: false, labAbortController: null });
3976
- }
3977
- },
3978
-
3979
- stopLabInference() {
3980
- const ctrl = get().labAbortController;
3981
- if (ctrl) ctrl.abort();
3982
- },
3983
-
3984
- saveLabPreset(name) {
3985
- const st = get();
3986
- const preset = {
3987
- id: `preset-${Date.now()}`,
3988
- name,
3989
- parameters: { ...st.labParameters },
3990
- systemPrompt: st.labSystemPrompt,
3991
- runtimeId: st.labActiveRuntime,
3992
- model: st.labActiveModel,
3993
- createdAt: Date.now(),
3994
- };
3995
- const presets = [...st.labPresets.filter((p) => p.name !== name), preset];
3996
- set({ labPresets: presets, labActivePreset: preset.id });
3997
- persistJSON('groove:labPresets', presets);
3998
- get().addToast('success', `Preset "${name}" saved`);
3999
- return preset;
4000
- },
4001
-
4002
- loadLabPreset(id) {
4003
- const preset = get().labPresets.find((p) => p.id === id);
4004
- if (!preset) return;
4005
- const updates = {
4006
- labParameters: { ...preset.parameters },
4007
- labSystemPrompt: preset.systemPrompt || '',
4008
- labActivePreset: id,
4009
- };
4010
- if (preset.model) updates.labActiveModel = preset.model;
4011
- set(updates);
4012
- persistJSON('groove:labParameters', preset.parameters);
4013
- if (preset.systemPrompt !== undefined) localStorage.setItem('groove:labSystemPrompt', preset.systemPrompt);
4014
- },
4015
-
4016
- deleteLabPreset(id) {
4017
- const presets = get().labPresets.filter((p) => p.id !== id);
4018
- set({ labPresets: presets, labActivePreset: get().labActivePreset === id ? null : get().labActivePreset });
4019
- persistJSON('groove:labPresets', presets);
4020
- get().addToast('success', 'Preset deleted');
4021
- },
4022
-
4023
- async launchLabAssistant(backend) {
4024
- const existing = get().labAssistantAgentId;
4025
- if (existing) {
4026
- const agent = get().agents.find((a) => a.id === existing);
4027
- if (agent && agent.status === 'running') {
4028
- set({ labAssistantMode: true });
4029
- return;
4030
- }
4031
- }
4032
- try {
4033
- const data = await api.post('/lab/assistant', { backend });
4034
- localStorage.setItem('groove:labAssistantAgentId', data.agentId);
4035
- localStorage.setItem('groove:labAssistantBackend', backend);
4036
- set({ labAssistantAgentId: data.agentId, labAssistantMode: true, labAssistantBackend: backend });
4037
- get().addToast('info', `Lab Assistant started for ${backend}`);
4038
- } catch (err) {
4039
- get().addToast('error', 'Failed to start assistant', err.message);
4040
- }
4041
- },
4042
-
4043
- dismissLabAssistant() {
4044
- set({ labAssistantMode: false });
4045
- },
4046
-
4047
- clearLabAssistant() {
4048
- const id = get().labAssistantAgentId;
4049
- if (id) api.delete(`/agents/${encodeURIComponent(id)}`).catch(() => {});
4050
- localStorage.removeItem('groove:labAssistantAgentId');
4051
- localStorage.removeItem('groove:labAssistantBackend');
4052
- set({ labAssistantAgentId: null, labAssistantMode: false, labAssistantBackend: null });
4053
- },
4054
-
4055
- setLabAssistantMode(mode) {
4056
- set({ labAssistantMode: mode });
4057
- },
4058
-
4059
- async renameFile(oldPath, newPath) {
4060
- try {
4061
- await api.post('/files/rename', { oldPath, newPath });
4062
- set((s) => {
4063
- const tabs = s.editorOpenTabs.map((t) => t === oldPath ? newPath : t);
4064
- const files = { ...s.editorFiles };
4065
- if (files[oldPath]) { files[newPath] = files[oldPath]; delete files[oldPath]; }
4066
- const active = s.editorActiveFile === oldPath ? newPath : s.editorActiveFile;
4067
- return { editorOpenTabs: tabs, editorFiles: files, editorActiveFile: active };
4068
- });
4069
- const oldParent = oldPath.includes('/') ? oldPath.split('/').slice(0, -1).join('/') : '';
4070
- const newParent = newPath.includes('/') ? newPath.split('/').slice(0, -1).join('/') : '';
4071
- await get().fetchTreeDir(oldParent);
4072
- if (newParent !== oldParent) await get().fetchTreeDir(newParent);
4073
- get().addToast('success', 'Renamed');
4074
- return true;
4075
- } catch (err) {
4076
- get().addToast('error', 'Rename failed', err.message);
4077
- return false;
4078
- }
4079
- },
4080
-
4081
- // ── Integration Agent Install ────────────────────────────
4082
-
4083
- async installViaExistingAgent(integration, agentId) {
4084
- const message = buildIntegrationPrompt(integration);
4085
- await get().instructAgent(agentId, message);
4086
- get().setActiveView('agents');
4087
- get().selectAgent(agentId);
4088
- },
4089
-
4090
- async spawnIntegrationTeam(integration) {
4091
- const team = await get().createTeam(integration.name);
4092
- const prompt = buildIntegrationPrompt(integration);
4093
- const agent = await get().spawnAgent({ role: 'planner', prompt, teamId: team.id });
4094
- get().setActiveView('agents');
4095
- get().selectAgent(agent.id);
4096
- return agent;
4097
- },
4098
1011
  }));
4099
-
4100
- function buildIntegrationPrompt(integration) {
4101
- const lines = [
4102
- `Set up the "${integration.name}" integration for this project.`,
4103
- '',
4104
- ];
4105
- if (integration.description) lines.push(`**Description:** ${integration.description}`);
4106
- if (integration.npmPackage) lines.push(`**npm package:** ${integration.npmPackage}`);
4107
- if (integration.authType) lines.push(`**Auth type:** ${integration.authType}`);
4108
- if (integration.envKeys?.length) {
4109
- lines.push('', '**Environment keys required:**');
4110
- for (const k of integration.envKeys) {
4111
- lines.push(`- \`${k.key}\` — ${k.label}${k.required ? ' (required)' : ''}`);
4112
- }
4113
- }
4114
- if (integration.setupSteps?.length) {
4115
- lines.push('', '**Setup steps:**');
4116
- integration.setupSteps.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
4117
- }
4118
- if (integration.setupUrl) lines.push(``, `**Setup URL:** ${integration.setupUrl}`);
4119
- if (integration.agentInstructions) lines.push('', `**Agent instructions:** ${integration.agentInstructions}`);
4120
- return lines.join('\n');
4121
- }