@swarmclawai/swarmclaw 0.3.1 → 0.4.5

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 (203) hide show
  1. package/README.md +33 -13
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +10 -0
  6. package/package.json +4 -1
  7. package/src/app/api/agents/[id]/route.ts +20 -18
  8. package/src/app/api/agents/[id]/thread/route.ts +4 -3
  9. package/src/app/api/agents/route.ts +8 -3
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/clawhub/install/route.ts +2 -2
  13. package/src/app/api/connectors/[id]/route.ts +14 -3
  14. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  15. package/src/app/api/connectors/route.ts +12 -4
  16. package/src/app/api/credentials/[id]/route.ts +2 -1
  17. package/src/app/api/credentials/route.ts +5 -3
  18. package/src/app/api/daemon/route.ts +6 -1
  19. package/src/app/api/documents/route.ts +2 -2
  20. package/src/app/api/files/serve/route.ts +8 -0
  21. package/src/app/api/ip/route.ts +3 -1
  22. package/src/app/api/knowledge/[id]/route.ts +5 -4
  23. package/src/app/api/knowledge/upload/route.ts +2 -2
  24. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  25. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  26. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  27. package/src/app/api/mcp-servers/route.ts +5 -3
  28. package/src/app/api/memory/[id]/route.ts +9 -8
  29. package/src/app/api/memory/route.ts +2 -2
  30. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  31. package/src/app/api/openclaw/directory/route.ts +26 -0
  32. package/src/app/api/openclaw/discover/route.ts +61 -0
  33. package/src/app/api/openclaw/sync/route.ts +30 -0
  34. package/src/app/api/orchestrator/graph/route.ts +25 -0
  35. package/src/app/api/orchestrator/run/route.ts +2 -2
  36. package/src/app/api/plugins/marketplace/route.ts +3 -1
  37. package/src/app/api/plugins/route.ts +3 -1
  38. package/src/app/api/projects/[id]/route.ts +55 -0
  39. package/src/app/api/projects/route.ts +27 -0
  40. package/src/app/api/providers/[id]/models/route.ts +2 -1
  41. package/src/app/api/providers/[id]/route.ts +13 -12
  42. package/src/app/api/providers/configs/route.ts +3 -1
  43. package/src/app/api/providers/route.ts +7 -3
  44. package/src/app/api/schedules/[id]/route.ts +16 -15
  45. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  46. package/src/app/api/schedules/route.ts +8 -3
  47. package/src/app/api/secrets/[id]/route.ts +16 -17
  48. package/src/app/api/secrets/route.ts +5 -3
  49. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  50. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  51. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  52. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  53. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  54. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  55. package/src/app/api/sessions/[id]/route.ts +2 -1
  56. package/src/app/api/sessions/route.ts +11 -4
  57. package/src/app/api/settings/route.ts +3 -1
  58. package/src/app/api/setup/doctor/route.ts +1 -0
  59. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  60. package/src/app/api/skills/[id]/route.ts +23 -21
  61. package/src/app/api/skills/import/route.ts +2 -2
  62. package/src/app/api/skills/route.ts +5 -3
  63. package/src/app/api/tasks/[id]/approve/route.ts +74 -0
  64. package/src/app/api/tasks/[id]/route.ts +9 -5
  65. package/src/app/api/tasks/route.ts +5 -2
  66. package/src/app/api/tts/stream/route.ts +48 -0
  67. package/src/app/api/upload/route.ts +2 -2
  68. package/src/app/api/uploads/[filename]/route.ts +4 -1
  69. package/src/app/api/usage/route.ts +3 -1
  70. package/src/app/api/version/route.ts +3 -1
  71. package/src/app/api/webhooks/[id]/route.ts +31 -32
  72. package/src/app/api/webhooks/route.ts +5 -3
  73. package/src/app/icon.svg +58 -0
  74. package/src/app/page.tsx +11 -26
  75. package/src/cli/index.js +28 -9
  76. package/src/cli/index.ts +45 -2
  77. package/src/cli/spec.js +2 -8
  78. package/src/components/agents/agent-card.tsx +1 -1
  79. package/src/components/agents/agent-list.tsx +3 -1
  80. package/src/components/agents/agent-sheet.tsx +166 -81
  81. package/src/components/chat/chat-area.tsx +71 -34
  82. package/src/components/chat/chat-header.tsx +141 -29
  83. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  84. package/src/components/chat/message-bubble.tsx +110 -42
  85. package/src/components/chat/tool-call-bubble.tsx +50 -6
  86. package/src/components/chat/tool-request-banner.tsx +1 -9
  87. package/src/components/chat/voice-overlay.tsx +80 -0
  88. package/src/components/connectors/connector-list.tsx +9 -10
  89. package/src/components/connectors/connector-sheet.tsx +55 -36
  90. package/src/components/input/chat-input.tsx +72 -56
  91. package/src/components/knowledge/knowledge-list.tsx +27 -31
  92. package/src/components/layout/app-layout.tsx +133 -90
  93. package/src/components/layout/daemon-indicator.tsx +3 -5
  94. package/src/components/logs/log-list.tsx +5 -9
  95. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  96. package/src/components/memory/memory-detail.tsx +1 -1
  97. package/src/components/plugins/plugin-list.tsx +227 -27
  98. package/src/components/projects/project-list.tsx +122 -0
  99. package/src/components/projects/project-sheet.tsx +135 -0
  100. package/src/components/providers/provider-list.tsx +46 -13
  101. package/src/components/providers/provider-sheet.tsx +0 -45
  102. package/src/components/runs/run-list.tsx +6 -15
  103. package/src/components/schedules/schedule-card.tsx +54 -4
  104. package/src/components/schedules/schedule-list.tsx +9 -4
  105. package/src/components/schedules/schedule-sheet.tsx +0 -47
  106. package/src/components/secrets/secrets-list.tsx +20 -2
  107. package/src/components/sessions/new-session-sheet.tsx +14 -15
  108. package/src/components/sessions/session-card.tsx +1 -1
  109. package/src/components/sessions/session-list.tsx +7 -7
  110. package/src/components/shared/connector-platform-icon.tsx +26 -20
  111. package/src/components/shared/model-combobox.tsx +148 -0
  112. package/src/components/shared/settings/section-heartbeat.tsx +8 -40
  113. package/src/components/shared/settings/section-orchestrator.tsx +9 -11
  114. package/src/components/shared/settings/section-web-search.tsx +56 -0
  115. package/src/components/shared/settings/settings-page.tsx +73 -0
  116. package/src/components/skills/skill-list.tsx +262 -35
  117. package/src/components/skills/skill-sheet.tsx +0 -45
  118. package/src/components/tasks/task-board.tsx +3 -6
  119. package/src/components/tasks/task-card.tsx +43 -1
  120. package/src/components/tasks/task-list.tsx +8 -7
  121. package/src/components/tasks/task-sheet.tsx +0 -44
  122. package/src/components/usage/usage-list.tsx +12 -4
  123. package/src/hooks/use-continuous-speech.ts +144 -0
  124. package/src/hooks/use-view-router.ts +52 -0
  125. package/src/hooks/use-voice-conversation.ts +80 -0
  126. package/src/hooks/use-ws.ts +66 -0
  127. package/src/instrumentation.ts +2 -0
  128. package/src/lib/chat.ts +14 -2
  129. package/src/lib/id.ts +6 -0
  130. package/src/lib/projects.ts +13 -0
  131. package/src/lib/provider-sets.ts +5 -0
  132. package/src/lib/providers/anthropic.ts +15 -2
  133. package/src/lib/providers/index.ts +8 -0
  134. package/src/lib/providers/ollama.ts +10 -2
  135. package/src/lib/providers/openai.ts +42 -13
  136. package/src/lib/providers/openclaw.ts +11 -0
  137. package/src/lib/server/api-routes.test.ts +5 -6
  138. package/src/lib/server/build-llm.ts +17 -4
  139. package/src/lib/server/chat-execution.ts +57 -8
  140. package/src/lib/server/collection-helpers.ts +54 -0
  141. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  142. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  143. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  144. package/src/lib/server/connectors/googlechat.ts +46 -7
  145. package/src/lib/server/connectors/manager.ts +401 -6
  146. package/src/lib/server/connectors/media.ts +2 -2
  147. package/src/lib/server/connectors/openclaw.ts +64 -0
  148. package/src/lib/server/connectors/pairing.test.ts +99 -0
  149. package/src/lib/server/connectors/pairing.ts +256 -0
  150. package/src/lib/server/connectors/signal.ts +1 -0
  151. package/src/lib/server/connectors/teams.ts +5 -5
  152. package/src/lib/server/connectors/types.ts +10 -0
  153. package/src/lib/server/context-manager.ts +1 -1
  154. package/src/lib/server/daemon-state.ts +3 -0
  155. package/src/lib/server/data-dir.ts +1 -0
  156. package/src/lib/server/execution-log.ts +3 -3
  157. package/src/lib/server/heartbeat-service.ts +67 -3
  158. package/src/lib/server/knowledge-db.test.ts +2 -33
  159. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  160. package/src/lib/server/main-agent-loop.ts +67 -8
  161. package/src/lib/server/memory-db.ts +6 -6
  162. package/src/lib/server/openclaw-approvals.ts +105 -0
  163. package/src/lib/server/openclaw-sync.ts +496 -0
  164. package/src/lib/server/orchestrator-lg.ts +422 -20
  165. package/src/lib/server/orchestrator.ts +29 -9
  166. package/src/lib/server/process-manager.ts +2 -2
  167. package/src/lib/server/queue.ts +39 -13
  168. package/src/lib/server/scheduler.ts +2 -2
  169. package/src/lib/server/session-mailbox.ts +2 -2
  170. package/src/lib/server/session-run-manager.ts +8 -3
  171. package/src/lib/server/session-tools/connector.ts +51 -4
  172. package/src/lib/server/session-tools/crud.ts +3 -3
  173. package/src/lib/server/session-tools/delegate.ts +5 -5
  174. package/src/lib/server/session-tools/file.ts +176 -3
  175. package/src/lib/server/session-tools/index.ts +4 -0
  176. package/src/lib/server/session-tools/memory.ts +2 -2
  177. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  178. package/src/lib/server/session-tools/sandbox.ts +197 -0
  179. package/src/lib/server/session-tools/search-providers.ts +270 -0
  180. package/src/lib/server/session-tools/session-info.ts +2 -2
  181. package/src/lib/server/session-tools/web.ts +47 -66
  182. package/src/lib/server/storage-mcp.test.ts +25 -2
  183. package/src/lib/server/storage.ts +36 -7
  184. package/src/lib/server/stream-agent-chat.ts +106 -22
  185. package/src/lib/server/task-result.test.ts +44 -0
  186. package/src/lib/server/task-result.ts +14 -0
  187. package/src/lib/server/task-validation.test.ts +23 -0
  188. package/src/lib/server/task-validation.ts +5 -3
  189. package/src/lib/server/ws-hub.ts +85 -0
  190. package/src/lib/tool-definitions.ts +44 -0
  191. package/src/lib/tts-stream.ts +130 -0
  192. package/src/lib/upload.ts +7 -1
  193. package/src/lib/view-routes.ts +28 -0
  194. package/src/lib/ws-client.ts +124 -0
  195. package/src/proxy.ts +3 -0
  196. package/src/stores/use-app-store.ts +28 -1
  197. package/src/stores/use-chat-store.ts +42 -14
  198. package/src/types/index.ts +34 -2
  199. package/src/app/api/agents/generate/route.ts +0 -42
  200. package/src/app/api/generate/info/route.ts +0 -12
  201. package/src/app/api/generate/route.ts +0 -106
  202. package/src/app/favicon.ico +0 -0
  203. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -0,0 +1,85 @@
1
+ import { WebSocketServer, WebSocket } from 'ws'
2
+ import type { IncomingMessage } from 'http'
3
+ import { validateAccessKey } from './storage'
4
+
5
+ interface WsClient {
6
+ ws: WebSocket
7
+ topics: Set<string>
8
+ }
9
+
10
+ interface WsHub {
11
+ wss: WebSocketServer
12
+ clients: Set<WsClient>
13
+ }
14
+
15
+ const GK = '__swarmclaw_ws__' as const
16
+
17
+ function getHub(): WsHub | null {
18
+ return (globalThis as any)[GK] ?? null
19
+ }
20
+
21
+ export function initWsServer() {
22
+ if (getHub()) return
23
+
24
+ const port = Number(process.env.WS_PORT) || (Number(process.env.PORT) || 3456) + 1
25
+ const wss = new WebSocketServer({ port, path: '/ws' })
26
+ const clients = new Set<WsClient>()
27
+
28
+ const hub: WsHub = { wss, clients }
29
+ ;(globalThis as any)[GK] = hub
30
+
31
+ wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
32
+ // Auth: validate ?key= from upgrade URL
33
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
34
+ const key = url.searchParams.get('key') || ''
35
+ if (!validateAccessKey(key)) {
36
+ ws.close(4001, 'Unauthorized')
37
+ return
38
+ }
39
+
40
+ const client: WsClient = { ws, topics: new Set() }
41
+ clients.add(client)
42
+
43
+ ws.on('message', (raw) => {
44
+ try {
45
+ const msg = JSON.parse(String(raw))
46
+ if (msg.type === 'subscribe' && Array.isArray(msg.topics)) {
47
+ for (const t of msg.topics) {
48
+ if (typeof t === 'string') client.topics.add(t)
49
+ }
50
+ } else if (msg.type === 'unsubscribe' && Array.isArray(msg.topics)) {
51
+ for (const t of msg.topics) client.topics.delete(t)
52
+ }
53
+ } catch {
54
+ // ignore malformed messages
55
+ }
56
+ })
57
+
58
+ ws.on('close', () => {
59
+ clients.delete(client)
60
+ })
61
+
62
+ ws.on('error', () => {
63
+ clients.delete(client)
64
+ })
65
+ })
66
+
67
+ wss.on('error', (err) => {
68
+ console.error('[ws-hub] WebSocket server error:', err.message)
69
+ })
70
+
71
+ console.log(`[ws-hub] WebSocket server listening on port ${port}`)
72
+ }
73
+
74
+ export function notify(topic: string, action = 'update', id?: string) {
75
+ const hub = getHub()
76
+ if (!hub) return
77
+
78
+ const payload = JSON.stringify(id ? { topic, action, id } : { topic, action })
79
+
80
+ for (const client of hub.clients) {
81
+ if (client.topics.has(topic) && client.ws.readyState === WebSocket.OPEN) {
82
+ client.ws.send(payload)
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,44 @@
1
+ export interface ToolDefinition {
2
+ id: string
3
+ label: string
4
+ description: string
5
+ }
6
+
7
+ export const AVAILABLE_TOOLS: ToolDefinition[] = [
8
+ { id: 'shell', label: 'Shell', description: 'Execute commands in the working directory' },
9
+ { id: 'files', label: 'Files', description: 'Read, write, list, move, copy, and send files' },
10
+ { id: 'copy_file', label: 'Copy File', description: 'Copy files within the working directory' },
11
+ { id: 'move_file', label: 'Move File', description: 'Move/rename files within the working directory' },
12
+ { id: 'delete_file', label: 'Delete File', description: 'Delete files/directories (disabled by default)' },
13
+ { id: 'edit_file', label: 'Edit File', description: 'Search-and-replace editing within files' },
14
+ { id: 'process', label: 'Process', description: 'Monitor and control long-running shell commands' },
15
+ { id: 'web_search', label: 'Web Search', description: 'Search the web via DuckDuckGo' },
16
+ { id: 'web_fetch', label: 'Web Fetch', description: 'Fetch and extract text from URLs' },
17
+ { id: 'claude_code', label: 'Claude Code', description: 'Delegate complex tasks to Claude Code CLI' },
18
+ { id: 'codex_cli', label: 'Codex CLI', description: 'Delegate complex tasks to OpenAI Codex CLI' },
19
+ { id: 'opencode_cli', label: 'OpenCode CLI', description: 'Delegate complex tasks to OpenCode CLI' },
20
+ { id: 'browser', label: 'Browser', description: 'Playwright — browse, scrape, interact with web pages' },
21
+ { id: 'memory', label: 'Memory', description: 'Store and retrieve long-term memories across conversations' },
22
+ { id: 'sandbox', label: 'Sandbox', description: 'Run JS/TS/Python code in an isolated Deno sandbox' },
23
+ { id: 'create_document', label: 'Create Document', description: 'Render markdown to PDF, HTML, or image' },
24
+ { id: 'create_spreadsheet', label: 'Create Spreadsheet', description: 'Create Excel or CSV files from structured data' },
25
+ ]
26
+
27
+ export const PLATFORM_TOOLS: ToolDefinition[] = [
28
+ { id: 'manage_agents', label: 'Agents', description: 'Create, edit, and delete agents' },
29
+ { id: 'manage_tasks', label: 'Tasks', description: 'Create, edit, and delete tasks' },
30
+ { id: 'manage_schedules', label: 'Schedules', description: 'Create, edit, and delete schedules' },
31
+ { id: 'manage_skills', label: 'Skills', description: 'Create, edit, and delete skills' },
32
+ { id: 'manage_documents', label: 'Documents', description: 'Upload, search, and delete indexed documents' },
33
+ { id: 'manage_webhooks', label: 'Webhooks', description: 'Register webhooks that trigger agent workflows' },
34
+ { id: 'manage_connectors', label: 'Connectors', description: 'Create, edit, and delete connectors' },
35
+ { id: 'manage_sessions', label: 'Chats', description: 'List chats, send messages, and spawn agent work' },
36
+ { id: 'manage_secrets', label: 'Secrets', description: 'Store and retrieve encrypted service secrets' },
37
+ ]
38
+
39
+ export const ALL_TOOLS: ToolDefinition[] = [...AVAILABLE_TOOLS, ...PLATFORM_TOOLS]
40
+
41
+ /** Flat id→label lookup for display */
42
+ export const TOOL_LABELS: Record<string, string> = Object.fromEntries(
43
+ ALL_TOOLS.map((t) => [t.id, t.label]),
44
+ )
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Streaming TTS utilities: sentence accumulation and ordered audio playback.
3
+ */
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // SentenceAccumulator — buffers text deltas, emits on sentence boundaries
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export class SentenceAccumulator {
10
+ private buffer = ''
11
+ private onSentence: (sentence: string) => void
12
+
13
+ constructor(onSentence: (sentence: string) => void) {
14
+ this.onSentence = onSentence
15
+ }
16
+
17
+ push(delta: string) {
18
+ this.buffer += delta
19
+ // Emit on sentence-ending punctuation followed by space or newline
20
+ const sentenceEnd = /([.!?])\s+/g
21
+ let match: RegExpExecArray | null
22
+ let lastIndex = 0
23
+ while ((match = sentenceEnd.exec(this.buffer)) !== null) {
24
+ const sentence = this.buffer.slice(lastIndex, match.index + 1).trim()
25
+ if (sentence) this.onSentence(sentence)
26
+ lastIndex = match.index + match[0].length
27
+ }
28
+ // Also emit on double newlines
29
+ const doubleNewline = this.buffer.indexOf('\n\n', lastIndex)
30
+ if (doubleNewline !== -1) {
31
+ const sentence = this.buffer.slice(lastIndex, doubleNewline).trim()
32
+ if (sentence) this.onSentence(sentence)
33
+ lastIndex = doubleNewline + 2
34
+ }
35
+ // Flush if buffer exceeds 200 chars without a break
36
+ if (this.buffer.length - lastIndex > 200) {
37
+ const sentence = this.buffer.slice(lastIndex).trim()
38
+ if (sentence) this.onSentence(sentence)
39
+ lastIndex = this.buffer.length
40
+ }
41
+ this.buffer = this.buffer.slice(lastIndex)
42
+ }
43
+
44
+ flush() {
45
+ const remaining = this.buffer.trim()
46
+ if (remaining) this.onSentence(remaining)
47
+ this.buffer = ''
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // AudioChunkQueue — ordered sequential playback of audio chunks
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export class AudioChunkQueue {
56
+ private queue: Promise<ArrayBuffer>[] = []
57
+ private playing = false
58
+ private audioCtx: AudioContext | null = null
59
+ private currentSource: AudioBufferSourceNode | null = null
60
+ private stopped = false
61
+ onComplete?: () => void
62
+
63
+ enqueue(fetchPromise: Promise<ArrayBuffer>) {
64
+ this.queue.push(fetchPromise)
65
+ if (!this.playing) this.playNext()
66
+ }
67
+
68
+ private async playNext() {
69
+ if (this.stopped) return
70
+ if (this.queue.length === 0) {
71
+ this.playing = false
72
+ this.onComplete?.()
73
+ return
74
+ }
75
+
76
+ this.playing = true
77
+ const bufferPromise = this.queue.shift()!
78
+ try {
79
+ if (!this.audioCtx) this.audioCtx = new AudioContext()
80
+ if (this.audioCtx.state === 'suspended') await this.audioCtx.resume()
81
+
82
+ const arrayBuffer = await bufferPromise
83
+ if (this.stopped) return
84
+ const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer)
85
+ if (this.stopped) return
86
+
87
+ const source = this.audioCtx.createBufferSource()
88
+ source.buffer = audioBuffer
89
+ source.connect(this.audioCtx.destination)
90
+ this.currentSource = source
91
+
92
+ await new Promise<void>((resolve) => {
93
+ source.onended = () => {
94
+ this.currentSource = null
95
+ resolve()
96
+ }
97
+ source.start()
98
+ })
99
+ } catch {
100
+ // Skip failed chunks
101
+ }
102
+
103
+ if (!this.stopped) this.playNext()
104
+ }
105
+
106
+ stop() {
107
+ this.stopped = true
108
+ this.queue = []
109
+ if (this.currentSource) {
110
+ try { this.currentSource.stop() } catch { /* noop */ }
111
+ this.currentSource = null
112
+ }
113
+ this.playing = false
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Helper to fetch streaming TTS audio
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export function fetchStreamTts(text: string): Promise<ArrayBuffer> {
122
+ return fetch('/api/tts/stream', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ text }),
126
+ }).then((res) => {
127
+ if (!res.ok) throw new Error(`TTS error: ${res.status}`)
128
+ return res.arrayBuffer()
129
+ })
130
+ }
package/src/lib/upload.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import type { UploadResult } from '../types'
2
+ import { getStoredAccessKey } from './api-client'
2
3
 
3
4
  export async function uploadImage(file: File): Promise<UploadResult> {
5
+ const key = getStoredAccessKey()
4
6
  const res = await fetch('/api/upload', {
5
7
  method: 'POST',
6
- headers: { 'X-Filename': file.name },
8
+ headers: {
9
+ 'X-Filename': file.name,
10
+ ...(key ? { 'X-Access-Key': key } : {}),
11
+ },
7
12
  body: file,
8
13
  })
14
+ if (!res.ok) throw new Error(`Upload failed (${res.status})`)
9
15
  return res.json()
10
16
  }
@@ -0,0 +1,28 @@
1
+ import type { AppView } from '@/types'
2
+
3
+ export const DEFAULT_VIEW: AppView = 'agents'
4
+
5
+ export const VIEW_TO_PATH: Record<AppView, string> = {
6
+ agents: '/agents',
7
+ schedules: '/schedules',
8
+ memory: '/memory',
9
+ tasks: '/tasks',
10
+ secrets: '/secrets',
11
+ providers: '/providers',
12
+ skills: '/skills',
13
+ connectors: '/connectors',
14
+ webhooks: '/webhooks',
15
+ mcp_servers: '/mcp-servers',
16
+ knowledge: '/knowledge',
17
+ plugins: '/plugins',
18
+ usage: '/usage',
19
+ runs: '/runs',
20
+ logs: '/logs',
21
+ settings: '/settings',
22
+ projects: '/projects',
23
+ }
24
+
25
+ const entries = Object.entries(VIEW_TO_PATH) as [AppView, string][]
26
+ export const PATH_TO_VIEW: Record<string, AppView> = Object.fromEntries(
27
+ entries.map(([view, path]) => [path, view]),
28
+ ) as Record<string, AppView>
@@ -0,0 +1,124 @@
1
+ type WsCallback = () => void
2
+
3
+ let ws: WebSocket | null = null
4
+ let accessKey = ''
5
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
6
+ let reconnectDelay = 1000
7
+ const MAX_RECONNECT_DELAY = 30_000
8
+ const listeners = new Map<string, Set<WsCallback>>()
9
+ let connected = false
10
+
11
+ function getWsUrl(key: string): string {
12
+ const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
13
+ const port = process.env.NEXT_PUBLIC_WS_PORT || '3457'
14
+ const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws'
15
+ return `${protocol}://${host}:${port}/ws?key=${encodeURIComponent(key)}`
16
+ }
17
+
18
+ function handleMessage(event: MessageEvent) {
19
+ try {
20
+ const msg = JSON.parse(event.data)
21
+ const topic = msg.topic as string
22
+ if (!topic) return
23
+ const cbs = listeners.get(topic)
24
+ if (cbs) {
25
+ for (const cb of cbs) cb()
26
+ }
27
+ } catch {
28
+ // ignore malformed
29
+ }
30
+ }
31
+
32
+ function scheduleReconnect() {
33
+ if (reconnectTimer) return
34
+ reconnectTimer = setTimeout(() => {
35
+ reconnectTimer = null
36
+ if (!accessKey) return
37
+ connect(accessKey)
38
+ }, reconnectDelay)
39
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
40
+ }
41
+
42
+ function connect(key: string) {
43
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
44
+
45
+ try {
46
+ ws = new WebSocket(getWsUrl(key))
47
+ } catch {
48
+ scheduleReconnect()
49
+ return
50
+ }
51
+
52
+ ws.onopen = () => {
53
+ connected = true
54
+ reconnectDelay = 1000
55
+ // Subscribe to all currently registered topics
56
+ const topics = Array.from(listeners.keys())
57
+ if (topics.length > 0) {
58
+ ws?.send(JSON.stringify({ type: 'subscribe', topics }))
59
+ }
60
+ }
61
+
62
+ ws.onmessage = handleMessage
63
+
64
+ ws.onclose = () => {
65
+ connected = false
66
+ ws = null
67
+ if (accessKey) scheduleReconnect()
68
+ }
69
+
70
+ ws.onerror = () => {
71
+ // onclose will fire after this
72
+ }
73
+ }
74
+
75
+ export function connectWs(key: string) {
76
+ accessKey = key
77
+ reconnectDelay = 1000
78
+ connect(key)
79
+ }
80
+
81
+ export function disconnectWs() {
82
+ accessKey = ''
83
+ if (reconnectTimer) {
84
+ clearTimeout(reconnectTimer)
85
+ reconnectTimer = null
86
+ }
87
+ if (ws) {
88
+ ws.onclose = null
89
+ ws.close()
90
+ ws = null
91
+ }
92
+ connected = false
93
+ }
94
+
95
+ export function subscribeWs(topic: string, callback: WsCallback) {
96
+ let set = listeners.get(topic)
97
+ const isNew = !set
98
+ if (!set) {
99
+ set = new Set()
100
+ listeners.set(topic, set)
101
+ }
102
+ set.add(callback)
103
+
104
+ // Tell server about new topic subscription
105
+ if (isNew && ws?.readyState === WebSocket.OPEN) {
106
+ ws.send(JSON.stringify({ type: 'subscribe', topics: [topic] }))
107
+ }
108
+ }
109
+
110
+ export function unsubscribeWs(topic: string, callback: WsCallback) {
111
+ const set = listeners.get(topic)
112
+ if (!set) return
113
+ set.delete(callback)
114
+ if (set.size === 0) {
115
+ listeners.delete(topic)
116
+ if (ws?.readyState === WebSocket.OPEN) {
117
+ ws.send(JSON.stringify({ type: 'unsubscribe', topics: [topic] }))
118
+ }
119
+ }
120
+ }
121
+
122
+ export function isWsConnected(): boolean {
123
+ return connected
124
+ }
package/src/proxy.ts CHANGED
@@ -9,6 +9,8 @@ export function proxy(request: NextRequest) {
9
9
  const { pathname } = request.nextUrl
10
10
  const isWebhookTrigger = request.method === 'POST'
11
11
  && /^\/api\/webhooks\/[^/]+\/?$/.test(pathname)
12
+ const isConnectorWebhook = request.method === 'POST'
13
+ && /^\/api\/connectors\/[^/]+\/webhook\/?$/.test(pathname)
12
14
 
13
15
  // Only protect API routes (not auth, uploads served as static assets, or inbound webhooks)
14
16
  if (
@@ -16,6 +18,7 @@ export function proxy(request: NextRequest) {
16
18
  || pathname === '/api/auth'
17
19
  || pathname.startsWith('/api/uploads/')
18
20
  || isWebhookTrigger
21
+ || isConnectorWebhook
19
22
  ) {
20
23
  return NextResponse.next()
21
24
  }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { create } from 'zustand'
4
- import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta } from '../types'
4
+ import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project } from '../types'
5
5
  import { fetchSessions, fetchDirs, fetchProviders, fetchCredentials } from '../lib/sessions'
6
6
  import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
@@ -151,6 +151,16 @@ interface AppState {
151
151
  editingPluginFilename: string | null
152
152
  setEditingPluginFilename: (filename: string | null) => void
153
153
 
154
+ // Projects
155
+ projects: Record<string, Project>
156
+ loadProjects: () => Promise<void>
157
+ projectSheetOpen: boolean
158
+ setProjectSheetOpen: (open: boolean) => void
159
+ editingProjectId: string | null
160
+ setEditingProjectId: (id: string | null) => void
161
+ activeProjectFilter: string | null
162
+ setActiveProjectFilter: (id: string | null) => void
163
+
154
164
  }
155
165
 
156
166
  export const useAppStore = create<AppState>((set, get) => ({
@@ -465,4 +475,21 @@ export const useAppStore = create<AppState>((set, get) => ({
465
475
  editingPluginFilename: null,
466
476
  setEditingPluginFilename: (filename) => set({ editingPluginFilename: filename }),
467
477
 
478
+ // Projects
479
+ projects: {},
480
+ loadProjects: async () => {
481
+ try {
482
+ const projects = await api<Record<string, Project>>('GET', '/projects')
483
+ set({ projects })
484
+ } catch {
485
+ // ignore
486
+ }
487
+ },
488
+ projectSheetOpen: false,
489
+ setProjectSheetOpen: (open) => set({ projectSheetOpen: open }),
490
+ editingProjectId: null,
491
+ setEditingProjectId: (id) => set({ editingProjectId: id }),
492
+ activeProjectFilter: null,
493
+ setActiveProjectFilter: (id) => set({ activeProjectFilter: id }),
494
+
468
495
  }))
@@ -7,7 +7,7 @@ import { speak } from '../lib/tts'
7
7
  import { getStoredAccessKey } from '../lib/api-client'
8
8
  import { useAppStore } from './use-app-store'
9
9
 
10
- interface PendingImage {
10
+ export interface PendingFile {
11
11
  file: File
12
12
  path: string
13
13
  url: string
@@ -44,8 +44,15 @@ interface ChatState {
44
44
  ttsEnabled: boolean
45
45
  toggleTts: () => void
46
46
 
47
- pendingImage: PendingImage | null
48
- setPendingImage: (img: PendingImage | null) => void
47
+ // Multi-file attachment support
48
+ pendingFiles: PendingFile[]
49
+ addPendingFile: (f: PendingFile) => void
50
+ removePendingFile: (index: number) => void
51
+ clearPendingFiles: () => void
52
+
53
+ // Legacy single-image compat (reads first pendingFile)
54
+ pendingImage: PendingFile | null
55
+ setPendingImage: (img: PendingFile | null) => void
49
56
 
50
57
  devServer: DevServerStatus | null
51
58
  setDevServer: (ds: DevServerStatus | null) => void
@@ -57,6 +64,10 @@ interface ChatState {
57
64
  retryLastMessage: () => Promise<void>
58
65
  sendHeartbeat: (sessionId: string) => Promise<void>
59
66
  stopStreaming: () => void
67
+
68
+ // Voice conversation
69
+ voiceConversationActive: boolean
70
+ onStreamEvent: ((event: { t: string; text?: string }) => void) | null
60
71
  }
61
72
 
62
73
  export const useChatStore = create<ChatState>((set, get) => ({
@@ -70,21 +81,36 @@ export const useChatStore = create<ChatState>((set, get) => ({
70
81
  lastUsage: null,
71
82
  ttsEnabled: false,
72
83
  toggleTts: () => set((s) => ({ ttsEnabled: !s.ttsEnabled })),
73
- pendingImage: null,
74
- setPendingImage: (img) => set({ pendingImage: img }),
84
+ voiceConversationActive: false,
85
+ onStreamEvent: null,
86
+
87
+ pendingFiles: [],
88
+ addPendingFile: (f) => set((s) => ({ pendingFiles: [...s.pendingFiles, f] })),
89
+ removePendingFile: (index) => set((s) => ({ pendingFiles: s.pendingFiles.filter((_, i) => i !== index) })),
90
+ clearPendingFiles: () => set({ pendingFiles: [] }),
91
+
92
+ // Legacy compat: pendingImage reads/writes the first pending file
93
+ get pendingImage() { const files = get().pendingFiles; return files.length ? files[0] : null },
94
+ setPendingImage: (img) => set({ pendingFiles: img ? [img] : [] }),
95
+
75
96
  devServer: null,
76
97
  setDevServer: (ds) => set({ devServer: ds }),
77
98
  debugOpen: false,
78
99
  setDebugOpen: (open) => set({ debugOpen: open }),
79
100
 
80
101
  sendMessage: async (text: string) => {
81
- if (!text.trim() || get().streaming) return
102
+ const { pendingFiles } = get()
103
+ if ((!text.trim() && !pendingFiles.length) || get().streaming) return
82
104
  const sessionId = useAppStore.getState().currentSessionId
83
105
  if (!sessionId) return
84
106
 
85
- const { pendingImage } = get()
86
- const imagePath = pendingImage?.path
87
- const imageUrl = pendingImage?.url
107
+ // Primary image (backward compat)
108
+ const imagePath = pendingFiles[0]?.path
109
+ const imageUrl = pendingFiles[0]?.url
110
+ // All attached file paths
111
+ const attachedFiles = pendingFiles.length > 1
112
+ ? pendingFiles.map((f) => f.path)
113
+ : undefined
88
114
 
89
115
  const userMsg: Message = {
90
116
  role: 'user',
@@ -92,13 +118,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
92
118
  time: Date.now(),
93
119
  imagePath,
94
120
  imageUrl,
121
+ attachedFiles,
95
122
  }
96
123
  set((s) => ({
97
124
  streaming: true,
98
125
  streamingSessionId: sessionId,
99
126
  streamText: '',
100
127
  messages: [...s.messages, userMsg],
101
- pendingImage: null,
128
+ pendingFiles: [],
102
129
  toolEvents: [],
103
130
  lastUsage: null,
104
131
  }))
@@ -114,6 +141,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
114
141
  /cancelled by steer mode|stopped by user/i.test(msg || '')
115
142
 
116
143
  await streamChat(sessionId, text, imagePath, imageUrl, (event: SSEEvent) => {
144
+ // Forward events to voice conversation handler if active
145
+ get().onStreamEvent?.(event)
117
146
  if (event.t === 'd') {
118
147
  fullText += event.text || ''
119
148
  set({ streamText: fullText })
@@ -166,7 +195,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
166
195
  } else if (event.t === 'done') {
167
196
  // done
168
197
  }
169
- })
198
+ }, attachedFiles)
170
199
 
171
200
  if (fullText.trim()) {
172
201
  const currentToolEvents = get().toolEvents
@@ -188,7 +217,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
188
217
  streamingSessionId: null,
189
218
  streamText: '',
190
219
  }))
191
- if (get().ttsEnabled) speak(fullText)
220
+ if (get().ttsEnabled && !get().voiceConversationActive) speak(fullText)
192
221
  } else {
193
222
  set({ streaming: false, streamingSessionId: null, streamText: '' })
194
223
  }
@@ -218,9 +247,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
218
247
  set({ messages: msgs })
219
248
  }
220
249
  // Re-send the last user message through the normal SSE flow
221
- // Temporarily set pendingImage if there was one
222
250
  if (imagePath) {
223
- set({ pendingImage: { file: new File([], ''), path: imagePath, url: '' } })
251
+ set({ pendingFiles: [{ file: new File([], ''), path: imagePath, url: '' }] })
224
252
  }
225
253
  await get().sendMessage(message)
226
254
  } catch {