@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.
- package/README.md +33 -13
- package/bin/server-cmd.js +14 -7
- package/bin/swarmclaw.js +3 -1
- package/bin/update-cmd.js +120 -0
- package/next.config.ts +10 -0
- package/package.json +4 -1
- package/src/app/api/agents/[id]/route.ts +20 -18
- package/src/app/api/agents/[id]/thread/route.ts +4 -3
- package/src/app/api/agents/route.ts +8 -3
- package/src/app/api/auth/route.ts +3 -1
- package/src/app/api/claude-skills/route.ts +3 -1
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +14 -3
- package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
- package/src/app/api/connectors/route.ts +12 -4
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +5 -3
- package/src/app/api/daemon/route.ts +6 -1
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- package/src/app/api/ip/route.ts +3 -1
- package/src/app/api/knowledge/[id]/route.ts +5 -4
- package/src/app/api/knowledge/upload/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/route.ts +11 -14
- package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
- package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
- package/src/app/api/mcp-servers/route.ts +5 -3
- package/src/app/api/memory/[id]/route.ts +9 -8
- package/src/app/api/memory/route.ts +2 -2
- package/src/app/api/memory-images/[filename]/route.ts +2 -1
- package/src/app/api/openclaw/directory/route.ts +26 -0
- package/src/app/api/openclaw/discover/route.ts +61 -0
- package/src/app/api/openclaw/sync/route.ts +30 -0
- package/src/app/api/orchestrator/graph/route.ts +25 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- package/src/app/api/plugins/marketplace/route.ts +3 -1
- package/src/app/api/plugins/route.ts +3 -1
- package/src/app/api/projects/[id]/route.ts +55 -0
- package/src/app/api/projects/route.ts +27 -0
- package/src/app/api/providers/[id]/models/route.ts +2 -1
- package/src/app/api/providers/[id]/route.ts +13 -12
- package/src/app/api/providers/configs/route.ts +3 -1
- package/src/app/api/providers/route.ts +7 -3
- package/src/app/api/schedules/[id]/route.ts +16 -15
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +8 -3
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +5 -3
- package/src/app/api/sessions/[id]/chat/route.ts +5 -2
- package/src/app/api/sessions/[id]/clear/route.ts +2 -1
- package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
- package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
- package/src/app/api/sessions/[id]/messages/route.ts +2 -1
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +2 -1
- package/src/app/api/sessions/route.ts +11 -4
- package/src/app/api/settings/route.ts +3 -1
- package/src/app/api/setup/doctor/route.ts +1 -0
- package/src/app/api/setup/openclaw-device/route.ts +3 -1
- package/src/app/api/skills/[id]/route.ts +23 -21
- package/src/app/api/skills/import/route.ts +2 -2
- package/src/app/api/skills/route.ts +5 -3
- package/src/app/api/tasks/[id]/approve/route.ts +74 -0
- package/src/app/api/tasks/[id]/route.ts +9 -5
- package/src/app/api/tasks/route.ts +5 -2
- package/src/app/api/tts/stream/route.ts +48 -0
- package/src/app/api/upload/route.ts +2 -2
- package/src/app/api/uploads/[filename]/route.ts +4 -1
- package/src/app/api/usage/route.ts +3 -1
- package/src/app/api/version/route.ts +3 -1
- package/src/app/api/webhooks/[id]/route.ts +31 -32
- package/src/app/api/webhooks/route.ts +5 -3
- package/src/app/icon.svg +58 -0
- package/src/app/page.tsx +11 -26
- package/src/cli/index.js +28 -9
- package/src/cli/index.ts +45 -2
- package/src/cli/spec.js +2 -8
- package/src/components/agents/agent-card.tsx +1 -1
- package/src/components/agents/agent-list.tsx +3 -1
- package/src/components/agents/agent-sheet.tsx +166 -81
- package/src/components/chat/chat-area.tsx +71 -34
- package/src/components/chat/chat-header.tsx +141 -29
- package/src/components/chat/chat-tool-toggles.tsx +12 -53
- package/src/components/chat/message-bubble.tsx +110 -42
- package/src/components/chat/tool-call-bubble.tsx +50 -6
- package/src/components/chat/tool-request-banner.tsx +1 -9
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +9 -10
- package/src/components/connectors/connector-sheet.tsx +55 -36
- package/src/components/input/chat-input.tsx +72 -56
- package/src/components/knowledge/knowledge-list.tsx +27 -31
- package/src/components/layout/app-layout.tsx +133 -90
- package/src/components/layout/daemon-indicator.tsx +3 -5
- package/src/components/logs/log-list.tsx +5 -9
- package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
- package/src/components/memory/memory-detail.tsx +1 -1
- package/src/components/plugins/plugin-list.tsx +227 -27
- package/src/components/projects/project-list.tsx +122 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/providers/provider-list.tsx +46 -13
- package/src/components/providers/provider-sheet.tsx +0 -45
- package/src/components/runs/run-list.tsx +6 -15
- package/src/components/schedules/schedule-card.tsx +54 -4
- package/src/components/schedules/schedule-list.tsx +9 -4
- package/src/components/schedules/schedule-sheet.tsx +0 -47
- package/src/components/secrets/secrets-list.tsx +20 -2
- package/src/components/sessions/new-session-sheet.tsx +14 -15
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/shared/connector-platform-icon.tsx +26 -20
- package/src/components/shared/model-combobox.tsx +148 -0
- package/src/components/shared/settings/section-heartbeat.tsx +8 -40
- package/src/components/shared/settings/section-orchestrator.tsx +9 -11
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +73 -0
- package/src/components/skills/skill-list.tsx +262 -35
- package/src/components/skills/skill-sheet.tsx +0 -45
- package/src/components/tasks/task-board.tsx +3 -6
- package/src/components/tasks/task-card.tsx +43 -1
- package/src/components/tasks/task-list.tsx +8 -7
- package/src/components/tasks/task-sheet.tsx +0 -44
- package/src/components/usage/usage-list.tsx +12 -4
- package/src/hooks/use-continuous-speech.ts +144 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/hooks/use-ws.ts +66 -0
- package/src/instrumentation.ts +2 -0
- package/src/lib/chat.ts +14 -2
- package/src/lib/id.ts +6 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +15 -2
- package/src/lib/providers/index.ts +8 -0
- package/src/lib/providers/ollama.ts +10 -2
- package/src/lib/providers/openai.ts +42 -13
- package/src/lib/providers/openclaw.ts +11 -0
- package/src/lib/server/api-routes.test.ts +5 -6
- package/src/lib/server/build-llm.ts +17 -4
- package/src/lib/server/chat-execution.ts +57 -8
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
- package/src/lib/server/connectors/bluebubbles.ts +357 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +46 -7
- package/src/lib/server/connectors/manager.ts +401 -6
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +64 -0
- package/src/lib/server/connectors/pairing.test.ts +99 -0
- package/src/lib/server/connectors/pairing.ts +256 -0
- package/src/lib/server/connectors/signal.ts +1 -0
- package/src/lib/server/connectors/teams.ts +5 -5
- package/src/lib/server/connectors/types.ts +10 -0
- package/src/lib/server/context-manager.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -0
- package/src/lib/server/data-dir.ts +1 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +67 -3
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/langgraph-checkpoint.ts +274 -0
- package/src/lib/server/main-agent-loop.ts +67 -8
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -0
- package/src/lib/server/openclaw-sync.ts +496 -0
- package/src/lib/server/orchestrator-lg.ts +422 -20
- package/src/lib/server/orchestrator.ts +29 -9
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +39 -13
- package/src/lib/server/scheduler.ts +2 -2
- package/src/lib/server/session-mailbox.ts +2 -2
- package/src/lib/server/session-run-manager.ts +8 -3
- package/src/lib/server/session-tools/connector.ts +51 -4
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +5 -5
- package/src/lib/server/session-tools/file.ts +176 -3
- package/src/lib/server/session-tools/index.ts +4 -0
- package/src/lib/server/session-tools/memory.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
- package/src/lib/server/session-tools/sandbox.ts +197 -0
- package/src/lib/server/session-tools/search-providers.ts +270 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- package/src/lib/server/session-tools/web.ts +47 -66
- package/src/lib/server/storage-mcp.test.ts +25 -2
- package/src/lib/server/storage.ts +36 -7
- package/src/lib/server/stream-agent-chat.ts +106 -22
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/server/task-validation.test.ts +23 -0
- package/src/lib/server/task-validation.ts +5 -3
- package/src/lib/server/ws-hub.ts +85 -0
- package/src/lib/tool-definitions.ts +44 -0
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/upload.ts +7 -1
- package/src/lib/view-routes.ts +28 -0
- package/src/lib/ws-client.ts +124 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +28 -1
- package/src/stores/use-chat-store.ts +42 -14
- package/src/types/index.ts +34 -2
- package/src/app/api/agents/generate/route.ts +0 -42
- package/src/app/api/generate/info/route.ts +0 -12
- package/src/app/api/generate/route.ts +0 -106
- package/src/app/favicon.ico +0 -0
- 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
|
+
}
|
package/src/instrumentation.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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,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
|
|
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
|
-
|
|
13
|
-
|
|
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 (
|
|
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: ${
|
|
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)
|