claw-subagent-service 0.0.176 → 0.0.179
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/package.json
CHANGED
|
@@ -78,12 +78,72 @@ class RongyunMessageHandler {
|
|
|
78
78
|
this.commandLock = false;
|
|
79
79
|
this.commandLockTimer = null;
|
|
80
80
|
this.messageSender = null;
|
|
81
|
+
this.serverAPI = null;
|
|
82
|
+
// 流式消息队列:确保片段串行发送
|
|
83
|
+
this._streamQueue = Promise.resolve();
|
|
84
|
+
// 存储流式消息的 RongCloud messageUID:streamId -> messageUID
|
|
85
|
+
this._streamMessageUIDs = new Map();
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
setMessageSender(messageSender) {
|
|
84
89
|
this.messageSender = messageSender;
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
setServerAPI(serverAPI) {
|
|
93
|
+
this.serverAPI = serverAPI;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 发送流式消息片段(直接调用融云 API)
|
|
98
|
+
* @param {string} fromUserId - 发送者ID
|
|
99
|
+
* @param {string} targetId - 目标用户ID
|
|
100
|
+
* @param {string} content - 消息内容
|
|
101
|
+
* @param {string} streamId - 流式消息ID
|
|
102
|
+
* @param {boolean} isFirstChunk - 是否首流
|
|
103
|
+
* @param {boolean} isLastChunk - 是否尾流
|
|
104
|
+
* @param {number} seq - 片段序号
|
|
105
|
+
*/
|
|
106
|
+
async _sendStreamChunk(fromUserId, targetId, content, streamId, isFirstChunk, isLastChunk, seq = 1) {
|
|
107
|
+
const contentPreview = typeof content === 'string' ? content.substring(0, 100) : JSON.stringify(content).substring(0, 100);
|
|
108
|
+
this.logInfo(`[RongyunMessageHandler] _sendStreamChunk: target=${targetId}, streamId=${streamId}, seq=${seq}, first=${isFirstChunk}, last=${isLastChunk}, content_len=${content?.length || 0}`);
|
|
109
|
+
|
|
110
|
+
if (!this.serverAPI) {
|
|
111
|
+
this.logWarn('[RongyunMessageHandler] _sendStreamChunk skipped: serverAPI not configured');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 使用队列确保流式消息片段串行发送,避免并发导致后端处理错乱
|
|
116
|
+
this._streamQueue = this._streamQueue.then(async () => {
|
|
117
|
+
try {
|
|
118
|
+
// 获取已存储的 RongCloud messageUID(首流响应返回的)
|
|
119
|
+
const messageUID = this._streamMessageUIDs.get(streamId);
|
|
120
|
+
|
|
121
|
+
const result = await this.serverAPI.sendStreamPrivate({
|
|
122
|
+
fromUserId,
|
|
123
|
+
toUserId: targetId,
|
|
124
|
+
content,
|
|
125
|
+
streamId,
|
|
126
|
+
isFirstChunk,
|
|
127
|
+
isLastChunk,
|
|
128
|
+
seq,
|
|
129
|
+
messageUID
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 首流时存储 RongCloud 返回的 messageUID
|
|
133
|
+
if (isFirstChunk && result?.messageUID) {
|
|
134
|
+
this._streamMessageUIDs.set(streamId, result.messageUID);
|
|
135
|
+
this.logInfo(`[RongyunMessageHandler] 首流 messageUID 已存储: ${result.messageUID}, streamId=${streamId}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.logInfo(`[RongyunMessageHandler] _sendStreamChunk 成功: seq=${seq}`);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
this.logWarn(`[RongyunMessageHandler] 发送流式消息失败: ${err.message}, seq=${seq}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await this._streamQueue;
|
|
145
|
+
}
|
|
146
|
+
|
|
87
147
|
logInfo(message) {
|
|
88
148
|
if (this.log?.info) {
|
|
89
149
|
this.log.info(message);
|
|
@@ -285,7 +345,7 @@ class RongyunMessageHandler {
|
|
|
285
345
|
const roomId = data.room_id;
|
|
286
346
|
const sessionId = data.gateway_session_id || data.session_id;
|
|
287
347
|
// 使用解析后的 content(聊天内容),如果没有则使用原始 content
|
|
288
|
-
|
|
348
|
+
let content = data.content || data._raw_content;
|
|
289
349
|
const requestId = data.request_id;
|
|
290
350
|
const sourceId = data.source_im_id;
|
|
291
351
|
|
|
@@ -301,12 +361,43 @@ class RongyunMessageHandler {
|
|
|
301
361
|
return;
|
|
302
362
|
}
|
|
303
363
|
|
|
364
|
+
// 语音消息:先进行语音识别
|
|
365
|
+
if (data.voiceUrl) {
|
|
366
|
+
const voiceText = await this._recognizeVoice(data.voiceUrl, data.voiceDuration);
|
|
367
|
+
if (voiceText !== null) {
|
|
368
|
+
content = `[语音转文字] ${voiceText}`;
|
|
369
|
+
} else {
|
|
370
|
+
content = `[语音消息,转文字失败] ${content}`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
304
374
|
let fullResponse = '';
|
|
375
|
+
let buffer = ''; // 用于流式发送的缓冲区
|
|
305
376
|
const chatTimeoutMs = (this.config.chatTimeout || 600) * 1000;
|
|
377
|
+
|
|
378
|
+
// 生成流式消息唯一ID
|
|
379
|
+
const streamId = `stream-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
380
|
+
let seq = 0;
|
|
381
|
+
let hasSentChunk = false;
|
|
382
|
+
const fromUserId = this.config.accountId || '';
|
|
306
383
|
|
|
307
384
|
try {
|
|
308
385
|
await forwardChatMessage(sessionId, content, async (delta) => {
|
|
309
386
|
fullResponse += delta;
|
|
387
|
+
buffer += delta;
|
|
388
|
+
|
|
389
|
+
// 当缓冲区达到一定大小或包含标点时,发送流式片段
|
|
390
|
+
// 使用 50 字符作为触发阈值(与 message-handler.js 保持一致)
|
|
391
|
+
if (buffer.length >= 50) {
|
|
392
|
+
seq += 1;
|
|
393
|
+
const chunkToSend = buffer;
|
|
394
|
+
buffer = ''; // 清空缓冲区
|
|
395
|
+
|
|
396
|
+
// 首流时发送首流标记
|
|
397
|
+
const isFirstChunk = seq === 1;
|
|
398
|
+
await this._sendStreamChunk(fromUserId, sourceId, chunkToSend, streamId, isFirstChunk, false, seq);
|
|
399
|
+
hasSentChunk = true;
|
|
400
|
+
}
|
|
310
401
|
}, (level, message) => {
|
|
311
402
|
if (level === 'ERROR') {
|
|
312
403
|
this.logError(`[CHAT-API] ${message}`);
|
|
@@ -317,21 +408,50 @@ class RongyunMessageHandler {
|
|
|
317
408
|
}
|
|
318
409
|
}, chatTimeoutMs);
|
|
319
410
|
|
|
411
|
+
// 发送剩余缓冲区内容
|
|
412
|
+
if (buffer.length > 0) {
|
|
413
|
+
seq += 1;
|
|
414
|
+
const isFirstChunk = seq === 1;
|
|
415
|
+
await this._sendStreamChunk(fromUserId, sourceId, buffer, streamId, isFirstChunk, false, seq);
|
|
416
|
+
hasSentChunk = true;
|
|
417
|
+
buffer = '';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 发送尾流标记
|
|
421
|
+
if (hasSentChunk) {
|
|
422
|
+
seq += 1;
|
|
423
|
+
await this._sendStreamChunk(fromUserId, sourceId, '', streamId, false, true, seq);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 同时发送完整的 command 消息作为历史记录(兼容旧前端)
|
|
320
427
|
await this.sendResponse(RongyunMessageTypeEnum.CHAT_MESSAGE, {
|
|
321
428
|
status: 'success',
|
|
322
429
|
message: 'Response received',
|
|
323
430
|
content: fullResponse,
|
|
324
431
|
metadata: {}
|
|
325
432
|
}, requestId, sourceId);
|
|
433
|
+
|
|
434
|
+
// 清理已存储的 messageUID
|
|
435
|
+
this._streamMessageUIDs.delete(streamId);
|
|
326
436
|
} catch (e) {
|
|
327
437
|
const msg = e instanceof Error ? e.message : String(e);
|
|
328
438
|
this.logError(`聊天消息处理异常: ${msg}`);
|
|
439
|
+
|
|
440
|
+
// 如果已经开始流式发送,发送错误标记
|
|
441
|
+
if (hasSentChunk) {
|
|
442
|
+
seq += 1;
|
|
443
|
+
await this._sendStreamChunk(fromUserId, sourceId, `[错误] 转发失败: ${msg}`, streamId, false, true, seq);
|
|
444
|
+
}
|
|
445
|
+
|
|
329
446
|
await this.sendResponse(RongyunMessageTypeEnum.CHAT_MESSAGE, {
|
|
330
447
|
status: 'error',
|
|
331
448
|
message: msg,
|
|
332
449
|
content: `[错误] 转发失败: ${msg}`,
|
|
333
450
|
metadata: {}
|
|
334
451
|
}, requestId, sourceId);
|
|
452
|
+
|
|
453
|
+
// 清理已存储的 messageUID
|
|
454
|
+
this._streamMessageUIDs.delete(streamId);
|
|
335
455
|
}
|
|
336
456
|
}
|
|
337
457
|
|
|
@@ -766,6 +886,53 @@ class RongyunMessageHandler {
|
|
|
766
886
|
this.logError(`发送响应失败: ${msg}`);
|
|
767
887
|
}
|
|
768
888
|
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* 语音识别:调用后端百度语音 API 将语音转为文字
|
|
892
|
+
*/
|
|
893
|
+
async _recognizeVoice(voiceUrl, voiceDuration) {
|
|
894
|
+
try {
|
|
895
|
+
if (!voiceUrl) {
|
|
896
|
+
this.logWarn('[_recognizeVoice] 语音 URL 为空,跳过识别');
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 从 URL 提取扩展名并映射为百度支持的格式
|
|
901
|
+
const urlPath = voiceUrl.split('?')[0];
|
|
902
|
+
const ext = urlPath.split('.').pop()?.toLowerCase() || '';
|
|
903
|
+
const fmtMap = { aac: 'm4a', ogg: 'mp3', oga: 'mp3', opus: 'mp3' };
|
|
904
|
+
let format = fmtMap[ext] || ext;
|
|
905
|
+
if (!['pcm', 'wav', 'amr', 'm4a', 'mp3'].includes(format)) {
|
|
906
|
+
format = 'mp3';
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 采样率修正:amr 强制 8000,其余兜底 16000
|
|
910
|
+
let sampleRate = 16000;
|
|
911
|
+
if (format === 'amr') sampleRate = 8000;
|
|
912
|
+
|
|
913
|
+
const axios = require('axios');
|
|
914
|
+
const apiUrl = `${this.config.apiBaseUrl}/im/api/voice/recognize`;
|
|
915
|
+
this.logInfo(`[_recognizeVoice] 调用语音识别 API: ${apiUrl}, format=${format}, sampleRate=${sampleRate}`);
|
|
916
|
+
|
|
917
|
+
const response = await axios.post(apiUrl, {
|
|
918
|
+
audioUrl: voiceUrl,
|
|
919
|
+
format,
|
|
920
|
+
sampleRate,
|
|
921
|
+
}, { timeout: 30000 });
|
|
922
|
+
|
|
923
|
+
if (response.data?.code === 200 && response.data?.data?.text !== undefined) {
|
|
924
|
+
const text = response.data.data.text;
|
|
925
|
+
this.logInfo(`[_recognizeVoice] 语音识别成功: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
|
926
|
+
return text;
|
|
927
|
+
} else {
|
|
928
|
+
this.logWarn(`[_recognizeVoice] 语音识别失败: ${JSON.stringify(response.data)}`);
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
} catch (err) {
|
|
932
|
+
this.logError(`[_recognizeVoice] 语音识别异常: ${err.message}`);
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
769
936
|
}
|
|
770
937
|
|
|
771
938
|
module.exports = {
|
|
@@ -176,9 +176,9 @@ class RongyunMessageSender {
|
|
|
176
176
|
|
|
177
177
|
// 优先使用自定义消息类型发送 P2P 消息
|
|
178
178
|
let result;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
179
|
+
// 客服消息和聊天消息都将业务内容放在顶层,方便前端解析
|
|
180
|
+
if (this.rongcloudClient.ServiceChatMessage && (msgType.includes('service') || msgType === 'chat_message')) {
|
|
181
|
+
// 对于客服/聊天消息,直接将业务内容放在顶层,方便前端解析
|
|
182
182
|
const serviceChatPayload = {
|
|
183
183
|
msg_type: msgType,
|
|
184
184
|
...content, // 展开业务内容(status, content, sessionId, userId 等)
|
|
@@ -245,6 +245,65 @@ class RongyunMessageSender {
|
|
|
245
245
|
requestId
|
|
246
246
|
);
|
|
247
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 发送流式消息片段(P2P)
|
|
251
|
+
* 使用融云服务端API发送 RC:StreamMsg
|
|
252
|
+
* @param {Object} options - 流式消息选项
|
|
253
|
+
* @param {string} options.targetId - 目标用户ID
|
|
254
|
+
* @param {string} options.content - 消息片段内容
|
|
255
|
+
* @param {string} options.streamId - 流式消息ID
|
|
256
|
+
* @param {number} options.seq - 片段序号
|
|
257
|
+
* @param {boolean} options.isFirstChunk - 是否首流
|
|
258
|
+
* @param {boolean} options.isLastChunk - 是否尾流
|
|
259
|
+
* @param {string} options.messageUID - 首流返回的messageUID(后续流使用)
|
|
260
|
+
* @returns {Promise<Object>} 发送结果
|
|
261
|
+
*/
|
|
262
|
+
async sendStreamToTarget({
|
|
263
|
+
targetId,
|
|
264
|
+
content,
|
|
265
|
+
streamId,
|
|
266
|
+
seq = 1,
|
|
267
|
+
isFirstChunk = false,
|
|
268
|
+
isLastChunk = false,
|
|
269
|
+
messageUID = null
|
|
270
|
+
}) {
|
|
271
|
+
// 需要 serverAPI 支持
|
|
272
|
+
if (!this.serverAPI) {
|
|
273
|
+
this.log?.error('[RongyunMessageSender] serverAPI 未设置,无法发送流式消息');
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const fromUserId = this.config.accountId || '';
|
|
279
|
+
|
|
280
|
+
const result = await this.serverAPI.sendStreamPrivate({
|
|
281
|
+
fromUserId,
|
|
282
|
+
toUserId: targetId,
|
|
283
|
+
content,
|
|
284
|
+
streamId,
|
|
285
|
+
isFirstChunk,
|
|
286
|
+
isLastChunk,
|
|
287
|
+
seq,
|
|
288
|
+
streamType: 'text',
|
|
289
|
+
messageUID
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
this.log?.info(`[RongyunMessageSender] 流式消息已发送: seq=${seq}, first=${isFirstChunk}, last=${isLastChunk}`);
|
|
293
|
+
return result;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
this.log?.error(`[RongyunMessageSender] 发送流式消息失败: ${err.message}`);
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 设置 serverAPI(用于发送流式消息)
|
|
302
|
+
* @param {RongCloudServerAPI} serverAPI
|
|
303
|
+
*/
|
|
304
|
+
setServerAPI(serverAPI) {
|
|
305
|
+
this.serverAPI = serverAPI;
|
|
306
|
+
}
|
|
248
307
|
}
|
|
249
308
|
|
|
250
309
|
module.exports = {
|
package/service/worker.js
CHANGED
|
@@ -8,6 +8,8 @@ const { createLogger } = require('./logger');
|
|
|
8
8
|
const { RongCloudClient, MessageHandler, ensurePluginsAllow } = require('./rongcloud');
|
|
9
9
|
const { RongyunMessageHandler } = require('./modules/rongyun-message-handler');
|
|
10
10
|
const { RongyunMessageSender } = require('./modules/rongyun-message-sender');
|
|
11
|
+
const { RongCloudServerAPI } = require('./rongcloud/rongcloud-server-api');
|
|
12
|
+
const { SystemConfigManager } = require('./modules/system-config');
|
|
11
13
|
const { HeartbeatManager, DashboardReporter } = require('./modules/heartbeat-dashboard');
|
|
12
14
|
const { getOpenClawStatus } = require('./modules/port-checker');
|
|
13
15
|
const { getMacAddress } = require('./modules/mac-address');
|
|
@@ -340,12 +342,20 @@ async function initRongCloud() {
|
|
|
340
342
|
|
|
341
343
|
rongcloudClient = new RongCloudClient(rongcloudConfig, log);
|
|
342
344
|
|
|
345
|
+
// 创建系统配置管理器(用于融云服务端API)
|
|
346
|
+
const configManager = new SystemConfigManager(rongcloudConfig, log);
|
|
347
|
+
|
|
348
|
+
// 创建融云服务端API客户端(用于发送流式消息)
|
|
349
|
+
const serverAPI = new RongCloudServerAPI(configManager, log);
|
|
350
|
+
|
|
343
351
|
// 创建消息发送器
|
|
344
352
|
const messageSender = new RongyunMessageSender(rongcloudClient, rongcloudConfig, log);
|
|
353
|
+
messageSender.setServerAPI(serverAPI); // 注入 serverAPI,支持发送流式消息
|
|
345
354
|
|
|
346
355
|
// 创建新的融云消息处理器(与桌面客户端对齐)
|
|
347
356
|
const rongyunMessageHandler = new RongyunMessageHandler(rongcloudClient, rongcloudConfig, log);
|
|
348
357
|
rongyunMessageHandler.setMessageSender(messageSender);
|
|
358
|
+
rongyunMessageHandler.setServerAPI(serverAPI); // 注入 serverAPI
|
|
349
359
|
|
|
350
360
|
messageHandler = new MessageHandler(
|
|
351
361
|
rongcloudConfig,
|