@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,144 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+
5
+ export type ContinuousSpeechState = 'idle' | 'listening' | 'cooldown' | 'waitingForResponse'
6
+
7
+ interface UseContinuousSpeechOptions {
8
+ lang?: string
9
+ silenceDelayMs?: number
10
+ onUtterance: (transcript: string) => void
11
+ }
12
+
13
+ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
14
+ const { lang, silenceDelayMs = 800, onUtterance } = options
15
+ const [state, setState] = useState<ContinuousSpeechState>('idle')
16
+ const [transcript, setTranscript] = useState('')
17
+ const [interimText, setInterimText] = useState('')
18
+
19
+ const recogRef = useRef<any>(null)
20
+ const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
21
+ const activeRef = useRef(false)
22
+ const accumulatedRef = useRef('')
23
+
24
+ const clearSilenceTimer = () => {
25
+ if (silenceTimerRef.current) {
26
+ clearTimeout(silenceTimerRef.current)
27
+ silenceTimerRef.current = null
28
+ }
29
+ }
30
+
31
+ const startRecognition = useCallback(() => {
32
+ const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
33
+ if (!SR) return
34
+
35
+ if (recogRef.current) {
36
+ try { recogRef.current.stop() } catch { /* noop */ }
37
+ }
38
+
39
+ const recog = new SR()
40
+ recog.continuous = true
41
+ recog.interimResults = true
42
+ recog.maxAlternatives = 1
43
+ recog.lang = lang || navigator.language || 'en-US'
44
+
45
+ recog.onresult = (e: any) => {
46
+ clearSilenceTimer()
47
+ let interim = ''
48
+ let final = ''
49
+
50
+ for (let i = e.resultIndex; i < e.results.length; i++) {
51
+ const result = e.results[i]
52
+ if (result.isFinal) {
53
+ final += result[0].transcript
54
+ } else {
55
+ interim += result[0].transcript
56
+ }
57
+ }
58
+
59
+ if (final) {
60
+ accumulatedRef.current += (accumulatedRef.current ? ' ' : '') + final.trim()
61
+ setTranscript(accumulatedRef.current)
62
+ setInterimText('')
63
+
64
+ // Start silence timer — after delay, send the utterance
65
+ silenceTimerRef.current = setTimeout(() => {
66
+ if (!activeRef.current) return
67
+ const text = accumulatedRef.current.trim()
68
+ if (text) {
69
+ setState('waitingForResponse')
70
+ onUtterance(text)
71
+ accumulatedRef.current = ''
72
+ setTranscript('')
73
+ }
74
+ }, silenceDelayMs)
75
+ } else {
76
+ setInterimText(interim)
77
+ }
78
+ }
79
+
80
+ recog.onerror = (e: any) => {
81
+ // 'no-speech' is normal during silence; 'aborted' when stopping intentionally
82
+ if (e.error === 'no-speech' || e.error === 'aborted') return
83
+ console.warn('[continuous-speech] error:', e.error)
84
+ }
85
+
86
+ recog.onend = () => {
87
+ // Auto-restart if still active (browser may stop recognition periodically)
88
+ if (activeRef.current && state !== 'waitingForResponse') {
89
+ try { recog.start() } catch { /* noop */ }
90
+ }
91
+ }
92
+
93
+ recogRef.current = recog
94
+ try {
95
+ recog.start()
96
+ setState('listening')
97
+ } catch {
98
+ setState('idle')
99
+ }
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [lang, silenceDelayMs, onUtterance])
102
+
103
+ const start = useCallback(() => {
104
+ activeRef.current = true
105
+ accumulatedRef.current = ''
106
+ setTranscript('')
107
+ setInterimText('')
108
+ startRecognition()
109
+ }, [startRecognition])
110
+
111
+ const stop = useCallback(() => {
112
+ activeRef.current = false
113
+ clearSilenceTimer()
114
+ if (recogRef.current) {
115
+ try { recogRef.current.stop() } catch { /* noop */ }
116
+ recogRef.current = null
117
+ }
118
+ setState('idle')
119
+ setTranscript('')
120
+ setInterimText('')
121
+ accumulatedRef.current = ''
122
+ }, [])
123
+
124
+ const pause = useCallback(() => {
125
+ clearSilenceTimer()
126
+ if (recogRef.current) {
127
+ try { recogRef.current.stop() } catch { /* noop */ }
128
+ }
129
+ }, [])
130
+
131
+ const resume = useCallback(() => {
132
+ if (!activeRef.current) return
133
+ accumulatedRef.current = ''
134
+ setTranscript('')
135
+ setInterimText('')
136
+ setState('listening')
137
+ startRecognition()
138
+ }, [startRecognition])
139
+
140
+ const supported = typeof window !== 'undefined' &&
141
+ !!((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition)
142
+
143
+ return { state, transcript, interimText, start, stop, pause, resume, supported }
144
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { VIEW_TO_PATH, PATH_TO_VIEW, DEFAULT_VIEW } from '@/lib/view-routes'
6
+
7
+ export function useViewRouter() {
8
+ const fromPopstate = useRef(false)
9
+
10
+ // Mount: read pathname → set active view
11
+ useEffect(() => {
12
+ const view = PATH_TO_VIEW[window.location.pathname]
13
+ if (view) {
14
+ useAppStore.getState().setActiveView(view)
15
+ } else {
16
+ useAppStore.getState().setActiveView(DEFAULT_VIEW)
17
+ window.history.replaceState(null, '', VIEW_TO_PATH[DEFAULT_VIEW])
18
+ }
19
+ }, [])
20
+
21
+ // State→URL: push new path when activeView changes
22
+ 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
28
+ if (fromPopstate.current) {
29
+ fromPopstate.current = false
30
+ return
31
+ }
32
+ const targetPath = VIEW_TO_PATH[next]
33
+ if (targetPath && window.location.pathname !== targetPath) {
34
+ window.history.pushState(null, '', targetPath)
35
+ }
36
+ })
37
+ return unsub
38
+ }, [])
39
+
40
+ // Popstate: browser back/forward → update view
41
+ useEffect(() => {
42
+ const onPopstate = () => {
43
+ const view = PATH_TO_VIEW[window.location.pathname]
44
+ if (view) {
45
+ fromPopstate.current = true
46
+ useAppStore.getState().setActiveView(view)
47
+ }
48
+ }
49
+ window.addEventListener('popstate', onPopstate)
50
+ return () => window.removeEventListener('popstate', onPopstate)
51
+ }, [])
52
+ }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+ import { useContinuousSpeech } from './use-continuous-speech'
5
+ import { SentenceAccumulator, AudioChunkQueue, fetchStreamTts } from '@/lib/tts-stream'
6
+ import { useChatStore } from '@/stores/use-chat-store'
7
+
8
+ export type VoiceConversationState = 'idle' | 'listening' | 'processing' | 'speaking'
9
+
10
+ export function useVoiceConversation() {
11
+ const [active, setActive] = useState(false)
12
+ const [voiceState, setVoiceState] = useState<VoiceConversationState>('idle')
13
+ const accumulatorRef = useRef<SentenceAccumulator | null>(null)
14
+ const queueRef = useRef<AudioChunkQueue | null>(null)
15
+ const sendMessage = useChatStore((s) => s.sendMessage)
16
+
17
+ const speech = useContinuousSpeech({
18
+ onUtterance: useCallback((text: string) => {
19
+ setVoiceState('processing')
20
+ // Send the transcribed text as a chat message
21
+ sendMessage(text)
22
+ }, [sendMessage]),
23
+ })
24
+
25
+ // Called by the chat store's onStreamEvent callback
26
+ const handleStreamEvent = useCallback((event: { t: string; text?: string }) => {
27
+ if (!active) return
28
+
29
+ if (event.t === 'd' && event.text) {
30
+ setVoiceState('speaking')
31
+ if (!accumulatorRef.current) {
32
+ const queue = new AudioChunkQueue()
33
+ queueRef.current = queue
34
+ queue.onComplete = () => {
35
+ // Resume listening after TTS playback finishes
36
+ setVoiceState('listening')
37
+ speech.resume()
38
+ }
39
+ accumulatorRef.current = new SentenceAccumulator((sentence) => {
40
+ queue.enqueue(fetchStreamTts(sentence))
41
+ })
42
+ }
43
+ accumulatorRef.current.push(event.text)
44
+ } else if (event.t === 'done') {
45
+ // Flush remaining text to TTS
46
+ if (accumulatorRef.current) {
47
+ accumulatorRef.current.flush()
48
+ accumulatorRef.current = null
49
+ }
50
+ }
51
+ }, [active, speech])
52
+
53
+ const start = useCallback(() => {
54
+ setActive(true)
55
+ setVoiceState('listening')
56
+ // Register the stream event handler on the chat store
57
+ useChatStore.setState({ onStreamEvent: handleStreamEvent, voiceConversationActive: true })
58
+ speech.start()
59
+ }, [speech, handleStreamEvent])
60
+
61
+ const stop = useCallback(() => {
62
+ setActive(false)
63
+ setVoiceState('idle')
64
+ speech.stop()
65
+ queueRef.current?.stop()
66
+ queueRef.current = null
67
+ accumulatorRef.current = null
68
+ useChatStore.setState({ onStreamEvent: null, voiceConversationActive: false })
69
+ }, [speech])
70
+
71
+ return {
72
+ active,
73
+ state: voiceState,
74
+ interimText: speech.interimText,
75
+ transcript: speech.transcript,
76
+ supported: speech.supported,
77
+ start,
78
+ stop,
79
+ }
80
+ }
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { subscribeWs, unsubscribeWs, isWsConnected } from '@/lib/ws-client'
5
+
6
+ /**
7
+ * Subscribe to a WebSocket topic. Calls `handler` on push events.
8
+ * Falls back to polling at `fallbackMs` when WS is disconnected.
9
+ */
10
+ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
11
+ const handlerRef = useRef(handler)
12
+ handlerRef.current = handler
13
+ const fallbackMsRef = useRef(fallbackMs)
14
+ fallbackMsRef.current = fallbackMs
15
+
16
+ // WS subscription — only re-runs when topic changes
17
+ useEffect(() => {
18
+ if (!topic) return
19
+
20
+ const cb = () => handlerRef.current()
21
+ subscribeWs(topic, cb)
22
+ return () => { unsubscribeWs(topic, cb) }
23
+ }, [topic])
24
+
25
+ // Fallback polling — separate effect so it doesn't tear down WS subscription
26
+ useEffect(() => {
27
+ if (!topic) return
28
+
29
+ let fallbackId: ReturnType<typeof setInterval> | null = null
30
+ const cb = () => handlerRef.current()
31
+
32
+ const startFallback = () => {
33
+ const ms = fallbackMsRef.current
34
+ if (fallbackId || !ms || ms <= 0) return
35
+ fallbackId = setInterval(cb, ms)
36
+ }
37
+ const stopFallback = () => {
38
+ if (fallbackId) {
39
+ clearInterval(fallbackId)
40
+ fallbackId = null
41
+ }
42
+ }
43
+
44
+ // Check WS connection state periodically to toggle fallback
45
+ const checkId = setInterval(() => {
46
+ const ms = fallbackMsRef.current
47
+ if (!ms || ms <= 0) {
48
+ stopFallback()
49
+ } else if (isWsConnected()) {
50
+ stopFallback()
51
+ } else {
52
+ startFallback()
53
+ }
54
+ }, 2000)
55
+
56
+ // Start fallback immediately if not connected and fallback is enabled
57
+ if (!isWsConnected() && fallbackMsRef.current && fallbackMsRef.current > 0) {
58
+ startFallback()
59
+ }
60
+
61
+ return () => {
62
+ stopFallback()
63
+ clearInterval(checkId)
64
+ }
65
+ }, [topic])
66
+ }
@@ -2,7 +2,9 @@ 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
6
  startScheduler()
6
7
  resumeQueue()
8
+ initWsServer()
7
9
  }
8
10
  }
package/src/lib/chat.ts CHANGED
@@ -12,8 +12,19 @@ export async function streamChat(
12
12
  imagePath?: string,
13
13
  imageUrl?: string,
14
14
  onEvent?: (event: SSEEvent) => void,
15
+ optionsOrFiles?: StreamChatOptions | string[],
15
16
  options?: StreamChatOptions,
16
17
  ): Promise<void> {
18
+ // Support both (options) and (attachedFiles, options) as 6th arg
19
+ let attachedFiles: string[] | undefined
20
+ let opts: StreamChatOptions | undefined
21
+ if (Array.isArray(optionsOrFiles)) {
22
+ attachedFiles = optionsOrFiles
23
+ opts = options
24
+ } else {
25
+ opts = optionsOrFiles
26
+ }
27
+
17
28
  const key = getStoredAccessKey()
18
29
  const res = await fetch(`/api/sessions/${sessionId}/chat`, {
19
30
  method: 'POST',
@@ -25,8 +36,9 @@ export async function streamChat(
25
36
  message,
26
37
  imagePath,
27
38
  imageUrl,
28
- internal: !!options?.internal,
29
- queueMode: options?.queueMode,
39
+ attachedFiles,
40
+ internal: !!opts?.internal,
41
+ queueMode: opts?.queueMode,
30
42
  }),
31
43
  })
32
44
 
package/src/lib/id.ts ADDED
@@ -0,0 +1,6 @@
1
+ import crypto from 'crypto'
2
+
3
+ /** Generate a random hex ID. Default 4 bytes = 8 hex chars. */
4
+ export function genId(bytes = 4): string {
5
+ return crypto.randomBytes(bytes).toString('hex')
6
+ }
@@ -0,0 +1,13 @@
1
+ import { api } from './api-client'
2
+ import type { Project } from '../types'
3
+
4
+ export const fetchProjects = () => api<Record<string, Project>>('GET', '/projects')
5
+
6
+ export const createProject = (data: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) =>
7
+ api<Project>('POST', '/projects', data)
8
+
9
+ export const updateProject = (id: string, data: Partial<Project>) =>
10
+ api<Project>('PUT', `/projects/${id}`, data)
11
+
12
+ export const deleteProject = (id: string) =>
13
+ api<string>('DELETE', `/projects/${id}`)
@@ -0,0 +1,5 @@
1
+ /** CLI providers that use their own tool execution — incompatible with LangGraph orchestration. */
2
+ export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
3
+
4
+ /** Providers with native tool/capability support (CLI providers + OpenClaw). */
5
+ export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'openclaw'])
@@ -23,10 +23,12 @@ 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 }: StreamChatOptions): Promise<string> {
26
+ export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
27
27
  return new Promise((resolve) => {
28
28
  const messages = buildMessages(session, message, imagePath, loadHistory)
29
29
  const model = session.model || 'claude-sonnet-4-6'
30
+ let usageInput = 0
31
+ let usageOutput = 0
30
32
 
31
33
  const body: Record<string, unknown> = {
32
34
  model,
@@ -86,11 +88,22 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
86
88
  fullResponse += parsed.delta.text
87
89
  write(`data: ${JSON.stringify({ t: 'd', text: parsed.delta.text })}\n\n`)
88
90
  }
91
+ // message_start carries input token count
92
+ if (parsed.type === 'message_start' && parsed.message?.usage) {
93
+ usageInput = parsed.message.usage.input_tokens || 0
94
+ }
95
+ // message_delta carries output token count
96
+ if (parsed.type === 'message_delta' && parsed.usage) {
97
+ usageOutput = parsed.usage.output_tokens || 0
98
+ }
89
99
  } catch {}
90
100
  }
91
101
  })
92
102
 
93
103
  apiRes.on('end', () => {
104
+ if (onUsage && (usageInput > 0 || usageOutput > 0)) {
105
+ onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
106
+ }
94
107
  active.delete(session.id)
95
108
  resolve(fullResponse)
96
109
  })
@@ -113,7 +126,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
113
126
  const msgs: Array<{ role: string; content: any }> = []
114
127
 
115
128
  if (loadHistory) {
116
- const history = loadHistory(session.id)
129
+ const history = loadHistory(session.id).slice(-40)
117
130
  for (const m of history) {
118
131
  if (m.role === 'user' && m.imagePath) {
119
132
  const blocks = fileToContentBlocks(m.imagePath)
@@ -13,6 +13,11 @@ export interface ProviderHandler {
13
13
  streamChat: (opts: StreamChatOptions) => Promise<string>
14
14
  }
15
15
 
16
+ export interface StreamChatUsage {
17
+ inputTokens: number
18
+ outputTokens: number
19
+ }
20
+
16
21
  export interface StreamChatOptions {
17
22
  session: any
18
23
  message: string
@@ -22,6 +27,7 @@ export interface StreamChatOptions {
22
27
  write: (data: string) => void
23
28
  active: Map<string, any>
24
29
  loadHistory: (sessionId: string) => any[]
30
+ onUsage?: (usage: StreamChatUsage) => void
25
31
  }
26
32
 
27
33
  interface BuiltinProviderConfig extends ProviderInfo {
@@ -246,6 +252,7 @@ export function getProviderList(): ProviderInfo[] {
246
252
  .map(({ handler, ...info }) => ({
247
253
  ...info,
248
254
  models: overrides[info.id] || info.models,
255
+ defaultModels: info.models,
249
256
  }))
250
257
  const customs = Object.values(getCustomProviders())
251
258
  .filter((c) => c.isEnabled)
@@ -253,6 +260,7 @@ export function getProviderList(): ProviderInfo[] {
253
260
  id: c.id as any,
254
261
  name: c.name,
255
262
  models: c.models,
263
+ defaultModels: c.models,
256
264
  requiresApiKey: c.requiresApiKey,
257
265
  requiresEndpoint: false,
258
266
  defaultEndpoint: c.baseUrl,
@@ -6,7 +6,7 @@ import type { StreamChatOptions } from './index'
6
6
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
7
7
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
8
8
 
9
- export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory }: StreamChatOptions): Promise<string> {
9
+ export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
10
10
  return new Promise((resolve) => {
11
11
  const messages = buildMessages(session, message, imagePath, loadHistory)
12
12
  const model = session.model || 'llama3'
@@ -69,6 +69,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
69
69
  fullResponse += content
70
70
  write(`data: ${JSON.stringify({ t: 'd', text: content })}\n\n`)
71
71
  }
72
+ // Final chunk (done: true) carries token counts
73
+ if (parsed.done && onUsage) {
74
+ const input = parsed.prompt_eval_count || 0
75
+ const output = parsed.eval_count || 0
76
+ if (input > 0 || output > 0) {
77
+ onUsage({ inputTokens: input, outputTokens: output })
78
+ }
79
+ }
72
80
  } catch {}
73
81
  }
74
82
  })
@@ -116,7 +124,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
116
124
  const msgs: Array<{ role: string; content: string; images?: string[] }> = []
117
125
 
118
126
  if (loadHistory) {
119
- const history = loadHistory(session.id)
127
+ const history = loadHistory(session.id).slice(-40)
120
128
  for (const m of history) {
121
129
  if (m.role === 'user' && m.imagePath) {
122
130
  msgs.push({ role: 'user', ...fileToOllamaMsg(m.text, m.imagePath) })
@@ -4,33 +4,55 @@ import type { StreamChatOptions } from './index'
4
4
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
5
5
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
6
6
 
7
- function fileToContentParts(filePath: string): any[] {
7
+ async function fileToContentParts(filePath: string): Promise<any[]> {
8
8
  if (!filePath || !fs.existsSync(filePath)) return []
9
+ const name = filePath.split('/').pop() || 'file'
9
10
  if (IMAGE_EXTS.test(filePath)) {
10
- const data = fs.readFileSync(filePath).toString('base64')
11
+ const buf = fs.readFileSync(filePath)
12
+ if (buf.length === 0) return [{ type: 'text', text: `[Attached image: ${name} — file is empty]` }]
13
+ const data = buf.toString('base64')
11
14
  const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
12
- const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
13
- return [{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}` } }]
15
+ let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
16
+ if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
17
+ else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
18
+ else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
19
+ else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
20
+ return [{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }]
14
21
  }
15
- if (TEXT_EXTS.test(filePath) || filePath.endsWith('.pdf')) {
22
+ if (filePath.endsWith('.pdf')) {
23
+ try {
24
+ // @ts-ignore — pdf-parse types
25
+ const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
26
+ const buf = fs.readFileSync(filePath)
27
+ const result = await pdfParse(buf)
28
+ const pdfText = (result.text || '').trim()
29
+ if (!pdfText) return [{ type: 'text', text: `[Attached PDF: ${name} — no extractable text]` }]
30
+ const maxChars = 100_000
31
+ const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
32
+ return [{ type: 'text', text: `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}` }]
33
+ } catch {
34
+ return [{ type: 'text', text: `[Attached PDF: ${name} — could not extract text]` }]
35
+ }
36
+ }
37
+ if (TEXT_EXTS.test(filePath)) {
16
38
  try {
17
39
  const text = fs.readFileSync(filePath, 'utf-8')
18
- const name = filePath.split('/').pop() || 'file'
19
40
  return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
20
41
  } catch { return [] }
21
42
  }
22
- return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
43
+ return [{ type: 'text', text: `[Attached file: ${name}]` }]
23
44
  }
24
45
 
25
- export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
46
+ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
26
47
  return new Promise(async (resolve) => {
27
- const messages = buildMessages(session, message, imagePath, systemPrompt, loadHistory)
48
+ const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
28
49
  const model = session.model || 'gpt-4o'
29
50
 
30
51
  const payload = JSON.stringify({
31
52
  model,
32
53
  messages,
33
54
  stream: true,
55
+ stream_options: { include_usage: true },
34
56
  })
35
57
 
36
58
  let fullResponse = ''
@@ -115,6 +137,13 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
115
137
  fullResponse += delta
116
138
  write(`data: ${JSON.stringify({ t: 'd', text: delta })}\n\n`)
117
139
  }
140
+ // Extract usage from the final chunk (stream_options: include_usage)
141
+ if (parsed.usage && onUsage) {
142
+ onUsage({
143
+ inputTokens: parsed.usage.prompt_tokens || 0,
144
+ outputTokens: parsed.usage.completion_tokens || 0,
145
+ })
146
+ }
118
147
  } catch {}
119
148
  }
120
149
  }
@@ -134,7 +163,7 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
134
163
  })
135
164
  }
136
165
 
137
- function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
166
+ async function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
138
167
  const msgs: Array<{ role: string; content: any }> = []
139
168
 
140
169
  if (systemPrompt) {
@@ -142,10 +171,10 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
142
171
  }
143
172
 
144
173
  if (loadHistory) {
145
- const history = loadHistory(session.id)
174
+ const history = loadHistory(session.id).slice(-40)
146
175
  for (const m of history) {
147
176
  if (m.role === 'user' && m.imagePath) {
148
- const parts = fileToContentParts(m.imagePath)
177
+ const parts = await fileToContentParts(m.imagePath)
149
178
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: m.text }] })
150
179
  } else {
151
180
  msgs.push({ role: m.role, content: m.text })
@@ -155,7 +184,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
155
184
 
156
185
  // Current message with optional attachment
157
186
  if (imagePath) {
158
- const parts = fileToContentParts(imagePath)
187
+ const parts = await fileToContentParts(imagePath)
159
188
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: message }] })
160
189
  } else {
161
190
  msgs.push({ role: 'user', content: message })
@@ -63,6 +63,17 @@ function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
63
63
  }
64
64
 
65
65
  function loadOrCreateDeviceIdentity(): DeviceIdentity {
66
+ // 0. Check shared device token for cross-synced identity
67
+ try {
68
+ const { getSharedDeviceToken } = require('../server/openclaw-sync')
69
+ const sharedToken = getSharedDeviceToken()
70
+ if (sharedToken) {
71
+ // Shared token exists — the connector has already paired.
72
+ // Still need the keypair, so continue to identity resolution below.
73
+ // The token will be used during WS connect.
74
+ }
75
+ } catch { /* openclaw-sync not available */ }
76
+
66
77
  // 1. Prefer the openclaw CLI's identity — it's likely already paired with the gateway
67
78
  const cliIdentityPath = path.join(resolveCliStateDir(), 'identity', 'device.json')
68
79
  const cliIdentity = tryLoadIdentityFile(cliIdentityPath)