@eyeclaw/eyeclaw 2.0.14 → 2.2.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.
- package/index.ts +51 -19
- package/openclaw.plugin.json +7 -20
- package/package.json +5 -17
- package/src/cli.ts +18 -22
- package/src/http-handler.ts +208 -0
- package/src/types.ts +1 -36
- package/src/websocket-client.ts +316 -0
- package/src/channel.ts +0 -295
- package/src/client.ts +0 -374
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EyeClaw SDK - WebSocket Client
|
|
3
|
+
*
|
|
4
|
+
* 连接到 Rails 服务器,接收消息并流式返回
|
|
5
|
+
*/
|
|
6
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
|
|
7
|
+
import type { EyeClawConfig } from './types.js'
|
|
8
|
+
|
|
9
|
+
interface WebSocketMessage {
|
|
10
|
+
type: string
|
|
11
|
+
[key: string]: any
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ActionCableMessage {
|
|
15
|
+
identifier: string
|
|
16
|
+
message: WebSocketMessage
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class EyeClawWebSocketClient {
|
|
20
|
+
private ws: WebSocket | null = null
|
|
21
|
+
private api: OpenClawPluginApi
|
|
22
|
+
private config: EyeClawConfig
|
|
23
|
+
private reconnectAttempts = 0
|
|
24
|
+
private maxReconnectAttempts = 5
|
|
25
|
+
private reconnectDelay = 3000
|
|
26
|
+
private subscribed = false
|
|
27
|
+
private pingInterval: any = null
|
|
28
|
+
|
|
29
|
+
constructor(api: OpenClawPluginApi, config: EyeClawConfig) {
|
|
30
|
+
this.api = api
|
|
31
|
+
this.config = config
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 启动 WebSocket 连接
|
|
36
|
+
*/
|
|
37
|
+
async start() {
|
|
38
|
+
const { serverUrl, sdkToken, botId } = this.config
|
|
39
|
+
|
|
40
|
+
if (!serverUrl || !sdkToken || !botId) {
|
|
41
|
+
this.api.logger.warn('[EyeClaw] WebSocket: Missing config (serverUrl, sdkToken, or botId)')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const wsUrl = serverUrl.replace(/^http/, 'ws') + `/cable?sdk_token=${sdkToken}&bot_id=${botId}`
|
|
46
|
+
this.api.logger.info(`[EyeClaw] WebSocket connecting to: ${wsUrl}`)
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// @ts-ignore - WebSocket 在 Node 环境中可用
|
|
50
|
+
this.ws = new WebSocket(wsUrl)
|
|
51
|
+
|
|
52
|
+
this.ws.onopen = () => {
|
|
53
|
+
this.api.logger.info('[EyeClaw] WebSocket connected')
|
|
54
|
+
this.reconnectAttempts = 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.ws.onmessage = (event) => {
|
|
58
|
+
this.handleMessage(event.data)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.ws.onerror = (error) => {
|
|
62
|
+
this.api.logger.error(`[EyeClaw] WebSocket error: ${error}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.ws.onclose = () => {
|
|
66
|
+
this.api.logger.warn('[EyeClaw] WebSocket disconnected')
|
|
67
|
+
this.subscribed = false
|
|
68
|
+
this.stopPing()
|
|
69
|
+
this.scheduleReconnect()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.api.logger.error(`[EyeClaw] WebSocket connection failed: ${error}`)
|
|
74
|
+
this.scheduleReconnect()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 停止 WebSocket 连接
|
|
80
|
+
*/
|
|
81
|
+
stop() {
|
|
82
|
+
this.stopPing()
|
|
83
|
+
if (this.ws) {
|
|
84
|
+
this.ws.close()
|
|
85
|
+
this.ws = null
|
|
86
|
+
}
|
|
87
|
+
this.subscribed = false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 处理 WebSocket 消息
|
|
92
|
+
*/
|
|
93
|
+
private handleMessage(data: string) {
|
|
94
|
+
try {
|
|
95
|
+
const message = JSON.parse(data)
|
|
96
|
+
|
|
97
|
+
// Welcome message
|
|
98
|
+
if (message.type === 'welcome') {
|
|
99
|
+
this.api.logger.info('[EyeClaw] Received welcome, subscribing...')
|
|
100
|
+
this.subscribe()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ping/pong
|
|
105
|
+
if (message.type === 'ping') {
|
|
106
|
+
this.send({ type: 'pong' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Subscription confirmation
|
|
111
|
+
if (message.type === 'confirm_subscription') {
|
|
112
|
+
this.api.logger.info('[EyeClaw] ✅ Subscribed to channel')
|
|
113
|
+
this.subscribed = true
|
|
114
|
+
this.startPing()
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Rejection
|
|
119
|
+
if (message.type === 'reject_subscription') {
|
|
120
|
+
this.api.logger.error('[EyeClaw] ❌ Subscription rejected')
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 实际消息 - 从 Rails 发送的消息
|
|
125
|
+
if (message.identifier && message.message) {
|
|
126
|
+
const payload = message.message
|
|
127
|
+
this.handleCommand(payload)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.api.logger.error(`[EyeClaw] Failed to parse message: ${error}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 订阅到 BotChannel
|
|
137
|
+
*/
|
|
138
|
+
private subscribe() {
|
|
139
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
|
140
|
+
|
|
141
|
+
const channelIdentifier = JSON.stringify({
|
|
142
|
+
channel: 'BotChannel',
|
|
143
|
+
bot_id: this.config.botId,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
this.send({
|
|
147
|
+
command: 'subscribe',
|
|
148
|
+
identifier: channelIdentifier,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 处理命令消息
|
|
154
|
+
*/
|
|
155
|
+
private async handleCommand(payload: WebSocketMessage) {
|
|
156
|
+
const { type, message: text, session_id, command } = payload
|
|
157
|
+
|
|
158
|
+
// 只处理 execute_command 类型的消息
|
|
159
|
+
if (type !== 'execute_command' && type !== 'chat') {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const userMessage = text || command
|
|
164
|
+
if (!userMessage) {
|
|
165
|
+
this.api.logger.warn('[EyeClaw] No message content')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.api.logger.info(`[EyeClaw] Processing: ${userMessage.substring(0, 50)}...`)
|
|
170
|
+
|
|
171
|
+
// 通过 OpenClaw API 处理消息,获取流式响应
|
|
172
|
+
await this.processWithOpenClaw(userMessage, session_id)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 使用 OpenClaw API 处理消息(流式)
|
|
177
|
+
*/
|
|
178
|
+
private async processWithOpenClaw(message: string, sessionId?: string) {
|
|
179
|
+
const gatewayPort = this.api.config?.gateway?.port ?? 18789
|
|
180
|
+
const gatewayToken = this.api.config?.gateway?.auth?.token
|
|
181
|
+
|
|
182
|
+
const openclawUrl = `http://127.0.0.1:${gatewayPort}/v1/chat/completions`
|
|
183
|
+
const openclawBody = {
|
|
184
|
+
model: 'openclaw:main',
|
|
185
|
+
stream: true,
|
|
186
|
+
messages: [{ role: 'user', content: message }],
|
|
187
|
+
user: sessionId ? `eyeclaw:${sessionId}` : 'eyeclaw:ws',
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
191
|
+
if (gatewayToken) headers['Authorization'] = `Bearer ${gatewayToken}`
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(openclawUrl, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers,
|
|
197
|
+
body: JSON.stringify(openclawBody),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const errorText = await response.text()
|
|
202
|
+
throw new Error(`OpenClaw API error: ${response.status} - ${errorText}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const reader = response.body?.getReader()
|
|
206
|
+
if (!reader) throw new Error('No response body')
|
|
207
|
+
|
|
208
|
+
const decoder = new TextDecoder()
|
|
209
|
+
let buffer = ''
|
|
210
|
+
|
|
211
|
+
// 发送流式响应回 Rails
|
|
212
|
+
while (true) {
|
|
213
|
+
const { done, value } = await reader.read()
|
|
214
|
+
if (done) break
|
|
215
|
+
|
|
216
|
+
buffer += decoder.decode(value, { stream: true })
|
|
217
|
+
const lines = buffer.split('\n')
|
|
218
|
+
buffer = lines.pop() || ''
|
|
219
|
+
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
const trimmed = line.trim()
|
|
222
|
+
if (!trimmed.startsWith('data: ')) continue
|
|
223
|
+
const data = trimmed.slice(6)
|
|
224
|
+
if (data === '[DONE]') continue
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const chunk = JSON.parse(data)
|
|
228
|
+
const content = chunk.choices?.[0]?.delta?.content
|
|
229
|
+
if (content) {
|
|
230
|
+
this.sendChunk(content, sessionId)
|
|
231
|
+
}
|
|
232
|
+
} catch { /* ignore */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 发送完成信号
|
|
237
|
+
this.sendMessage('stream_end', { session_id: sessionId })
|
|
238
|
+
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
241
|
+
this.api.logger.error(`[EyeClaw] OpenClaw error: ${errorMsg}`)
|
|
242
|
+
this.sendMessage('stream_error', { error: errorMsg, session_id: sessionId })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 通过 WebSocket 发送消息到 Rails
|
|
248
|
+
*/
|
|
249
|
+
private send(data: any) {
|
|
250
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
|
251
|
+
this.ws.send(JSON.stringify(data))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 发送流式内容块到 Rails
|
|
256
|
+
*/
|
|
257
|
+
private sendChunk(content: string, sessionId?: string) {
|
|
258
|
+
this.send({
|
|
259
|
+
type: 'stream_chunk',
|
|
260
|
+
content,
|
|
261
|
+
session_id: sessionId,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 发送消息到 Rails(带 channel identifier)
|
|
267
|
+
*/
|
|
268
|
+
private sendMessage(type: string, data: any) {
|
|
269
|
+
const channelIdentifier = JSON.stringify({
|
|
270
|
+
channel: 'BotChannel',
|
|
271
|
+
bot_id: this.config.botId,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
this.send({
|
|
275
|
+
command: 'message',
|
|
276
|
+
identifier: channelIdentifier,
|
|
277
|
+
data: { type, ...data },
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 启动心跳
|
|
283
|
+
*/
|
|
284
|
+
private startPing() {
|
|
285
|
+
this.pingInterval = setInterval(() => {
|
|
286
|
+
this.send({ type: 'ping' })
|
|
287
|
+
}, 30000)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 停止心跳
|
|
292
|
+
*/
|
|
293
|
+
private stopPing() {
|
|
294
|
+
if (this.pingInterval) {
|
|
295
|
+
clearInterval(this.pingInterval)
|
|
296
|
+
this.pingInterval = null
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 计划重连
|
|
302
|
+
*/
|
|
303
|
+
private scheduleReconnect() {
|
|
304
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
305
|
+
this.api.logger.error('[EyeClaw] Max reconnect attempts reached')
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this.reconnectAttempts++
|
|
310
|
+
this.api.logger.info(`[EyeClaw] Reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`)
|
|
311
|
+
|
|
312
|
+
setTimeout(() => {
|
|
313
|
+
this.start()
|
|
314
|
+
}, this.reconnectDelay)
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/channel.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import type { ChannelPlugin } from 'openclaw/plugin-sdk'
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
|
|
3
|
-
import type { EyeClawConfig, ResolvedEyeClawAccount } from './types.js'
|
|
4
|
-
import { EyeClawClient } from './client.js'
|
|
5
|
-
|
|
6
|
-
// Active clients map (accountId -> client)
|
|
7
|
-
const clients = new Map<string, EyeClawClient>()
|
|
8
|
-
|
|
9
|
-
// Store runtime for use in gateway.startAccount (set during plugin registration)
|
|
10
|
-
let _runtime: any = null
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Set the plugin runtime (called during plugin registration)
|
|
14
|
-
*/
|
|
15
|
-
export function setRuntime(runtime: any) {
|
|
16
|
-
_runtime = runtime
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function getRuntime() {
|
|
20
|
-
return _runtime
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Resolve EyeClaw account configuration
|
|
25
|
-
*/
|
|
26
|
-
function resolveEyeClawAccount(cfg: any, accountId: string): ResolvedEyeClawAccount {
|
|
27
|
-
const eyeclawConfig: EyeClawConfig = cfg?.channels?.eyeclaw || {}
|
|
28
|
-
|
|
29
|
-
// Default account uses top-level config
|
|
30
|
-
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
31
|
-
return {
|
|
32
|
-
accountId: DEFAULT_ACCOUNT_ID,
|
|
33
|
-
enabled: eyeclawConfig.enabled !== false,
|
|
34
|
-
configured: !!(eyeclawConfig.botId && eyeclawConfig.sdkToken),
|
|
35
|
-
name: 'Default',
|
|
36
|
-
config: eyeclawConfig,
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Named accounts not supported yet
|
|
41
|
-
throw new Error(`Named accounts not yet supported for EyeClaw`)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* List all EyeClaw account IDs
|
|
46
|
-
*/
|
|
47
|
-
function listEyeClawAccountIds(cfg: any): string[] {
|
|
48
|
-
return [DEFAULT_ACCOUNT_ID]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* EyeClaw Channel Plugin
|
|
53
|
-
*/
|
|
54
|
-
export const eyeclawPlugin: ChannelPlugin<ResolvedEyeClawAccount> = {
|
|
55
|
-
id: 'eyeclaw',
|
|
56
|
-
|
|
57
|
-
meta: {
|
|
58
|
-
id: 'eyeclaw',
|
|
59
|
-
label: 'EyeClaw',
|
|
60
|
-
selectionLabel: 'EyeClaw Platform',
|
|
61
|
-
docsPath: '/channels/eyeclaw',
|
|
62
|
-
docsLabel: 'eyeclaw',
|
|
63
|
-
blurb: 'EyeClaw platform integration via WebSocket.',
|
|
64
|
-
order: 100,
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
capabilities: {
|
|
68
|
-
chatTypes: ['direct', 'channel'],
|
|
69
|
-
polls: false,
|
|
70
|
-
threads: false,
|
|
71
|
-
media: false,
|
|
72
|
-
reactions: false,
|
|
73
|
-
edit: false,
|
|
74
|
-
reply: false,
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
reload: { configPrefixes: ['channels.eyeclaw'] },
|
|
78
|
-
|
|
79
|
-
configSchema: {
|
|
80
|
-
schema: {
|
|
81
|
-
type: 'object',
|
|
82
|
-
additionalProperties: true,
|
|
83
|
-
properties: {
|
|
84
|
-
enabled: {
|
|
85
|
-
type: 'boolean',
|
|
86
|
-
description: 'Enable/disable the plugin',
|
|
87
|
-
},
|
|
88
|
-
botId: {
|
|
89
|
-
type: ['string', 'number'],
|
|
90
|
-
description: 'Bot ID from EyeClaw platform',
|
|
91
|
-
},
|
|
92
|
-
sdkToken: {
|
|
93
|
-
type: 'string',
|
|
94
|
-
description: 'SDK token for authentication',
|
|
95
|
-
},
|
|
96
|
-
serverUrl: {
|
|
97
|
-
type: 'string',
|
|
98
|
-
description: 'EyeClaw server URL',
|
|
99
|
-
},
|
|
100
|
-
reconnectInterval: {
|
|
101
|
-
type: 'number',
|
|
102
|
-
description: 'Reconnect interval in milliseconds',
|
|
103
|
-
},
|
|
104
|
-
heartbeatInterval: {
|
|
105
|
-
type: 'number',
|
|
106
|
-
description: 'Heartbeat interval in milliseconds',
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
config: {
|
|
113
|
-
listAccountIds: (cfg) => listEyeClawAccountIds(cfg),
|
|
114
|
-
resolveAccount: (cfg, accountId) => resolveEyeClawAccount(cfg, accountId || DEFAULT_ACCOUNT_ID),
|
|
115
|
-
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
116
|
-
},
|
|
117
|
-
|
|
118
|
-
status: {
|
|
119
|
-
defaultRuntime: {
|
|
120
|
-
accountId: DEFAULT_ACCOUNT_ID,
|
|
121
|
-
running: false,
|
|
122
|
-
lastStartAt: null,
|
|
123
|
-
lastStopAt: null,
|
|
124
|
-
lastError: null,
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
buildChannelSummary: ({ snapshot }) => ({
|
|
128
|
-
configured: snapshot.configured ?? false,
|
|
129
|
-
running: snapshot.running ?? false,
|
|
130
|
-
lastStartAt: snapshot.lastStartAt ?? null,
|
|
131
|
-
lastStopAt: snapshot.lastStopAt ?? null,
|
|
132
|
-
lastError: snapshot.lastError ?? null,
|
|
133
|
-
}),
|
|
134
|
-
|
|
135
|
-
probeAccount: async ({ account }) => {
|
|
136
|
-
// Simple probe - check if configured
|
|
137
|
-
return {
|
|
138
|
-
ok: account.configured,
|
|
139
|
-
message: account.configured ? 'Configured' : 'Not configured',
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
144
|
-
accountId: account.accountId,
|
|
145
|
-
enabled: account.enabled,
|
|
146
|
-
configured: account.configured,
|
|
147
|
-
name: account.name,
|
|
148
|
-
running: runtime?.running ?? false,
|
|
149
|
-
lastStartAt: runtime?.lastStartAt ?? null,
|
|
150
|
-
lastStopAt: runtime?.lastStopAt ?? null,
|
|
151
|
-
lastError: runtime?.lastError ?? null,
|
|
152
|
-
probe,
|
|
153
|
-
}),
|
|
154
|
-
},
|
|
155
|
-
|
|
156
|
-
gateway: {
|
|
157
|
-
startAccount: async (ctx: any) => {
|
|
158
|
-
const account = resolveEyeClawAccount(ctx.cfg, ctx.accountId)
|
|
159
|
-
|
|
160
|
-
if (!account.configured || !account.config) {
|
|
161
|
-
throw new Error('EyeClaw not configured. Please set botId and sdkToken.')
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const config = account.config
|
|
165
|
-
|
|
166
|
-
// Validate required fields
|
|
167
|
-
if (!config.botId || !config.sdkToken) {
|
|
168
|
-
throw new Error('botId and sdkToken are required')
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Set defaults
|
|
172
|
-
const clientConfig = {
|
|
173
|
-
botId: String(config.botId),
|
|
174
|
-
sdkToken: config.sdkToken,
|
|
175
|
-
serverUrl: config.serverUrl || 'http://localhost:3000',
|
|
176
|
-
reconnectInterval: config.reconnectInterval || 5000,
|
|
177
|
-
heartbeatInterval: config.heartbeatInterval || 30000,
|
|
178
|
-
enabled: true,
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
ctx.log?.info(`🦞 Starting EyeClaw SDK... botId=${clientConfig.botId}, serverUrl=${clientConfig.serverUrl}`)
|
|
182
|
-
|
|
183
|
-
// Create logger adapter
|
|
184
|
-
const logger = {
|
|
185
|
-
debug: (msg: string) => ctx.log?.debug?.(msg),
|
|
186
|
-
info: (msg: string) => ctx.log?.info(msg),
|
|
187
|
-
warn: (msg: string) => ctx.log?.warn(msg),
|
|
188
|
-
error: (msg: string) => ctx.log?.error(msg),
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Get runtime from module-level storage (set during register)
|
|
192
|
-
const runtime = getRuntime()
|
|
193
|
-
if (!runtime) {
|
|
194
|
-
throw new Error('OpenClaw runtime not available - did you install the plugin correctly?')
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Create and connect client
|
|
198
|
-
const client = new EyeClawClient(clientConfig, logger)
|
|
199
|
-
clients.set(ctx.accountId, client)
|
|
200
|
-
|
|
201
|
-
// Register OpenClaw Agent callback for chat messages
|
|
202
|
-
// Use runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher for true streaming
|
|
203
|
-
client.setSendAgentCallback(async (message: string) => {
|
|
204
|
-
const streamId = Date.now().toString()
|
|
205
|
-
const streamKey = 'eyeclaw-web-chat'
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
ctx.log?.info(`🤖 Processing message via OpenClaw dispatchReply: ${message}`)
|
|
209
|
-
|
|
210
|
-
// 发送 stream_start
|
|
211
|
-
client.sendStreamChunk('stream_start', streamId, '')
|
|
212
|
-
|
|
213
|
-
// 使用 OpenClaw 的 dispatchReplyWithBufferedBlockDispatcher 实现真正的流式
|
|
214
|
-
// 这和 WeCom 插件使用的方式完全相同
|
|
215
|
-
// API 路径: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher
|
|
216
|
-
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
217
|
-
ctx: {
|
|
218
|
-
// Inbound message context
|
|
219
|
-
sessionKey: `eyeclaw:${streamKey}`,
|
|
220
|
-
messageKey: `eyeclaw:${streamKey}:${streamId}`,
|
|
221
|
-
peerKey: streamKey,
|
|
222
|
-
message: {
|
|
223
|
-
role: 'user',
|
|
224
|
-
content: message,
|
|
225
|
-
roleDetail: 'user',
|
|
226
|
-
},
|
|
227
|
-
},
|
|
228
|
-
cfg: ctx.cfg,
|
|
229
|
-
dispatcherOptions: {
|
|
230
|
-
// 流式回调 - LLM 每生成一块文本就实时调用
|
|
231
|
-
deliver: async (payload: any, info: any) => {
|
|
232
|
-
const text = payload.text || ''
|
|
233
|
-
if (text) {
|
|
234
|
-
ctx.log?.debug(`Delivering chunk: ${text.substring(0, 50)}...`)
|
|
235
|
-
client.sendStreamChunk('stream_chunk', streamId, text)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 当主响应完成时记录
|
|
239
|
-
if (info.kind === 'final') {
|
|
240
|
-
ctx.log?.info('Main response complete')
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
onError: async (error: any, info: any) => {
|
|
244
|
-
ctx.log?.error(`Reply failed: ${error.message}`)
|
|
245
|
-
client.sendStreamChunk('stream_error', streamId, error.message)
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
// 发送 stream_end
|
|
251
|
-
client.sendStreamChunk('stream_end', streamId, '')
|
|
252
|
-
ctx.log?.info(`✅ Message processed successfully`)
|
|
253
|
-
|
|
254
|
-
} catch (error) {
|
|
255
|
-
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
256
|
-
ctx.log?.error(`Failed to process message: ${errorMsg}`)
|
|
257
|
-
// 发送错误通知
|
|
258
|
-
try {
|
|
259
|
-
client.sendStreamChunk('stream_error', streamId, errorMsg)
|
|
260
|
-
client.sendLog('error', `❌ Error: ${errorMsg}`)
|
|
261
|
-
} catch (sendError) {
|
|
262
|
-
ctx.log?.error(`Failed to send error notification: ${sendError}`)
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
await client.connect()
|
|
269
|
-
ctx.log?.info('✅ Successfully connected to EyeClaw platform')
|
|
270
|
-
ctx.setStatus({ accountId: ctx.accountId, running: true, lastStartAt: Date.now() })
|
|
271
|
-
|
|
272
|
-
// Wait for abort signal
|
|
273
|
-
await new Promise<void>((resolve) => {
|
|
274
|
-
ctx.abortSignal.addEventListener('abort', () => {
|
|
275
|
-
ctx.log?.info('🛑 Shutting down EyeClaw SDK...')
|
|
276
|
-
client.disconnect()
|
|
277
|
-
clients.delete(ctx.accountId)
|
|
278
|
-
ctx.setStatus({ accountId: ctx.accountId, running: false, lastStopAt: Date.now() })
|
|
279
|
-
resolve()
|
|
280
|
-
})
|
|
281
|
-
})
|
|
282
|
-
} catch (error) {
|
|
283
|
-
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
284
|
-
ctx.log?.error(`Failed to connect to EyeClaw: ${errorMsg}`)
|
|
285
|
-
ctx.setStatus({
|
|
286
|
-
accountId: ctx.accountId,
|
|
287
|
-
running: false,
|
|
288
|
-
lastError: errorMsg,
|
|
289
|
-
lastStopAt: Date.now(),
|
|
290
|
-
})
|
|
291
|
-
throw error
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
}
|