@swarmclawai/swarmclaw 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+
5
+ export type ContinuousSpeechState = 'idle' | 'listening' | 'cooldown' | 'waitingForResponse'
6
+
7
+ interface SpeechRecognitionResult {
8
+ isFinal: boolean
9
+ [index: number]: { transcript: string }
10
+ }
11
+
12
+ interface SpeechRecognitionResultList {
13
+ readonly length: number
14
+ [index: number]: SpeechRecognitionResult
15
+ }
16
+
17
+ interface SpeechRecognitionEvent {
18
+ resultIndex: number
19
+ results: SpeechRecognitionResultList
20
+ }
21
+
22
+ interface SpeechRecognitionErrorEvent {
23
+ error: string
24
+ }
25
+
26
+ interface SpeechRecognitionInstance {
27
+ continuous: boolean
28
+ interimResults: boolean
29
+ maxAlternatives: number
30
+ lang: string
31
+ onresult: ((e: SpeechRecognitionEvent) => void) | null
32
+ onerror: ((e: SpeechRecognitionErrorEvent) => void) | null
33
+ onend: (() => void) | null
34
+ start(): void
35
+ stop(): void
36
+ }
37
+
38
+ interface WindowWithSpeechRecognition {
39
+ SpeechRecognition?: new () => SpeechRecognitionInstance
40
+ webkitSpeechRecognition?: new () => SpeechRecognitionInstance
41
+ }
42
+
43
+ interface UseContinuousSpeechOptions {
44
+ lang?: string
45
+ silenceDelayMs?: number
46
+ onUtterance: (transcript: string) => void
47
+ }
48
+
49
+ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
50
+ const { lang, silenceDelayMs = 800, onUtterance } = options
51
+ const [state, setState] = useState<ContinuousSpeechState>('idle')
52
+ const [transcript, setTranscript] = useState('')
53
+ const [interimText, setInterimText] = useState('')
54
+
55
+ const recogRef = useRef<SpeechRecognitionInstance | null>(null)
56
+ const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
57
+ const activeRef = useRef(false)
58
+ const accumulatedRef = useRef('')
59
+
60
+ const clearSilenceTimer = () => {
61
+ if (silenceTimerRef.current) {
62
+ clearTimeout(silenceTimerRef.current)
63
+ silenceTimerRef.current = null
64
+ }
65
+ }
66
+
67
+ const startRecognition = useCallback(() => {
68
+ const w = window as unknown as WindowWithSpeechRecognition
69
+ const SR = w.SpeechRecognition || w.webkitSpeechRecognition
70
+ if (!SR) return
71
+
72
+ if (recogRef.current) {
73
+ try { recogRef.current.stop() } catch { /* noop */ }
74
+ }
75
+
76
+ const recog = new SR()
77
+ recog.continuous = true
78
+ recog.interimResults = true
79
+ recog.maxAlternatives = 1
80
+ recog.lang = lang || navigator.language || 'en-US'
81
+
82
+ recog.onresult = (e: SpeechRecognitionEvent) => {
83
+ clearSilenceTimer()
84
+ let interim = ''
85
+ let final = ''
86
+
87
+ for (let i = e.resultIndex; i < e.results.length; i++) {
88
+ const result = e.results[i]
89
+ if (result.isFinal) {
90
+ final += result[0].transcript
91
+ } else {
92
+ interim += result[0].transcript
93
+ }
94
+ }
95
+
96
+ if (final) {
97
+ accumulatedRef.current += (accumulatedRef.current ? ' ' : '') + final.trim()
98
+ setTranscript(accumulatedRef.current)
99
+ setInterimText('')
100
+
101
+ // Start silence timer — after delay, send the utterance
102
+ silenceTimerRef.current = setTimeout(() => {
103
+ if (!activeRef.current) return
104
+ const text = accumulatedRef.current.trim()
105
+ if (text) {
106
+ setState('waitingForResponse')
107
+ onUtterance(text)
108
+ accumulatedRef.current = ''
109
+ setTranscript('')
110
+ }
111
+ }, silenceDelayMs)
112
+ } else {
113
+ setInterimText(interim)
114
+ }
115
+ }
116
+
117
+ recog.onerror = (e: SpeechRecognitionErrorEvent) => {
118
+ // 'no-speech' is normal during silence; 'aborted' when stopping intentionally
119
+ if (e.error === 'no-speech' || e.error === 'aborted') return
120
+ console.warn('[continuous-speech] error:', e.error)
121
+ }
122
+
123
+ recog.onend = () => {
124
+ // Auto-restart if still active (browser may stop recognition periodically)
125
+ if (activeRef.current && state !== 'waitingForResponse') {
126
+ try { recog.start() } catch { /* noop */ }
127
+ }
128
+ }
129
+
130
+ recogRef.current = recog
131
+ try {
132
+ recog.start()
133
+ setState('listening')
134
+ } catch {
135
+ setState('idle')
136
+ }
137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
138
+ }, [lang, silenceDelayMs, onUtterance])
139
+
140
+ const start = useCallback(() => {
141
+ activeRef.current = true
142
+ accumulatedRef.current = ''
143
+ setTranscript('')
144
+ setInterimText('')
145
+ startRecognition()
146
+ }, [startRecognition])
147
+
148
+ const stop = useCallback(() => {
149
+ activeRef.current = false
150
+ clearSilenceTimer()
151
+ if (recogRef.current) {
152
+ try { recogRef.current.stop() } catch { /* noop */ }
153
+ recogRef.current = null
154
+ }
155
+ setState('idle')
156
+ setTranscript('')
157
+ setInterimText('')
158
+ accumulatedRef.current = ''
159
+ }, [])
160
+
161
+ const pause = useCallback(() => {
162
+ clearSilenceTimer()
163
+ if (recogRef.current) {
164
+ try { recogRef.current.stop() } catch { /* noop */ }
165
+ }
166
+ }, [])
167
+
168
+ const resume = useCallback(() => {
169
+ if (!activeRef.current) return
170
+ accumulatedRef.current = ''
171
+ setTranscript('')
172
+ setInterimText('')
173
+ setState('listening')
174
+ startRecognition()
175
+ }, [startRecognition])
176
+
177
+ const supported = typeof window !== 'undefined' &&
178
+ !!((window as unknown as WindowWithSpeechRecognition).SpeechRecognition || (window as unknown as WindowWithSpeechRecognition).webkitSpeechRecognition)
179
+
180
+ return { state, transcript, interimText, start, stop, pause, resume, supported }
181
+ }
@@ -0,0 +1,63 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+ import { useWs } from './use-ws'
6
+
7
+ /** Call an OpenClaw gateway RPC method via the proxy route. */
8
+ export function useOpenClawRpc<T = unknown>(method: string | null, params?: unknown) {
9
+ const [data, setData] = useState<T | null>(null)
10
+ const [loading, setLoading] = useState(false)
11
+ const [error, setError] = useState<string | null>(null)
12
+ const paramsRef = useRef(params)
13
+ paramsRef.current = params
14
+
15
+ const fetch = useCallback(async () => {
16
+ if (!method) return
17
+ setLoading(true)
18
+ setError(null)
19
+ try {
20
+ const res = await api<{ ok: boolean; result: T; error?: string }>('POST', '/openclaw/gateway', {
21
+ method,
22
+ params: paramsRef.current,
23
+ })
24
+ if (res.error) {
25
+ setError(res.error)
26
+ } else {
27
+ setData(res.result)
28
+ }
29
+ } catch (err: unknown) {
30
+ setError(err instanceof Error ? err.message : String(err))
31
+ } finally {
32
+ setLoading(false)
33
+ }
34
+ }, [method])
35
+
36
+ useEffect(() => { fetch() }, [fetch])
37
+
38
+ return { data, loading, error, refetch: fetch }
39
+ }
40
+
41
+ /** Subscribe to an OpenClaw event topic via the WS hub. */
42
+ export function useOpenClawEvent(topic: string, handler: () => void) {
43
+ useWs(`openclaw:${topic}`, handler)
44
+ }
45
+
46
+ /** Check gateway connection status. */
47
+ export function useOpenClawConnected() {
48
+ const [connected, setConnected] = useState(false)
49
+
50
+ const check = useCallback(async () => {
51
+ try {
52
+ const res = await api<{ connected: boolean }>('GET', '/openclaw/gateway')
53
+ setConnected(res.connected)
54
+ } catch {
55
+ setConnected(false)
56
+ }
57
+ }, [])
58
+
59
+ useEffect(() => { check() }, [check])
60
+ useWs('openclaw:agents', check)
61
+
62
+ return connected
63
+ }
@@ -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
+ }
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,58 @@
1
+ let ctx: AudioContext | null = null
2
+
3
+ function ensureCtx(): AudioContext | null {
4
+ if (!ctx) {
5
+ try { ctx = new AudioContext() } catch { return null }
6
+ }
7
+ if (ctx.state === 'suspended') ctx.resume()
8
+ return ctx
9
+ }
10
+
11
+ function tone(freq: number, duration: number, type: OscillatorType = 'sine', delay = 0) {
12
+ const c = ensureCtx()
13
+ if (!c) return
14
+ const osc = c.createOscillator()
15
+ const gain = c.createGain()
16
+ osc.type = type
17
+ osc.frequency.value = freq
18
+ gain.gain.setValueAtTime(0.12, c.currentTime + delay)
19
+ gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + delay + duration / 1000)
20
+ osc.connect(gain)
21
+ gain.connect(c.destination)
22
+ osc.start(c.currentTime + delay)
23
+ osc.stop(c.currentTime + delay + duration / 1000)
24
+ }
25
+
26
+ /** Two ascending tones: C5 → E5 */
27
+ export function playStreamStart() {
28
+ tone(523, 80, 'sine', 0)
29
+ tone(659, 80, 'sine', 0.09)
30
+ }
31
+
32
+ /** Two descending tones: E5 → C5 */
33
+ export function playStreamEnd() {
34
+ tone(659, 80, 'sine', 0)
35
+ tone(523, 80, 'sine', 0.09)
36
+ }
37
+
38
+ /** Single ding: A5 */
39
+ export function playToolComplete() {
40
+ tone(880, 120, 'triangle')
41
+ }
42
+
43
+ /** Low buzz: A3 */
44
+ export function playError() {
45
+ tone(220, 200, 'square')
46
+ }
47
+
48
+ const LS_KEY = 'sc_sound_notifications'
49
+
50
+ export function getSoundEnabled(): boolean {
51
+ if (typeof window === 'undefined') return false
52
+ return localStorage.getItem(LS_KEY) === '1'
53
+ }
54
+
55
+ export function setSoundEnabled(v: boolean) {
56
+ if (typeof window === 'undefined') return
57
+ localStorage.setItem(LS_KEY, v ? '1' : '0')
58
+ }
@@ -0,0 +1,97 @@
1
+ import type { PersonalityDraft } from '@/types'
2
+
3
+ // --- IDENTITY.md ---
4
+
5
+ export function parseIdentityMd(content: string): PersonalityDraft['identity'] {
6
+ const result: PersonalityDraft['identity'] = {}
7
+ const lines = content.split('\n')
8
+ for (const line of lines) {
9
+ const match = line.match(/^\s*-\s*(.+?):\s*(.+)$/)
10
+ if (!match) continue
11
+ const key = match[1].trim().toLowerCase()
12
+ const value = match[2].trim()
13
+ if (key === 'name') result.name = value
14
+ else if (key === 'creature' || key === 'species' || key === 'type') result.creature = value
15
+ else if (key === 'vibe' || key === 'personality') result.vibe = value
16
+ else if (key === 'emoji' || key === 'icon') result.emoji = value
17
+ }
18
+ return result
19
+ }
20
+
21
+ export function serializeIdentityMd(draft: PersonalityDraft['identity']): string {
22
+ const lines: string[] = ['# Identity', '']
23
+ if (draft.name) lines.push(`- Name: ${draft.name}`)
24
+ if (draft.creature) lines.push(`- Creature: ${draft.creature}`)
25
+ if (draft.vibe) lines.push(`- Vibe: ${draft.vibe}`)
26
+ if (draft.emoji) lines.push(`- Emoji: ${draft.emoji}`)
27
+ return lines.join('\n') + '\n'
28
+ }
29
+
30
+ // --- USER.md ---
31
+
32
+ export function parseUserMd(content: string): PersonalityDraft['user'] {
33
+ const result: PersonalityDraft['user'] = {}
34
+ const contextIdx = content.indexOf('## Context')
35
+ const headerPart = contextIdx >= 0 ? content.slice(0, contextIdx) : content
36
+ const contextPart = contextIdx >= 0 ? content.slice(contextIdx + '## Context'.length).trim() : ''
37
+
38
+ const lines = headerPart.split('\n')
39
+ for (const line of lines) {
40
+ const match = line.match(/^\s*-\s*(.+?):\s*(.+)$/)
41
+ if (!match) continue
42
+ const key = match[1].trim().toLowerCase()
43
+ const value = match[2].trim()
44
+ if (key === 'name') result.name = value
45
+ else if (key === 'call them' || key === 'nickname') result.callThem = value
46
+ else if (key === 'pronouns') result.pronouns = value
47
+ else if (key === 'timezone') result.timezone = value
48
+ else if (key === 'notes') result.notes = value
49
+ }
50
+
51
+ if (contextPart) result.context = contextPart
52
+
53
+ return result
54
+ }
55
+
56
+ export function serializeUserMd(draft: PersonalityDraft['user']): string {
57
+ const lines: string[] = ['# User', '']
58
+ if (draft.name) lines.push(`- Name: ${draft.name}`)
59
+ if (draft.callThem) lines.push(`- Call them: ${draft.callThem}`)
60
+ if (draft.pronouns) lines.push(`- Pronouns: ${draft.pronouns}`)
61
+ if (draft.timezone) lines.push(`- Timezone: ${draft.timezone}`)
62
+ if (draft.notes) lines.push(`- Notes: ${draft.notes}`)
63
+ if (draft.context) {
64
+ lines.push('', '## Context', '', draft.context)
65
+ }
66
+ return lines.join('\n') + '\n'
67
+ }
68
+
69
+ // --- SOUL.md ---
70
+
71
+ export function parseSoulMd(content: string): PersonalityDraft['soul'] {
72
+ const result: PersonalityDraft['soul'] = {}
73
+ const sections = content.split(/^##\s+/m)
74
+
75
+ for (const section of sections) {
76
+ const nlIdx = section.indexOf('\n')
77
+ if (nlIdx < 0) continue
78
+ const heading = section.slice(0, nlIdx).trim().toLowerCase()
79
+ const body = section.slice(nlIdx + 1).trim()
80
+
81
+ if (heading.startsWith('core truth')) result.coreTruths = body
82
+ else if (heading.startsWith('boundar')) result.boundaries = body
83
+ else if (heading.startsWith('vibe')) result.vibe = body
84
+ else if (heading.startsWith('continuity')) result.continuity = body
85
+ }
86
+
87
+ return result
88
+ }
89
+
90
+ export function serializeSoulMd(draft: PersonalityDraft['soul']): string {
91
+ const lines: string[] = ['# Soul', '']
92
+ if (draft.coreTruths) lines.push('## Core Truths', '', draft.coreTruths, '')
93
+ if (draft.boundaries) lines.push('## Boundaries', '', draft.boundaries, '')
94
+ if (draft.vibe) lines.push('## Vibe', '', draft.vibe, '')
95
+ if (draft.continuity) lines.push('## Continuity', '', draft.continuity, '')
96
+ return lines.join('\n')
97
+ }
@@ -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
  })
@@ -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 {
@@ -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
  })
@@ -43,7 +43,7 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
43
43
  return [{ type: 'text', text: `[Attached file: ${name}]` }]
44
44
  }
45
45
 
46
- 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> {
47
47
  return new Promise(async (resolve) => {
48
48
  const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
49
49
  const model = session.model || 'gpt-4o'
@@ -52,6 +52,7 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
52
52
  model,
53
53
  messages,
54
54
  stream: true,
55
+ stream_options: { include_usage: true },
55
56
  })
56
57
 
57
58
  let fullResponse = ''
@@ -136,6 +137,13 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
136
137
  fullResponse += delta
137
138
  write(`data: ${JSON.stringify({ t: 'd', text: delta })}\n\n`)
138
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
+ }
139
147
  } catch {}
140
148
  }
141
149
  }