@swarmclawai/swarmclaw 0.5.3 → 0.6.2

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 (224) hide show
  1. package/README.md +53 -9
  2. package/bin/server-cmd.js +1 -0
  3. package/bin/swarmclaw.js +76 -16
  4. package/next.config.ts +11 -1
  5. package/package.json +5 -2
  6. package/scripts/postinstall.mjs +18 -0
  7. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  8. package/src/app/api/chatrooms/[id]/chat/route.ts +284 -0
  9. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  10. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  11. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  12. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  13. package/src/app/api/chatrooms/route.ts +50 -0
  14. package/src/app/api/connectors/[id]/route.ts +1 -0
  15. package/src/app/api/connectors/route.ts +2 -1
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/files/open/route.ts +43 -0
  18. package/src/app/api/knowledge/[id]/route.ts +13 -2
  19. package/src/app/api/knowledge/route.ts +8 -1
  20. package/src/app/api/memory/route.ts +8 -0
  21. package/src/app/api/notifications/route.ts +4 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +53 -1
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  29. package/src/app/api/sessions/[id]/route.ts +4 -0
  30. package/src/app/api/sessions/route.ts +3 -3
  31. package/src/app/api/settings/route.ts +9 -0
  32. package/src/app/api/setup/check-provider/route.ts +3 -16
  33. package/src/app/api/skills/[id]/route.ts +6 -0
  34. package/src/app/api/skills/route.ts +6 -0
  35. package/src/app/api/tasks/[id]/route.ts +12 -0
  36. package/src/app/api/tasks/bulk/route.ts +100 -0
  37. package/src/app/api/tasks/metrics/route.ts +101 -0
  38. package/src/app/api/tasks/route.ts +18 -2
  39. package/src/app/api/tts/route.ts +3 -2
  40. package/src/app/api/tts/stream/route.ts +3 -2
  41. package/src/app/api/uploads/[filename]/route.ts +19 -34
  42. package/src/app/api/uploads/route.ts +94 -0
  43. package/src/app/api/webhooks/[id]/route.ts +15 -1
  44. package/src/app/globals.css +63 -15
  45. package/src/app/page.tsx +142 -13
  46. package/src/cli/index.js +40 -1
  47. package/src/cli/index.test.js +30 -0
  48. package/src/cli/spec.js +42 -0
  49. package/src/components/agents/agent-avatar.tsx +57 -10
  50. package/src/components/agents/agent-card.tsx +50 -17
  51. package/src/components/agents/agent-chat-list.tsx +148 -12
  52. package/src/components/agents/agent-list.tsx +50 -19
  53. package/src/components/agents/agent-sheet.tsx +120 -65
  54. package/src/components/agents/inspector-panel.tsx +81 -6
  55. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  56. package/src/components/agents/personality-builder.tsx +42 -14
  57. package/src/components/agents/soul-library-picker.tsx +89 -0
  58. package/src/components/auth/access-key-gate.tsx +10 -3
  59. package/src/components/auth/setup-wizard.tsx +2 -2
  60. package/src/components/auth/user-picker.tsx +31 -3
  61. package/src/components/canvas/canvas-panel.tsx +96 -0
  62. package/src/components/chat/activity-moment.tsx +173 -0
  63. package/src/components/chat/chat-area.tsx +46 -22
  64. package/src/components/chat/chat-header.tsx +457 -286
  65. package/src/components/chat/chat-preview-panel.tsx +1 -2
  66. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  67. package/src/components/chat/delegation-banner.tsx +371 -0
  68. package/src/components/chat/file-path-chip.tsx +146 -0
  69. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  70. package/src/components/chat/markdown-utils.ts +9 -0
  71. package/src/components/chat/message-bubble.tsx +356 -315
  72. package/src/components/chat/message-list.tsx +230 -8
  73. package/src/components/chat/streaming-bubble.tsx +104 -47
  74. package/src/components/chat/suggestions-bar.tsx +1 -1
  75. package/src/components/chat/thinking-indicator.tsx +72 -10
  76. package/src/components/chat/tool-call-bubble.tsx +111 -73
  77. package/src/components/chat/tool-request-banner.tsx +31 -7
  78. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  79. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  80. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  81. package/src/components/chatrooms/chatroom-list.tsx +130 -0
  82. package/src/components/chatrooms/chatroom-message.tsx +432 -0
  83. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  84. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  85. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  86. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  87. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  88. package/src/components/connectors/connector-list.tsx +168 -90
  89. package/src/components/connectors/connector-sheet.tsx +95 -56
  90. package/src/components/home/home-view.tsx +501 -0
  91. package/src/components/input/chat-input.tsx +107 -43
  92. package/src/components/knowledge/knowledge-list.tsx +31 -1
  93. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  94. package/src/components/layout/app-layout.tsx +194 -97
  95. package/src/components/layout/update-banner.tsx +2 -2
  96. package/src/components/logs/log-list.tsx +2 -2
  97. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  98. package/src/components/memory/memory-agent-list.tsx +143 -0
  99. package/src/components/memory/memory-browser.tsx +205 -0
  100. package/src/components/memory/memory-card.tsx +34 -7
  101. package/src/components/memory/memory-detail.tsx +359 -120
  102. package/src/components/memory/memory-sheet.tsx +157 -23
  103. package/src/components/plugins/plugin-list.tsx +1 -1
  104. package/src/components/plugins/plugin-sheet.tsx +1 -1
  105. package/src/components/projects/project-detail.tsx +509 -0
  106. package/src/components/projects/project-list.tsx +195 -59
  107. package/src/components/providers/provider-list.tsx +2 -2
  108. package/src/components/providers/provider-sheet.tsx +3 -3
  109. package/src/components/schedules/schedule-card.tsx +1 -1
  110. package/src/components/schedules/schedule-list.tsx +1 -1
  111. package/src/components/schedules/schedule-sheet.tsx +259 -126
  112. package/src/components/secrets/secret-sheet.tsx +47 -24
  113. package/src/components/secrets/secrets-list.tsx +18 -8
  114. package/src/components/sessions/new-session-sheet.tsx +33 -65
  115. package/src/components/sessions/session-card.tsx +45 -14
  116. package/src/components/sessions/session-list.tsx +35 -18
  117. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  118. package/src/components/shared/agent-picker-list.tsx +90 -0
  119. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  120. package/src/components/shared/attachment-chip.tsx +165 -0
  121. package/src/components/shared/avatar.tsx +10 -1
  122. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  123. package/src/components/shared/check-icon.tsx +12 -0
  124. package/src/components/shared/confirm-dialog.tsx +1 -1
  125. package/src/components/shared/connector-platform-icon.tsx +51 -4
  126. package/src/components/shared/empty-state.tsx +32 -0
  127. package/src/components/shared/file-preview.tsx +34 -0
  128. package/src/components/shared/form-styles.ts +2 -0
  129. package/src/components/shared/icon-button.tsx +16 -2
  130. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  131. package/src/components/shared/notification-center.tsx +44 -6
  132. package/src/components/shared/profile-sheet.tsx +115 -0
  133. package/src/components/shared/reply-quote.tsx +26 -0
  134. package/src/components/shared/search-dialog.tsx +31 -15
  135. package/src/components/shared/section-label.tsx +12 -0
  136. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  137. package/src/components/shared/settings/section-embedding.tsx +48 -13
  138. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  139. package/src/components/shared/settings/section-providers.tsx +1 -1
  140. package/src/components/shared/settings/section-secrets.tsx +1 -1
  141. package/src/components/shared/settings/section-storage.tsx +206 -0
  142. package/src/components/shared/settings/section-theme.tsx +95 -0
  143. package/src/components/shared/settings/section-user-preferences.tsx +57 -0
  144. package/src/components/shared/settings/section-voice.tsx +42 -21
  145. package/src/components/shared/settings/section-web-search.tsx +30 -6
  146. package/src/components/shared/settings/settings-page.tsx +182 -27
  147. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  148. package/src/components/shared/settings/storage-browser.tsx +259 -0
  149. package/src/components/shared/sheet-footer.tsx +33 -0
  150. package/src/components/skills/skill-list.tsx +61 -30
  151. package/src/components/skills/skill-sheet.tsx +81 -2
  152. package/src/components/tasks/task-board.tsx +448 -26
  153. package/src/components/tasks/task-card.tsx +59 -9
  154. package/src/components/tasks/task-column.tsx +62 -3
  155. package/src/components/tasks/task-list.tsx +12 -4
  156. package/src/components/tasks/task-sheet.tsx +416 -74
  157. package/src/components/ui/hover-card.tsx +52 -0
  158. package/src/components/usage/metrics-dashboard.tsx +90 -6
  159. package/src/components/usage/usage-list.tsx +1 -1
  160. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  161. package/src/hooks/use-continuous-speech.ts +10 -4
  162. package/src/hooks/use-view-router.ts +69 -19
  163. package/src/hooks/use-voice-conversation.ts +53 -10
  164. package/src/hooks/use-ws.ts +4 -2
  165. package/src/instrumentation.ts +15 -1
  166. package/src/lib/chat.ts +2 -0
  167. package/src/lib/memory.ts +3 -0
  168. package/src/lib/providers/anthropic.ts +13 -7
  169. package/src/lib/providers/index.ts +1 -0
  170. package/src/lib/providers/openai.ts +13 -7
  171. package/src/lib/server/chat-execution.ts +75 -15
  172. package/src/lib/server/chatroom-helpers.ts +146 -0
  173. package/src/lib/server/connectors/manager.ts +229 -7
  174. package/src/lib/server/context-manager.ts +225 -13
  175. package/src/lib/server/create-notification.ts +14 -2
  176. package/src/lib/server/daemon-state.ts +157 -10
  177. package/src/lib/server/execution-log.ts +1 -0
  178. package/src/lib/server/heartbeat-service.ts +48 -6
  179. package/src/lib/server/heartbeat-wake.ts +110 -0
  180. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  181. package/src/lib/server/main-agent-loop.ts +1 -1
  182. package/src/lib/server/memory-consolidation.ts +105 -0
  183. package/src/lib/server/memory-db.ts +183 -10
  184. package/src/lib/server/mime.ts +51 -0
  185. package/src/lib/server/openclaw-gateway.ts +9 -1
  186. package/src/lib/server/orchestrator-lg.ts +2 -0
  187. package/src/lib/server/orchestrator.ts +5 -2
  188. package/src/lib/server/playwright-proxy.mjs +2 -3
  189. package/src/lib/server/prompt-runtime-context.ts +53 -0
  190. package/src/lib/server/provider-health.ts +125 -0
  191. package/src/lib/server/queue.ts +56 -10
  192. package/src/lib/server/scheduler.ts +8 -0
  193. package/src/lib/server/session-run-manager.ts +4 -0
  194. package/src/lib/server/session-tools/canvas.ts +67 -0
  195. package/src/lib/server/session-tools/chatroom.ts +136 -0
  196. package/src/lib/server/session-tools/connector.ts +83 -9
  197. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  198. package/src/lib/server/session-tools/crud.ts +21 -0
  199. package/src/lib/server/session-tools/delegate.ts +68 -4
  200. package/src/lib/server/session-tools/git.ts +71 -0
  201. package/src/lib/server/session-tools/http.ts +57 -0
  202. package/src/lib/server/session-tools/index.ts +10 -0
  203. package/src/lib/server/session-tools/memory.ts +7 -1
  204. package/src/lib/server/session-tools/search-providers.ts +16 -8
  205. package/src/lib/server/session-tools/subagent.ts +106 -0
  206. package/src/lib/server/session-tools/web.ts +115 -4
  207. package/src/lib/server/storage.ts +53 -29
  208. package/src/lib/server/stream-agent-chat.ts +185 -57
  209. package/src/lib/server/system-events.ts +49 -0
  210. package/src/lib/server/task-mention.ts +41 -0
  211. package/src/lib/server/ws-hub.ts +11 -0
  212. package/src/lib/sessions.ts +10 -0
  213. package/src/lib/soul-library.ts +103 -0
  214. package/src/lib/soul-suggestions.ts +109 -0
  215. package/src/lib/task-dedupe.ts +26 -0
  216. package/src/lib/tasks.ts +4 -1
  217. package/src/lib/tool-definitions.ts +2 -0
  218. package/src/lib/tts.ts +2 -2
  219. package/src/lib/view-routes.ts +36 -1
  220. package/src/lib/ws-client.ts +14 -4
  221. package/src/stores/use-app-store.ts +41 -3
  222. package/src/stores/use-chat-store.ts +113 -5
  223. package/src/stores/use-chatroom-store.ts +276 -0
  224. package/src/types/index.ts +88 -4
@@ -0,0 +1,52 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { HoverCard as HoverCardPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function HoverCard({
9
+ openDelay = 300,
10
+ closeDelay = 150,
11
+ ...props
12
+ }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
13
+ return (
14
+ <HoverCardPrimitive.Root
15
+ data-slot="hover-card"
16
+ openDelay={openDelay}
17
+ closeDelay={closeDelay}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ function HoverCardTrigger({
24
+ ...props
25
+ }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
26
+ return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
27
+ }
28
+
29
+ function HoverCardContent({
30
+ className,
31
+ sideOffset = 8,
32
+ children,
33
+ ...props
34
+ }: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
35
+ return (
36
+ <HoverCardPrimitive.Portal>
37
+ <HoverCardPrimitive.Content
38
+ data-slot="hover-card-content"
39
+ sideOffset={sideOffset}
40
+ className={cn(
41
+ "rounded-[12px] w-[260px] z-50 p-3 border border-white/[0.08] shadow-xl backdrop-blur-xl bg-[rgba(16,16,28,0.95)] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ </HoverCardPrimitive.Content>
48
+ </HoverCardPrimitive.Portal>
49
+ )
50
+ }
51
+
52
+ export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -57,6 +57,13 @@ function formatCost(n: number): string {
57
57
  return `$${n.toFixed(4)}`
58
58
  }
59
59
 
60
+ function formatDuration(ms: number): string {
61
+ if (!ms) return '—'
62
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
63
+ if (ms < 3600_000) return `${Math.round(ms / 60_000)}m`
64
+ return `${(ms / 3600_000).toFixed(1)}h`
65
+ }
66
+
60
67
  function formatBucketLabel(bucket: string, range: Range): string {
61
68
  if (range === '24h') {
62
69
  // "2026-03-01T14" → "14:00"
@@ -128,7 +135,24 @@ export function MetricsDashboard() {
128
135
  // eslint-disable-next-line react-hooks/exhaustive-deps
129
136
  }, [])
130
137
 
138
+ // --- Task metrics ---
139
+ const [taskMetrics, setTaskMetrics] = useState<{
140
+ wip: number; completedCount: number; avgCycleMs: number
141
+ velocity: { bucket: string; count: number }[]
142
+ byAgent: { agentName: string; completed: number; failed: number }[]
143
+ } | null>(null)
144
+
145
+ const loadTaskMetrics = useCallback(async () => {
146
+ try {
147
+ const res = await api<typeof taskMetrics>('GET', `/tasks/metrics?range=${range}`)
148
+ setTaskMetrics(res)
149
+ } catch { /* ignore */ }
150
+ }, [range])
151
+
152
+ useEffect(() => { loadTaskMetrics() }, [loadTaskMetrics])
153
+
131
154
  useWs('usage', loadData, 30_000)
155
+ useWs('tasks', loadTaskMetrics, 15_000)
132
156
 
133
157
  const completionRate = computeCompletionRate(tasks)
134
158
 
@@ -154,20 +178,20 @@ export function MetricsDashboard() {
154
178
 
155
179
  const tooltipStyle = {
156
180
  contentStyle: {
157
- background: '#1a1a2e',
181
+ background: 'var(--color-surface)',
158
182
  border: '1px solid rgba(255,255,255,0.08)',
159
183
  borderRadius: 8,
160
184
  fontSize: 12,
161
- color: '#e0e0e0',
185
+ color: 'var(--color-text)',
162
186
  },
163
- itemStyle: { color: '#e0e0e0' },
164
- labelStyle: { color: '#a0a0b0' },
187
+ itemStyle: { color: 'var(--color-text)' },
188
+ labelStyle: { color: 'var(--color-text-2)' },
165
189
  }
166
190
 
167
191
  return (
168
192
  <div className="flex-1 flex flex-col h-full overflow-y-auto">
169
193
  <div className="px-8 pt-6 pb-4 shrink-0">
170
- <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Usage</h1>
194
+ <h1 className="font-display text-[28px] font-700 tracking-[-0.03em]">Usage</h1>
171
195
  <p className="text-[13px] text-text-3 mt-1">Token usage, cost tracking &amp; agent performance</p>
172
196
  </div>
173
197
 
@@ -192,7 +216,10 @@ export function MetricsDashboard() {
192
216
 
193
217
  {loading && !data ? (
194
218
  <div className="flex-1 flex items-center justify-center">
195
- <p className="text-text-3 text-[13px]">Loading metrics…</p>
219
+ <div className="flex items-center gap-3">
220
+ <span className="w-5 h-5 rounded-full border-2 border-text-3/20 border-t-accent-bright animate-spin" />
221
+ <span className="text-[14px] text-text-3">Loading metrics...</span>
222
+ </div>
196
223
  </div>
197
224
  ) : (
198
225
  <div className="px-8 pb-8 space-y-6">
@@ -276,6 +303,63 @@ export function MetricsDashboard() {
276
303
  </ChartCard>
277
304
  </div>
278
305
 
306
+ {/* Task KPIs */}
307
+ {taskMetrics && (
308
+ <>
309
+ <h3 className="font-display text-[16px] font-700 text-text mt-2">Task Performance</h3>
310
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
311
+ <StatCard label="Tasks Completed" value={String(taskMetrics.completedCount)} />
312
+ <StatCard label="Avg Cycle Time" value={formatDuration(taskMetrics.avgCycleMs)} />
313
+ <StatCard label="WIP" value={String(taskMetrics.wip)} />
314
+ <StatCard label="Completion Rate" value={`${completionRate}%`} />
315
+ </div>
316
+
317
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
318
+ <ChartCard title="Task Velocity">
319
+ {taskMetrics.velocity.length > 0 ? (
320
+ <ResponsiveContainer width="100%" height={280}>
321
+ <BarChart data={taskMetrics.velocity.map((v) => ({ ...v, label: formatBucketLabel(v.bucket, range) }))} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
322
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
323
+ <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
324
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
325
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [value ?? 0, 'Completed']} />
326
+ <Bar dataKey="count" fill="#34D399" radius={[4, 4, 0, 0]} />
327
+ </BarChart>
328
+ </ResponsiveContainer>
329
+ ) : (
330
+ <EmptyChart />
331
+ )}
332
+ </ChartCard>
333
+
334
+ <ChartCard title="Tasks by Agent">
335
+ {taskMetrics.byAgent.length > 0 ? (
336
+ <ResponsiveContainer width="100%" height={280}>
337
+ <BarChart
338
+ data={taskMetrics.byAgent.slice(0, 8).map((a) => ({
339
+ name: a.agentName.length > 12 ? a.agentName.slice(0, 12) + '…' : a.agentName,
340
+ completed: a.completed,
341
+ failed: a.failed,
342
+ }))}
343
+ margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
344
+ >
345
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
346
+ <XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
347
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
348
+ <Tooltip {...tooltipStyle} />
349
+ <Bar dataKey="completed" fill="#34D399" radius={[4, 4, 0, 0]} stackId="a" name="Completed" />
350
+ <Bar dataKey="failed" fill="#F87171" radius={[4, 4, 0, 0]} stackId="a" name="Failed" />
351
+ <Legend verticalAlign="bottom" iconType="circle" iconSize={8}
352
+ formatter={(value: string) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{value}</span>} />
353
+ </BarChart>
354
+ </ResponsiveContainer>
355
+ ) : (
356
+ <EmptyChart />
357
+ )}
358
+ </ChartCard>
359
+ </div>
360
+ </>
361
+ )}
362
+
279
363
  {/* Provider Health */}
280
364
  {data?.providerHealth && Object.keys(data.providerHealth).length > 0 && (
281
365
  <div>
@@ -98,7 +98,7 @@ export function UsageList() {
98
98
  </div>
99
99
  <div className="mt-2 h-1 rounded-full bg-white/[0.04] overflow-hidden">
100
100
  <div
101
- className="h-full rounded-full bg-[#6366F1]/60"
101
+ className="h-full rounded-full bg-accent-bright/60"
102
102
  style={{ width: `${Math.max(pct, 1)}%` }}
103
103
  />
104
104
  </div>
@@ -389,7 +389,7 @@ export function WebhookSheet() {
389
389
  <button
390
390
  onClick={handleSave}
391
391
  disabled={saving}
392
- className="px-8 py-3 rounded-[14px] border-none bg-[#6366F1] text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
392
+ className="px-8 py-3 rounded-[14px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
393
393
  style={{ fontFamily: 'inherit' }}
394
394
  >
395
395
  {saving ? 'Saving...' : editing ? 'Update' : 'Create'}
@@ -48,7 +48,7 @@ interface UseContinuousSpeechOptions {
48
48
 
49
49
  export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
50
50
  const { lang, silenceDelayMs = 800, onUtterance } = options
51
- const [state, setState] = useState<ContinuousSpeechState>('idle')
51
+ const [state, _setState] = useState<ContinuousSpeechState>('idle')
52
52
  const [transcript, setTranscript] = useState('')
53
53
  const [interimText, setInterimText] = useState('')
54
54
 
@@ -56,6 +56,12 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
56
56
  const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
57
57
  const activeRef = useRef(false)
58
58
  const accumulatedRef = useRef('')
59
+ const stateRef = useRef<ContinuousSpeechState>('idle')
60
+
61
+ const setState = useCallback((next: ContinuousSpeechState) => {
62
+ stateRef.current = next
63
+ _setState(next)
64
+ }, [])
59
65
 
60
66
  const clearSilenceTimer = () => {
61
67
  if (silenceTimerRef.current) {
@@ -122,7 +128,7 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
122
128
 
123
129
  recog.onend = () => {
124
130
  // Auto-restart if still active (browser may stop recognition periodically)
125
- if (activeRef.current && state !== 'waitingForResponse') {
131
+ if (activeRef.current && stateRef.current !== 'waitingForResponse') {
126
132
  try { recog.start() } catch { /* noop */ }
127
133
  }
128
134
  }
@@ -156,7 +162,7 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
156
162
  setTranscript('')
157
163
  setInterimText('')
158
164
  accumulatedRef.current = ''
159
- }, [])
165
+ }, [setState])
160
166
 
161
167
  const pause = useCallback(() => {
162
168
  clearSilenceTimer()
@@ -172,7 +178,7 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
172
178
  setInterimText('')
173
179
  setState('listening')
174
180
  startRecognition()
175
- }, [startRecognition])
181
+ }, [startRecognition, setState])
176
182
 
177
183
  const supported = typeof window !== 'undefined' &&
178
184
  !!((window as unknown as WindowWithSpeechRecognition).SpeechRecognition || (window as unknown as WindowWithSpeechRecognition).webkitSpeechRecognition)
@@ -2,48 +2,98 @@
2
2
 
3
3
  import { useEffect, useRef } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
- import { VIEW_TO_PATH, PATH_TO_VIEW, DEFAULT_VIEW } from '@/lib/view-routes'
5
+ import { useChatroomStore } from '@/stores/use-chatroom-store'
6
+ import { parsePath, buildPath, DEFAULT_VIEW } from '@/lib/view-routes'
7
+
8
+ /** Map a view to the relevant entity ID from stores */
9
+ function getIdForView(view: string): string | null {
10
+ if (view === 'agents') return useAppStore.getState().currentAgentId
11
+ if (view === 'chatrooms') return useChatroomStore.getState().currentChatroomId
12
+ return null
13
+ }
14
+
15
+ /** Apply a parsed route to the stores */
16
+ function applyRoute(view: string, id: string | null) {
17
+ if (view === 'agents') useAppStore.getState().setCurrentAgent(id)
18
+ if (view === 'chatrooms') useChatroomStore.getState().setCurrentChatroom(id)
19
+ }
6
20
 
7
21
  export function useViewRouter() {
8
22
  const fromPopstate = useRef(false)
23
+ const suppressPush = useRef(false)
9
24
 
10
- // Mount: read pathname → set active view
25
+ // Mount: read pathname → set active view + entity ID
11
26
  useEffect(() => {
12
- const view = PATH_TO_VIEW[window.location.pathname]
13
- if (view) {
14
- useAppStore.getState().setActiveView(view)
27
+ const parsed = parsePath(window.location.pathname)
28
+ if (parsed) {
29
+ suppressPush.current = true
30
+ useAppStore.getState().setActiveView(parsed.view)
31
+ applyRoute(parsed.view, parsed.id)
32
+ suppressPush.current = false
15
33
  } else {
16
34
  useAppStore.getState().setActiveView(DEFAULT_VIEW)
17
- window.history.replaceState(null, '', VIEW_TO_PATH[DEFAULT_VIEW])
35
+ window.history.replaceState(null, '', buildPath(DEFAULT_VIEW))
18
36
  }
19
37
  }, [])
20
38
 
21
- // State→URL: push new path when activeView changes
39
+ // State→URL: push new path when activeView or entity ID changes
22
40
  useEffect(() => {
23
- let prev = useAppStore.getState().activeView
24
- const unsub = useAppStore.subscribe((state) => {
25
- const next = state.activeView
26
- if (next === prev) return
27
- prev = next
41
+ let prevView = useAppStore.getState().activeView
42
+ let prevId = getIdForView(prevView)
43
+
44
+ const unsubApp = useAppStore.subscribe((state) => {
45
+ if (suppressPush.current) return
46
+ const nextView = state.activeView
47
+ const nextId = getIdForView(nextView)
48
+
49
+ if (nextView === prevView && nextId === prevId) return
50
+ prevView = nextView
51
+ prevId = nextId
52
+
53
+ if (fromPopstate.current) {
54
+ fromPopstate.current = false
55
+ return
56
+ }
57
+ const targetPath = buildPath(nextView, nextId)
58
+ if (window.location.pathname !== targetPath) {
59
+ window.history.pushState(null, '', targetPath)
60
+ }
61
+ })
62
+
63
+ const unsubChatroom = useChatroomStore.subscribe((state) => {
64
+ if (suppressPush.current) return
65
+ const currentView = useAppStore.getState().activeView
66
+ if (currentView !== 'chatrooms') return
67
+ const nextId = state.currentChatroomId
68
+ if (nextId === prevId) return
69
+ prevId = nextId
70
+
28
71
  if (fromPopstate.current) {
29
72
  fromPopstate.current = false
30
73
  return
31
74
  }
32
- const targetPath = VIEW_TO_PATH[next]
33
- if (targetPath && window.location.pathname !== targetPath) {
75
+ const targetPath = buildPath('chatrooms', nextId)
76
+ if (window.location.pathname !== targetPath) {
34
77
  window.history.pushState(null, '', targetPath)
35
78
  }
36
79
  })
37
- return unsub
80
+
81
+ return () => {
82
+ unsubApp()
83
+ unsubChatroom()
84
+ }
38
85
  }, [])
39
86
 
40
- // Popstate: browser back/forward → update view
87
+ // Popstate: browser back/forward → update view + entity ID
41
88
  useEffect(() => {
42
89
  const onPopstate = () => {
43
- const view = PATH_TO_VIEW[window.location.pathname]
44
- if (view) {
90
+ const parsed = parsePath(window.location.pathname)
91
+ if (parsed) {
45
92
  fromPopstate.current = true
46
- useAppStore.getState().setActiveView(view)
93
+ suppressPush.current = true
94
+ useAppStore.getState().setActiveView(parsed.view)
95
+ applyRoute(parsed.view, parsed.id)
96
+ suppressPush.current = false
47
97
  }
48
98
  }
49
99
  window.addEventListener('popstate', onPopstate)
@@ -1,40 +1,68 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useRef, useState } from 'react'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
4
  import { useContinuousSpeech } from './use-continuous-speech'
5
5
  import { SentenceAccumulator, AudioChunkQueue, fetchStreamTts } from '@/lib/tts-stream'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
7
7
 
8
8
  export type VoiceConversationState = 'idle' | 'listening' | 'processing' | 'speaking'
9
9
 
10
+ /** Max time to wait in 'processing' before falling back to listening (30s). */
11
+ const PROCESSING_TIMEOUT_MS = 30_000
12
+
10
13
  export function useVoiceConversation() {
11
- const [active, setActive] = useState(false)
12
14
  const [voiceState, setVoiceState] = useState<VoiceConversationState>('idle')
13
15
  const accumulatorRef = useRef<SentenceAccumulator | null>(null)
14
16
  const queueRef = useRef<AudioChunkQueue | null>(null)
17
+ const activeRef = useRef(false)
18
+ const processingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
19
+ const [resumeNeeded, setResumeNeeded] = useState(0)
15
20
  const sendMessage = useChatStore((s) => s.sendMessage)
16
21
 
22
+ const clearProcessingTimer = () => {
23
+ if (processingTimerRef.current) {
24
+ clearTimeout(processingTimerRef.current)
25
+ processingTimerRef.current = null
26
+ }
27
+ }
28
+
17
29
  const speech = useContinuousSpeech({
18
30
  onUtterance: useCallback((text: string) => {
19
31
  setVoiceState('processing')
20
- // Send the transcribed text as a chat message
21
32
  sendMessage(text)
33
+ // Safety net: if no stream events arrive within timeout, resume listening
34
+ clearProcessingTimer()
35
+ processingTimerRef.current = setTimeout(() => {
36
+ if (activeRef.current) {
37
+ setVoiceState('listening')
38
+ setResumeNeeded((n) => n + 1)
39
+ }
40
+ }, PROCESSING_TIMEOUT_MS)
22
41
  }, [sendMessage]),
23
42
  })
24
43
 
44
+ // When resumeNeeded increments, call speech.resume
45
+ useEffect(() => {
46
+ if (resumeNeeded > 0) speech.resume()
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ }, [resumeNeeded])
49
+
25
50
  // Called by the chat store's onStreamEvent callback
26
51
  const handleStreamEvent = useCallback((event: { t: string; text?: string }) => {
27
- if (!active) return
52
+ if (!activeRef.current) return
28
53
 
29
54
  if (event.t === 'd' && event.text) {
55
+ clearProcessingTimer()
30
56
  setVoiceState('speaking')
31
57
  if (!accumulatorRef.current) {
32
58
  const queue = new AudioChunkQueue()
33
59
  queueRef.current = queue
34
60
  queue.onComplete = () => {
35
61
  // Resume listening after TTS playback finishes
36
- setVoiceState('listening')
37
- speech.resume()
62
+ if (activeRef.current) {
63
+ setVoiceState('listening')
64
+ speech.resume()
65
+ }
38
66
  }
39
67
  accumulatorRef.current = new SentenceAccumulator((sentence) => {
40
68
  queue.enqueue(fetchStreamTts(sentence))
@@ -42,16 +70,30 @@ export function useVoiceConversation() {
42
70
  }
43
71
  accumulatorRef.current.push(event.text)
44
72
  } else if (event.t === 'done') {
73
+ clearProcessingTimer()
45
74
  // Flush remaining text to TTS
46
75
  if (accumulatorRef.current) {
47
76
  accumulatorRef.current.flush()
48
77
  accumulatorRef.current = null
78
+ } else {
79
+ // No text was streamed (empty response or error) — resume listening
80
+ if (activeRef.current) {
81
+ setVoiceState('listening')
82
+ speech.resume()
83
+ }
84
+ }
85
+ } else if (event.t === 'err') {
86
+ // Error from the LLM — resume listening instead of staying stuck
87
+ clearProcessingTimer()
88
+ if (activeRef.current) {
89
+ setVoiceState('listening')
90
+ speech.resume()
49
91
  }
50
92
  }
51
- }, [active, speech])
93
+ }, [speech])
52
94
 
53
95
  const start = useCallback(() => {
54
- setActive(true)
96
+ activeRef.current = true
55
97
  setVoiceState('listening')
56
98
  // Register the stream event handler on the chat store
57
99
  useChatStore.setState({ onStreamEvent: handleStreamEvent, voiceConversationActive: true })
@@ -59,8 +101,9 @@ export function useVoiceConversation() {
59
101
  }, [speech, handleStreamEvent])
60
102
 
61
103
  const stop = useCallback(() => {
62
- setActive(false)
104
+ activeRef.current = false
63
105
  setVoiceState('idle')
106
+ clearProcessingTimer()
64
107
  speech.stop()
65
108
  queueRef.current?.stop()
66
109
  queueRef.current = null
@@ -69,7 +112,7 @@ export function useVoiceConversation() {
69
112
  }, [speech])
70
113
 
71
114
  return {
72
- active,
115
+ active: activeRef.current || voiceState !== 'idle',
73
116
  state: voiceState,
74
117
  interimText: speech.interimText,
75
118
  transcript: speech.transcript,
@@ -32,8 +32,10 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
32
32
  let fallbackId: ReturnType<typeof setInterval> | null = null
33
33
  const cb = () => handlerRef.current()
34
34
 
35
- // When page becomes visible again, fire an immediate refresh
36
- if (isActive) {
35
+ // When page becomes visible again, fire an immediate refresh
36
+ // but only for topics that use fallback polling (i.e. data-fetch topics).
37
+ // Event-only topics (like heartbeat pulses) should never fire from this effect.
38
+ if (isActive && fallbackMsRef.current && fallbackMsRef.current > 0) {
37
39
  cb()
38
40
  }
39
41
 
@@ -2,9 +2,23 @@ export async function register() {
2
2
  if (process.env.NEXT_RUNTIME === 'nodejs') {
3
3
  const { startScheduler } = await import('./lib/server/scheduler')
4
4
  const { resumeQueue } = await import('./lib/server/queue')
5
- const { initWsServer } = await import('./lib/server/ws-hub')
5
+ const { initWsServer, closeWsServer } = await import('./lib/server/ws-hub')
6
+ const { stopDaemon } = await import('./lib/server/daemon-state')
6
7
  startScheduler()
7
8
  resumeQueue()
8
9
  initWsServer()
10
+
11
+ // Graceful shutdown: stop background services and close WS connections
12
+ let shuttingDown = false
13
+ const shutdown = async (signal: string) => {
14
+ if (shuttingDown) return
15
+ shuttingDown = true
16
+ console.log(`[server] ${signal} received, shutting down gracefully...`)
17
+ stopDaemon({ source: signal })
18
+ await closeWsServer()
19
+ process.exit(0)
20
+ }
21
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
22
+ process.on('SIGINT', () => shutdown('SIGINT'))
9
23
  }
10
24
  }
package/src/lib/chat.ts CHANGED
@@ -4,6 +4,7 @@ import { getStoredAccessKey } from './api-client'
4
4
  interface StreamChatOptions {
5
5
  internal?: boolean
6
6
  queueMode?: 'followup' | 'steer' | 'collect'
7
+ replyToId?: string
7
8
  }
8
9
 
9
10
  export async function streamChat(
@@ -39,6 +40,7 @@ export async function streamChat(
39
40
  attachedFiles,
40
41
  internal: !!opts?.internal,
41
42
  queueMode: opts?.queueMode,
43
+ ...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
42
44
  }),
43
45
  })
44
46
 
package/src/lib/memory.ts CHANGED
@@ -40,3 +40,6 @@ export const updateMemory = (id: string, data: Partial<MemoryEntry>) =>
40
40
 
41
41
  export const deleteMemory = (id: string) =>
42
42
  api<string>('DELETE', `/memory/${id}`)
43
+
44
+ export const getMemoryCounts = () =>
45
+ api<Record<string, number>>('GET', '/memory?counts=true')
@@ -23,9 +23,9 @@ function fileToContentBlocks(filePath: string): any[] {
23
23
  return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
24
24
  }
25
25
 
26
- export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
26
+ export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
27
27
  return new Promise((resolve) => {
28
- const messages = buildMessages(session, message, imagePath, loadHistory)
28
+ const messages = buildMessages(session, message, imagePath, loadHistory, imageUrl)
29
29
  const model = session.model || 'claude-sonnet-4-6'
30
30
  let usageInput = 0
31
31
  let usageOutput = 0
@@ -122,14 +122,19 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
122
122
  })
123
123
  }
124
124
 
125
- function buildMessages(session: any, message: string, imagePath: string | undefined, loadHistory: (id: string) => any[]) {
125
+ function urlToImageBlock(url: string): { type: string; source: { type: string; url: string } } {
126
+ return { type: 'image', source: { type: 'url', url } }
127
+ }
128
+
129
+ function buildMessages(session: any, message: string, imagePath: string | undefined, loadHistory: (id: string) => any[], imageUrl?: string) {
126
130
  const msgs: Array<{ role: string; content: any }> = []
127
131
 
128
132
  if (loadHistory) {
129
133
  const history = loadHistory(session.id).slice(-40)
130
134
  for (const m of history) {
131
- if (m.role === 'user' && m.imagePath) {
132
- const blocks = fileToContentBlocks(m.imagePath)
135
+ if (m.role === 'user' && (m.imagePath || m.imageUrl)) {
136
+ const blocks = m.imagePath ? fileToContentBlocks(m.imagePath) : []
137
+ if (m.imageUrl) blocks.push(urlToImageBlock(m.imageUrl))
133
138
  msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: m.text }] })
134
139
  } else {
135
140
  msgs.push({ role: m.role, content: m.text })
@@ -138,8 +143,9 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
138
143
  }
139
144
 
140
145
  // Current message with optional attachment
141
- if (imagePath) {
142
- const blocks = fileToContentBlocks(imagePath)
146
+ if (imagePath || imageUrl) {
147
+ const blocks = imagePath ? fileToContentBlocks(imagePath) : []
148
+ if (imageUrl) blocks.push(urlToImageBlock(imageUrl))
143
149
  msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: message }] })
144
150
  } else {
145
151
  msgs.push({ role: 'user', content: message })
@@ -22,6 +22,7 @@ export interface StreamChatOptions {
22
22
  session: any
23
23
  message: string
24
24
  imagePath?: string
25
+ imageUrl?: string
25
26
  apiKey?: string | null
26
27
  systemPrompt?: string
27
28
  write: (data: string) => void