@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.
@@ -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
- }