lightclawbot 1.2.8 → 1.2.10
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/dist/src/config.js +12 -0
- package/dist/src/gateway.js +43 -7
- package/dist/src/history/message-parser.js +22 -0
- package/dist/src/outbound.js +53 -2
- package/dist/src/socket/native-socket.js +186 -23
- package/dist/src/streaming/stream-reply-sink.js +98 -37
- package/dist/src/tools/upload-tool.js +1 -1
- package/dist/src/utils/common.js +5 -1
- package/package.json +1 -1
package/dist/src/config.js
CHANGED
|
@@ -42,6 +42,18 @@ export const SOCKET_PATH = '/ws/agent';
|
|
|
42
42
|
export const SOCKET_RECONNECTION_DELAY = 1000;
|
|
43
43
|
export const SOCKET_RECONNECTION_DELAY_MAX = 30000;
|
|
44
44
|
export const SOCKET_RECONNECTION_ATTEMPTS = Infinity;
|
|
45
|
+
/** 首次重连随机抖动上限(ms),用于打散服务发布后的集中重连 */
|
|
46
|
+
export const SOCKET_RECONNECTION_INITIAL_JITTER_MAX = 10_000;
|
|
47
|
+
/** 后续指数退避随机抖动比例,0.5 表示在 50%~150% 区间内随机 */
|
|
48
|
+
export const SOCKET_RECONNECTION_JITTER_RATIO = 0.5;
|
|
49
|
+
/** WebSocket 协议级 ping 间隔(ms),需小于 ai-server 60s 在线 TTL */
|
|
50
|
+
export const WS_PING_INTERVAL_MS = 20_000;
|
|
51
|
+
/** WebSocket heartbeat 首次 ping 随机抖动上限(ms),用于避免重连后 ping 周期对齐 */
|
|
52
|
+
export const WS_HEARTBEAT_INITIAL_JITTER_MAX = WS_PING_INTERVAL_MS;
|
|
53
|
+
/** WebSocket pong 等待超时时间(ms) */
|
|
54
|
+
export const WS_PONG_TIMEOUT_MS = 10_000;
|
|
55
|
+
/** 连续 pong 超时次数阈值,达到后判定连接半开并主动重连 */
|
|
56
|
+
export const WS_MAX_MISSED_PONG = 2;
|
|
45
57
|
// ============================================================
|
|
46
58
|
// HTTP Header 配置
|
|
47
59
|
// ============================================================
|
package/dist/src/gateway.js
CHANGED
|
@@ -77,6 +77,30 @@ async function resolveBotClientId(apiKey, log) {
|
|
|
77
77
|
ticket: result?.data?.ticket || '',
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* 获取 Bot clientId 与 ticket,失败或 ticket 为空时额外重试一次。
|
|
82
|
+
* @param apiKey - 当前账户的 apiKey
|
|
83
|
+
* @param log - 可选日志对象
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveBotClientIdWithRetry(apiKey, log) {
|
|
86
|
+
let lastError;
|
|
87
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
const result = await resolveBotClientId(apiKey, log);
|
|
90
|
+
if (result.ticket || attempt === 1)
|
|
91
|
+
return result;
|
|
92
|
+
lastError = new Error(`[${CHANNEL_KEY}] Missing ticket in ${API_PATH_USER_CURRENT} response`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
lastError = error;
|
|
96
|
+
}
|
|
97
|
+
if (attempt === 0) {
|
|
98
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
99
|
+
log?.warn(`[${CHANNEL_KEY}] Failed to resolve ticket, retrying once: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
103
|
+
}
|
|
80
104
|
// ============================================================
|
|
81
105
|
// 核心:启动 Gateway
|
|
82
106
|
// ============================================================
|
|
@@ -112,7 +136,7 @@ export async function startGateway(ctx) {
|
|
|
112
136
|
// 通过 HTTP 接口获取 Bot 的 clientId(botClientId)
|
|
113
137
|
// 新配置格式:accountId 即为 uin,apiKey 与 uin 一一对应,直接构建映射表
|
|
114
138
|
log?.info(`${prefix} Resolving botClientId for account ${account.accountId}...`);
|
|
115
|
-
const { botClientId, ticket } = await
|
|
139
|
+
const { botClientId, ticket: initialTicket } = await resolveBotClientIdWithRetry(account.apiKey, log);
|
|
116
140
|
// 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作)
|
|
117
141
|
// 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题
|
|
118
142
|
buildGlobalApiKeyMap(cfg);
|
|
@@ -306,14 +330,26 @@ export async function startGateway(ctx) {
|
|
|
306
330
|
// 将 cleanup 注册到全局 Map,供防重入检查和外部销毁使用
|
|
307
331
|
activeGateways.set(account.accountId, cleanup);
|
|
308
332
|
// ---- WebSocket 连接 ----
|
|
309
|
-
// 采用 ticket 作为 query 参数进行认证,不再使用 header
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
333
|
+
// 采用 ticket 作为 query 参数进行认证,不再使用 header 方式。
|
|
334
|
+
// ai-server ticket 是一次性凭证,因此每次建连/重连前都必须重新获取 fresh ticket。
|
|
335
|
+
// 首次连接复用启动阶段已获取的 ticket;后续重连再重新请求,避免浪费未消费 ticket。
|
|
336
|
+
let nextTicket = initialTicket;
|
|
337
|
+
const buildSocketQuery = async () => {
|
|
338
|
+
const freshTicket = nextTicket || (await resolveBotClientIdWithRetry(account.apiKey, log)).ticket;
|
|
339
|
+
nextTicket = '';
|
|
340
|
+
if (!freshTicket) {
|
|
341
|
+
throw new Error(`[${CHANNEL_KEY}] Missing ticket in ${API_PATH_USER_CURRENT} response`);
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
ticket: freshTicket,
|
|
345
|
+
enableMultiLogin: false,
|
|
346
|
+
};
|
|
347
|
+
};
|
|
313
348
|
// 使用原生 WebSocket 封装层建立连接
|
|
314
349
|
const socket = new NativeSocketClient(WS_URL, {
|
|
315
|
-
//
|
|
316
|
-
path:
|
|
350
|
+
// 认证:每次连接前由动态 query 获取 fresh ticket,避免自动重连复用旧 ticket 触发 401。
|
|
351
|
+
path: SOCKET_PATH,
|
|
352
|
+
query: buildSocketQuery,
|
|
317
353
|
logPrefix: `[${CHANNEL_KEY}:${account.accountId}:NativeSocket]`,
|
|
318
354
|
});
|
|
319
355
|
currentSocket = socket;
|
|
@@ -210,6 +210,28 @@ export function normalizeMessage(msg) {
|
|
|
210
210
|
const allFiles = [...(textFiles.length > 0 ? textFiles : []), ...(contentFiles ?? [])];
|
|
211
211
|
if (allFiles.length > 0)
|
|
212
212
|
files = deduplicateFiles(allFiles);
|
|
213
|
+
// 修复:当用户消息有 MediaPath/MediaPaths(OpenClaw 存在附件文件)
|
|
214
|
+
// 但 files 里没有 uri 时,补上 localfile:// 格式的 uri
|
|
215
|
+
if (!files || files.some((f) => !f.uri)) {
|
|
216
|
+
const mediaPaths = Array.isArray(msg.MediaPaths)
|
|
217
|
+
? msg.MediaPaths
|
|
218
|
+
: (typeof msg.MediaPath === "string" ? [msg.MediaPath] : []);
|
|
219
|
+
if (mediaPaths.length > 0) {
|
|
220
|
+
const existingFiles = files ?? [];
|
|
221
|
+
const needInit = existingFiles.length === 0;
|
|
222
|
+
const updatedFiles = needInit
|
|
223
|
+
? mediaPaths.map((p, i) => ({
|
|
224
|
+
name: existingFiles[i]?.name ?? p.split("/").pop() ?? `file-${i}`,
|
|
225
|
+
mimeType: existingFiles[i]?.mimeType,
|
|
226
|
+
uri: `localfile://${p}`,
|
|
227
|
+
}))
|
|
228
|
+
: existingFiles.map((f, i) => ({
|
|
229
|
+
...f,
|
|
230
|
+
uri: f.uri ?? (mediaPaths[i] ? `localfile://${mediaPaths[i]}` : undefined),
|
|
231
|
+
}));
|
|
232
|
+
files = updatedFiles;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
213
235
|
}
|
|
214
236
|
else {
|
|
215
237
|
files = extractContentFiles(msg.content) || undefined;
|
package/dist/src/outbound.js
CHANGED
|
@@ -84,17 +84,52 @@ function sendViaSocket(accountId, target, text, replyToId) {
|
|
|
84
84
|
// 从 socket-registry 中获取当前账户的 socket entry
|
|
85
85
|
const entry = getSocket(resolvedAccountId);
|
|
86
86
|
const msgId = generateOutboundMsgId();
|
|
87
|
-
|
|
87
|
+
const fromId = entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '';
|
|
88
|
+
// 构造标准消息体,格式与 gateway 入站消息保持一致。
|
|
89
|
+
//
|
|
90
|
+
// 协议关键字段说明:
|
|
91
|
+
// - kind: 'stream_chunk'
|
|
92
|
+
// outbound 由 LLM 工具调用结果(如 message 工具发图/发文)触发,
|
|
93
|
+
// 归属于"流式回复信号链"的一部分,前端依赖 kind 推导消息状态
|
|
94
|
+
// (stream_chunk → 'updating')。若缺省,前端 statusMap 会兜底到
|
|
95
|
+
// typing_stop=='success',导致内容被独立成一条"已完成回复"气泡,
|
|
96
|
+
// 与上一段 LLM 文本(已 typing_stop)错位拼接。详见 stream-reply-sink。
|
|
97
|
+
// - extra.chatId: ''
|
|
98
|
+
// 协议规定所有 EVENT_MESSAGE_PRIVATE 必须携带 extra.chatId(多会话分桶)。
|
|
99
|
+
// outbound 触发时通常无明确 chatId 上下文(cron / message 工具 / REST 等),
|
|
100
|
+
// 按协议约定填空字符串落到默认会话。
|
|
101
|
+
// - idempotencyKey: 由 ReliableEmitter 在 emitWithAck 内部注入,此处无需显式构造。
|
|
88
102
|
const message = {
|
|
89
103
|
msgId,
|
|
90
104
|
// 优先使用 entry 中缓存的 botClientId,entry 不存在时从 registry 直接查询
|
|
91
|
-
from:
|
|
105
|
+
from: fromId,
|
|
92
106
|
to: target,
|
|
93
107
|
content: text,
|
|
94
108
|
timestamp: Date.now(),
|
|
109
|
+
kind: 'stream_chunk',
|
|
95
110
|
// undefined 表示非回复消息,避免发送多余字段
|
|
96
111
|
replyToMsgId: replyToId ?? undefined,
|
|
112
|
+
extra: { chatId: '' },
|
|
97
113
|
};
|
|
114
|
+
// 自闭合 typing_stop 帧:与 stream_chunk 共用同一个 msgId / chatId,
|
|
115
|
+
// content 必须为空字符串(前端 addOrUpdateMessage 会按 oldContent + content 拼接,
|
|
116
|
+
// 非空 content 会让 caption 文本被复制一遍)。
|
|
117
|
+
//
|
|
118
|
+
// 设计理由:
|
|
119
|
+
// outbound 通道(如 message 工具调用结果)由 OpenClaw 框架以「独立 messageId」投递,
|
|
120
|
+
// 不会与上一段 LLM 流式回复合并。仅发 stream_chunk 而无 typing_stop,
|
|
121
|
+
// 会导致前端这条独立气泡永远停留在 'updating' 状态(loading 转圈)。
|
|
122
|
+
// 故每条 outbound 消息都自带一帧 typing_stop 收尾,让该气泡正常进入 'success'。
|
|
123
|
+
const buildClosingFrame = () => ({
|
|
124
|
+
msgId,
|
|
125
|
+
from: fromId,
|
|
126
|
+
to: target,
|
|
127
|
+
content: '',
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
kind: 'typing_stop',
|
|
130
|
+
replyToMsgId: replyToId ?? undefined,
|
|
131
|
+
extra: { chatId: '' },
|
|
132
|
+
});
|
|
98
133
|
if (entry) {
|
|
99
134
|
// 策略 1:Socket 已连接,优先走可靠发送(emitWithAck + 自动重试)
|
|
100
135
|
const emitter = getReliableEmitter(resolvedAccountId);
|
|
@@ -105,12 +140,24 @@ function sendViaSocket(accountId, target, text, replyToId) {
|
|
|
105
140
|
logger.error(`[${CHANNEL_KEY}] outbound delivery failed after retries: msgId=${msgId}`);
|
|
106
141
|
}
|
|
107
142
|
});
|
|
143
|
+
// 紧跟一帧 typing_stop 闭合该 msgId 的流式状态。
|
|
144
|
+
// ReliableEmitter 内部按 emit 顺序生成 idempotencyKey(字典序 == emit 顺序),
|
|
145
|
+
// 即便网络层 batch 抖动导致 packet 乱序到达,前端也能按字典序合并出
|
|
146
|
+
// 「stream_chunk 在前 → typing_stop 在后」的稳定终态。
|
|
147
|
+
const closingFrame = buildClosingFrame();
|
|
148
|
+
emitter.emitWithAck(EVENT_MESSAGE_PRIVATE, closingFrame, msgId).then((ok) => {
|
|
149
|
+
if (!ok) {
|
|
150
|
+
logger.warn(`[${CHANNEL_KEY}] outbound closing frame ack timeout: msgId=${msgId}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
108
153
|
logger.info(`[${CHANNEL_KEY}] outbound sent via reliable WS: to=${target} msgId=${msgId}`);
|
|
109
154
|
return msgId;
|
|
110
155
|
}
|
|
111
156
|
// 策略 2:可靠发送器不可用,直接 emit(兜底,正常情况不应走到此分支)
|
|
112
157
|
try {
|
|
113
158
|
entry.socket.emit(EVENT_MESSAGE_PRIVATE, message);
|
|
159
|
+
// 同样补一帧 typing_stop;裸 emit 无 ack,无法保证送达,best-effort 即可。
|
|
160
|
+
entry.socket.emit(EVENT_MESSAGE_PRIVATE, buildClosingFrame());
|
|
114
161
|
logger.info(`[${CHANNEL_KEY}] outbound sent via WS (fallback): to=${target} msgId=${msgId}`);
|
|
115
162
|
return msgId;
|
|
116
163
|
}
|
|
@@ -125,6 +172,10 @@ function sendViaSocket(accountId, target, text, replyToId) {
|
|
|
125
172
|
if (hasEntry(resolvedAccountId)) {
|
|
126
173
|
const buffered = bufferMessage(resolvedAccountId, message);
|
|
127
174
|
if (buffered) {
|
|
175
|
+
// 顺序 push 一帧 typing_stop,flush 时随业务消息一起按 FIFO 发出。
|
|
176
|
+
// bufferMessage 失败(队列满)则跳过闭合帧 —— 主消息都没缓冲住,
|
|
177
|
+
// 闭合帧也没意义;前端会因永远收不到 stream_chunk 而不展示这条气泡。
|
|
178
|
+
bufferMessage(resolvedAccountId, buildClosingFrame());
|
|
128
179
|
logger.info(`[${CHANNEL_KEY}] outbound buffered (WS reconnecting): to=${target} msgId=${msgId}`);
|
|
129
180
|
return msgId;
|
|
130
181
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* ACK :{ event: 'message:ack'; data: { msgId: string; relatedMsgId: string; ... } }
|
|
14
14
|
*/
|
|
15
15
|
import WebSocket from 'ws';
|
|
16
|
-
import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS } from '../config.js';
|
|
16
|
+
import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS, SOCKET_RECONNECTION_INITIAL_JITTER_MAX, SOCKET_RECONNECTION_JITTER_RATIO, WS_PING_INTERVAL_MS, WS_HEARTBEAT_INITIAL_JITTER_MAX, WS_PONG_TIMEOUT_MS, WS_MAX_MISSED_PONG, } from '../config.js';
|
|
17
17
|
import { getModuleLogger } from '../utils/logger.js';
|
|
18
18
|
/** 模块级日志器 */
|
|
19
19
|
const moduleLog = getModuleLogger('socket.native-socket');
|
|
@@ -25,8 +25,11 @@ const moduleLog = getModuleLogger('socket.native-socket');
|
|
|
25
25
|
*
|
|
26
26
|
* 使用方式:
|
|
27
27
|
* ```ts
|
|
28
|
-
* //
|
|
29
|
-
* const socket = new NativeSocketClient(
|
|
28
|
+
* // path 仅表达路径;ticket 等鉴权参数通过 query 传入,可用异步函数在重连前刷新
|
|
29
|
+
* const socket = new NativeSocketClient(WS_URL, {
|
|
30
|
+
* path: '/ws/agent',
|
|
31
|
+
* query: async () => ({ ticket: await fetchTicket() }),
|
|
32
|
+
* });
|
|
30
33
|
* socket.on('connect', () => { ... });
|
|
31
34
|
* socket.on('message:private', (data) => { ... });
|
|
32
35
|
* socket.timeout(5000).emit('message:private', data, (err) => { ... });
|
|
@@ -42,6 +45,8 @@ export class NativeSocketClient {
|
|
|
42
45
|
_connected = false;
|
|
43
46
|
_id = undefined;
|
|
44
47
|
_ws = null;
|
|
48
|
+
/** 是否正在构建或发起连接,避免异步 query 获取期间重复建连 */
|
|
49
|
+
_connecting = false;
|
|
45
50
|
/** 是否为主动断开(主动断开时不触发自动重连) */
|
|
46
51
|
_manualDisconnect = false;
|
|
47
52
|
// ----------------------------------------------------------
|
|
@@ -50,6 +55,14 @@ export class NativeSocketClient {
|
|
|
50
55
|
_reconnectAttempts = 0;
|
|
51
56
|
_reconnectTimer = null;
|
|
52
57
|
// ----------------------------------------------------------
|
|
58
|
+
// WebSocket 协议级心跳状态
|
|
59
|
+
// ----------------------------------------------------------
|
|
60
|
+
_heartbeatInitialTimer = null;
|
|
61
|
+
_pingTimer = null;
|
|
62
|
+
_pongTimeoutTimer = null;
|
|
63
|
+
_lastPingAt = 0;
|
|
64
|
+
_missedPongCount = 0;
|
|
65
|
+
// ----------------------------------------------------------
|
|
53
66
|
// 事件监听器映射表
|
|
54
67
|
// ----------------------------------------------------------
|
|
55
68
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -67,16 +80,17 @@ export class NativeSocketClient {
|
|
|
67
80
|
/** 日志前缀,便于在多实例场景下区分日志来源。 */
|
|
68
81
|
logPrefix;
|
|
69
82
|
/**
|
|
70
|
-
* @param url WebSocket 服务端地址(ws:// 或 wss
|
|
83
|
+
* @param url WebSocket 服务端地址(ws:// 或 wss://)
|
|
71
84
|
* @param options 连接选项
|
|
72
|
-
* - path: 连接路径(如 /
|
|
85
|
+
* - path: 连接路径(如 /ws/agent),仅表达路径语义
|
|
86
|
+
* - query: 静态或动态查询参数;ticket 等鉴权参数应放在这里
|
|
73
87
|
* - logPrefix: 日志前缀(可选),默认 `[NativeSocket]`
|
|
74
88
|
*/
|
|
75
89
|
constructor(url, options = {}) {
|
|
76
90
|
this.url = url;
|
|
77
91
|
this.options = options;
|
|
78
92
|
this.logPrefix = options.logPrefix ?? '[NativeSocket]';
|
|
79
|
-
this._connect();
|
|
93
|
+
void this._connect();
|
|
80
94
|
}
|
|
81
95
|
// ----------------------------------------------------------
|
|
82
96
|
// NativeSocket 接口实现
|
|
@@ -184,38 +198,63 @@ export class NativeSocketClient {
|
|
|
184
198
|
connect() {
|
|
185
199
|
// 手动触发重连(用于服务端主动断开后的手动重连场景)
|
|
186
200
|
// 若当前已有活跃连接(CONNECTING 或 OPEN),直接返回,避免连接泄漏
|
|
187
|
-
if (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN)) {
|
|
188
|
-
this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws
|
|
201
|
+
if (this._connecting || (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN))) {
|
|
202
|
+
this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws?.readyState === WebSocket.OPEN ? 'OPEN' : 'CONNECTING'}`);
|
|
189
203
|
return this;
|
|
190
204
|
}
|
|
191
205
|
this.log.info(`${this.logPrefix} connect() called, manually reconnecting`);
|
|
192
206
|
this._manualDisconnect = false;
|
|
193
207
|
this._reconnectAttempts = 0;
|
|
194
208
|
this._clearReconnectTimer();
|
|
195
|
-
this._connect();
|
|
209
|
+
void this._connect();
|
|
196
210
|
return this;
|
|
197
211
|
}
|
|
198
212
|
// ----------------------------------------------------------
|
|
199
213
|
// 内部:建立 WebSocket 连接
|
|
200
214
|
// ----------------------------------------------------------
|
|
201
|
-
_connect() {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
215
|
+
async _connect() {
|
|
216
|
+
if (this._connecting || (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN))) {
|
|
217
|
+
this.log.debug?.(`${this.logPrefix} _connect() skipped, connection already in progress or open`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this._connecting = true;
|
|
221
|
+
let fullUrl;
|
|
222
|
+
try {
|
|
223
|
+
// 构建完整 URL(仅拼接 path,认证通过 URL query 参数完成,无需 headers)
|
|
224
|
+
fullUrl = await this._buildUrl();
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
this._connecting = false;
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
this.log.error(`${this.logPrefix} Failed to build WebSocket URL: ${message}`);
|
|
230
|
+
this._dispatch('connect_error', err instanceof Error ? err : new Error(message));
|
|
231
|
+
if (!this._manualDisconnect) {
|
|
232
|
+
this._scheduleReconnect();
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.log.info(`${this.logPrefix} Connecting to ${this._maskUrl(fullUrl)}`);
|
|
205
237
|
try {
|
|
206
238
|
const ws = new WebSocket(fullUrl);
|
|
207
239
|
this._ws = ws;
|
|
208
240
|
ws.onopen = () => {
|
|
241
|
+
this._connecting = false;
|
|
209
242
|
this._reconnectAttempts = 0;
|
|
210
243
|
this._connected = true;
|
|
211
244
|
this.log.info(`${this.logPrefix} WebSocket opened`);
|
|
245
|
+
this._startHeartbeat();
|
|
212
246
|
this._dispatch('connect');
|
|
213
247
|
};
|
|
214
248
|
ws.onmessage = (event) => {
|
|
215
249
|
this._handleMessage(event.data);
|
|
216
250
|
};
|
|
251
|
+
ws.on('pong', () => {
|
|
252
|
+
this._handleHeartbeatPong();
|
|
253
|
+
});
|
|
217
254
|
ws.onclose = (event) => {
|
|
218
255
|
const wasConnected = this._connected;
|
|
256
|
+
this._stopHeartbeat();
|
|
257
|
+
this._connecting = false;
|
|
219
258
|
this._connected = false;
|
|
220
259
|
this._id = undefined;
|
|
221
260
|
this._ws = null;
|
|
@@ -238,6 +277,7 @@ export class NativeSocketClient {
|
|
|
238
277
|
};
|
|
239
278
|
}
|
|
240
279
|
catch (err) {
|
|
280
|
+
this._connecting = false;
|
|
241
281
|
const message = err instanceof Error ? err.message : String(err);
|
|
242
282
|
this.log.error(`${this.logPrefix} Failed to construct WebSocket: ${message}`);
|
|
243
283
|
this._dispatch('connect_error', err instanceof Error ? err : new Error(String(err)));
|
|
@@ -314,8 +354,103 @@ export class NativeSocketClient {
|
|
|
314
354
|
}
|
|
315
355
|
}
|
|
316
356
|
// ----------------------------------------------------------
|
|
357
|
+
// 内部:WebSocket 协议级心跳
|
|
358
|
+
// ----------------------------------------------------------
|
|
359
|
+
_startHeartbeat() {
|
|
360
|
+
this._stopHeartbeat();
|
|
361
|
+
this._missedPongCount = 0;
|
|
362
|
+
const initialDelay = this._randomDelay(0, WS_HEARTBEAT_INITIAL_JITTER_MAX);
|
|
363
|
+
this.log.info(`${this.logPrefix} WebSocket heartbeat started (initialDelay=${initialDelay}ms, interval=${WS_PING_INTERVAL_MS}ms, timeout=${WS_PONG_TIMEOUT_MS}ms, maxMissed=${WS_MAX_MISSED_PONG})`);
|
|
364
|
+
this._heartbeatInitialTimer = setTimeout(() => {
|
|
365
|
+
this._heartbeatInitialTimer = null;
|
|
366
|
+
this._sendHeartbeatPing();
|
|
367
|
+
this._pingTimer = setInterval(() => {
|
|
368
|
+
this._sendHeartbeatPing();
|
|
369
|
+
}, WS_PING_INTERVAL_MS);
|
|
370
|
+
}, initialDelay);
|
|
371
|
+
}
|
|
372
|
+
_stopHeartbeat() {
|
|
373
|
+
if (this._heartbeatInitialTimer !== null) {
|
|
374
|
+
clearTimeout(this._heartbeatInitialTimer);
|
|
375
|
+
this._heartbeatInitialTimer = null;
|
|
376
|
+
}
|
|
377
|
+
if (this._pingTimer !== null) {
|
|
378
|
+
clearInterval(this._pingTimer);
|
|
379
|
+
this._pingTimer = null;
|
|
380
|
+
}
|
|
381
|
+
if (this._pongTimeoutTimer !== null) {
|
|
382
|
+
clearTimeout(this._pongTimeoutTimer);
|
|
383
|
+
this._pongTimeoutTimer = null;
|
|
384
|
+
}
|
|
385
|
+
this._lastPingAt = 0;
|
|
386
|
+
this._missedPongCount = 0;
|
|
387
|
+
}
|
|
388
|
+
_sendHeartbeatPing() {
|
|
389
|
+
if (!this._ws || this._ws.readyState !== WebSocket.OPEN)
|
|
390
|
+
return;
|
|
391
|
+
if (this._pongTimeoutTimer !== null) {
|
|
392
|
+
clearTimeout(this._pongTimeoutTimer);
|
|
393
|
+
this._pongTimeoutTimer = null;
|
|
394
|
+
}
|
|
395
|
+
this._lastPingAt = Date.now();
|
|
396
|
+
try {
|
|
397
|
+
this._ws.ping();
|
|
398
|
+
this.log.debug?.(`${this.logPrefix} WebSocket ping sent`);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
402
|
+
this._terminateStaleConnection(`ping failed: ${message}`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this._pongTimeoutTimer = setTimeout(() => {
|
|
406
|
+
this._pongTimeoutTimer = null;
|
|
407
|
+
this._missedPongCount++;
|
|
408
|
+
this.log.warn(`${this.logPrefix} WebSocket pong timeout (missed=${this._missedPongCount}/${WS_MAX_MISSED_PONG})`);
|
|
409
|
+
if (this._missedPongCount >= WS_MAX_MISSED_PONG) {
|
|
410
|
+
this._terminateStaleConnection('pong timeout');
|
|
411
|
+
}
|
|
412
|
+
}, WS_PONG_TIMEOUT_MS);
|
|
413
|
+
}
|
|
414
|
+
_handleHeartbeatPong() {
|
|
415
|
+
const rtt = this._lastPingAt > 0 ? Date.now() - this._lastPingAt : 0;
|
|
416
|
+
if (this._pongTimeoutTimer !== null) {
|
|
417
|
+
clearTimeout(this._pongTimeoutTimer);
|
|
418
|
+
this._pongTimeoutTimer = null;
|
|
419
|
+
}
|
|
420
|
+
this._missedPongCount = 0;
|
|
421
|
+
this.log.debug?.(`${this.logPrefix} WebSocket pong received (rtt=${rtt}ms)`);
|
|
422
|
+
}
|
|
423
|
+
_terminateStaleConnection(reason) {
|
|
424
|
+
if (!this._ws)
|
|
425
|
+
return;
|
|
426
|
+
this.log.warn(`${this.logPrefix} WebSocket stale detected: ${reason}, terminating connection`);
|
|
427
|
+
this._stopHeartbeat();
|
|
428
|
+
try {
|
|
429
|
+
this._ws.terminate();
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
433
|
+
this.log.error(`${this.logPrefix} WebSocket terminate failed: ${message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// ----------------------------------------------------------
|
|
317
437
|
// 内部:自动重连
|
|
318
438
|
// ----------------------------------------------------------
|
|
439
|
+
_randomDelay(min, max) {
|
|
440
|
+
const normalizedMin = Math.max(0, Math.floor(min));
|
|
441
|
+
const normalizedMax = Math.max(normalizedMin, Math.floor(max));
|
|
442
|
+
return normalizedMin + Math.floor(Math.random() * (normalizedMax - normalizedMin + 1));
|
|
443
|
+
}
|
|
444
|
+
_getReconnectDelay() {
|
|
445
|
+
const baseDelay = Math.min(SOCKET_RECONNECTION_DELAY * Math.pow(2, this._reconnectAttempts), SOCKET_RECONNECTION_DELAY_MAX);
|
|
446
|
+
if (this._reconnectAttempts === 0) {
|
|
447
|
+
return this._randomDelay(SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_INITIAL_JITTER_MAX);
|
|
448
|
+
}
|
|
449
|
+
const jitterRange = Math.max(0, SOCKET_RECONNECTION_JITTER_RATIO);
|
|
450
|
+
const minDelay = Math.floor(baseDelay * Math.max(0, 1 - jitterRange));
|
|
451
|
+
const maxDelay = Math.min(SOCKET_RECONNECTION_DELAY_MAX, Math.floor(baseDelay * (1 + jitterRange)));
|
|
452
|
+
return this._randomDelay(minDelay, maxDelay);
|
|
453
|
+
}
|
|
319
454
|
_scheduleReconnect() {
|
|
320
455
|
// 超过最大重连次数时停止重连
|
|
321
456
|
if (SOCKET_RECONNECTION_ATTEMPTS !== Infinity && this._reconnectAttempts >= SOCKET_RECONNECTION_ATTEMPTS) {
|
|
@@ -323,14 +458,13 @@ export class NativeSocketClient {
|
|
|
323
458
|
this._dispatch('connect_error', new Error(`Max reconnection attempts (${SOCKET_RECONNECTION_ATTEMPTS}) reached`));
|
|
324
459
|
return;
|
|
325
460
|
}
|
|
326
|
-
|
|
327
|
-
const delay = Math.min(SOCKET_RECONNECTION_DELAY * Math.pow(2, this._reconnectAttempts), SOCKET_RECONNECTION_DELAY_MAX);
|
|
461
|
+
const delay = this._getReconnectDelay();
|
|
328
462
|
this._reconnectAttempts++;
|
|
329
|
-
this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms`);
|
|
463
|
+
this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms (jittered)`);
|
|
330
464
|
this._reconnectTimer = setTimeout(() => {
|
|
331
465
|
this._reconnectTimer = null;
|
|
332
466
|
if (!this._manualDisconnect) {
|
|
333
|
-
this._connect();
|
|
467
|
+
void this._connect();
|
|
334
468
|
}
|
|
335
469
|
}, delay);
|
|
336
470
|
}
|
|
@@ -352,6 +486,7 @@ export class NativeSocketClient {
|
|
|
352
486
|
// 内部:关闭 WebSocket
|
|
353
487
|
// ----------------------------------------------------------
|
|
354
488
|
_closeWs() {
|
|
489
|
+
this._stopHeartbeat();
|
|
355
490
|
if (this._ws) {
|
|
356
491
|
// 移除所有回调,防止 onclose 触发重连
|
|
357
492
|
this._ws.onopen = null;
|
|
@@ -366,6 +501,7 @@ export class NativeSocketClient {
|
|
|
366
501
|
}
|
|
367
502
|
this._ws = null;
|
|
368
503
|
}
|
|
504
|
+
this._connecting = false;
|
|
369
505
|
this._connected = false;
|
|
370
506
|
this._id = undefined;
|
|
371
507
|
// onclose 被置 null 后不会自动触发,需手动 flush 挂起的 ACK
|
|
@@ -374,13 +510,40 @@ export class NativeSocketClient {
|
|
|
374
510
|
// ----------------------------------------------------------
|
|
375
511
|
// 内部:构建完整 URL
|
|
376
512
|
// ----------------------------------------------------------
|
|
377
|
-
_buildUrl() {
|
|
378
|
-
let base = this.url;
|
|
379
|
-
// 拼接 path(如 /claw-socket)
|
|
513
|
+
async _buildUrl() {
|
|
514
|
+
let base = this.url.replace(/\/$/, '');
|
|
380
515
|
if (this.options.path) {
|
|
381
|
-
|
|
382
|
-
|
|
516
|
+
const path = this.options.path.startsWith('/')
|
|
517
|
+
? this.options.path
|
|
518
|
+
: `/${this.options.path}`;
|
|
519
|
+
base += path;
|
|
520
|
+
}
|
|
521
|
+
const query = typeof this.options.query === 'function'
|
|
522
|
+
? await this.options.query()
|
|
523
|
+
: this.options.query;
|
|
524
|
+
const searchParams = new URLSearchParams();
|
|
525
|
+
Object.entries(query ?? {}).forEach(([key, value]) => {
|
|
526
|
+
if (value === undefined || value === null || value === '')
|
|
527
|
+
return;
|
|
528
|
+
searchParams.set(key, String(value));
|
|
529
|
+
});
|
|
530
|
+
const queryString = searchParams.toString();
|
|
531
|
+
if (!queryString)
|
|
532
|
+
return base;
|
|
533
|
+
return `${base}${base.includes('?') ? '&' : '?'}${queryString}`;
|
|
534
|
+
}
|
|
535
|
+
/** 对连接 URL 中的敏感 query 做日志脱敏 */
|
|
536
|
+
_maskUrl(url) {
|
|
537
|
+
try {
|
|
538
|
+
const parsed = new URL(url);
|
|
539
|
+
const ticket = parsed.searchParams.get('ticket');
|
|
540
|
+
if (ticket) {
|
|
541
|
+
parsed.searchParams.set('ticket', `${ticket.slice(0, 6)}...${ticket.slice(-4)}`);
|
|
542
|
+
}
|
|
543
|
+
return parsed.toString();
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return url.replace(/([?&]ticket=)([^&]+)/, (_match, prefix, value) => (`${prefix}${value.slice(0, 6)}...${value.slice(-4)}`));
|
|
383
547
|
}
|
|
384
|
-
return base;
|
|
385
548
|
}
|
|
386
549
|
}
|
|
@@ -33,6 +33,56 @@ const LOCALIZED_ABORT_REPLY = "已停止当前回复。";
|
|
|
33
33
|
function localizeAbortReplyText(text) {
|
|
34
34
|
return OPENCLAW_ABORT_REPLY_RE.test(text.trim()) ? LOCALIZED_ABORT_REPLY : text;
|
|
35
35
|
}
|
|
36
|
+
/** 已知的图片扩展名集合,用于判断是否走 files 渲染(而非 markdown 文本链接)。 */
|
|
37
|
+
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"]);
|
|
38
|
+
/**
|
|
39
|
+
* 判断 URL 指向的资源是否为图片(基于文件扩展名)。
|
|
40
|
+
*
|
|
41
|
+
* 优先匹配 COS 公网 URL 中的 `filePath=xxx` query 参数;
|
|
42
|
+
* 回退到 URL 末段。判断失败一律按"非图片"处理(保留文本链接,前端兼容性更好)。
|
|
43
|
+
*/
|
|
44
|
+
function isImageUrl(url) {
|
|
45
|
+
let candidate = url;
|
|
46
|
+
const filePathMatch = url.match(/filePath=([^&]+)/);
|
|
47
|
+
if (filePathMatch) {
|
|
48
|
+
try {
|
|
49
|
+
candidate = decodeURIComponent(filePathMatch[1]);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
candidate = filePathMatch[1];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const lastDot = candidate.lastIndexOf(".");
|
|
56
|
+
if (lastDot < 0)
|
|
57
|
+
return false;
|
|
58
|
+
const ext = candidate.slice(lastDot + 1).split(/[?#]/)[0].toLowerCase();
|
|
59
|
+
return IMAGE_EXTS.has(ext);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 对 mediaUrls 入口去重:同一文件常以裸路径 `/path/to/x.png` 和带前缀
|
|
63
|
+
* `file:///path/to/x.png` 两种形式同时出现(如 browser screenshot toolResult),
|
|
64
|
+
* 不归一就会让前端同时渲染两份"全部文件"卡片(即用户看到的"两个全部文件按钮")。
|
|
65
|
+
*
|
|
66
|
+
* 归一规则:
|
|
67
|
+
* - 去掉 `file://` 前缀做"路径键"比较;
|
|
68
|
+
* - 保留首次出现的原始字符串(保留 `file://` 与否取决于 mediaList 顺序),
|
|
69
|
+
* 避免破坏后续 `existsSync` / `mediaUrlsToFiles` 对路径形态的处理。
|
|
70
|
+
*
|
|
71
|
+
* 不做内容级去重(buffer hash),因为 data URL 与本地路径同图的归一代价较高,
|
|
72
|
+
* 且配合"图片不再重复拼 markdown"已能消除可见的重复渲染。
|
|
73
|
+
*/
|
|
74
|
+
function dedupMediaUrls(urls) {
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const result = [];
|
|
77
|
+
for (const url of urls) {
|
|
78
|
+
const key = url.startsWith("file://") ? url.slice(7) : url;
|
|
79
|
+
if (seen.has(key))
|
|
80
|
+
continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
result.push(url);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
36
86
|
export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
37
87
|
const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
|
|
38
88
|
// ── 群聊扩展字段 ──
|
|
@@ -237,25 +287,44 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
237
287
|
}
|
|
238
288
|
};
|
|
239
289
|
/**
|
|
240
|
-
* 处理 payload 中的 mediaUrls
|
|
290
|
+
* 处理 payload 中的 mediaUrls:去重 → COS 上传 → 与文本同帧推送给用户。
|
|
241
291
|
* 仅在 mediaList 非空时调用。
|
|
242
292
|
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
293
|
+
* 私聊 / 群聊统一通过 `emitSignal('stream_chunk', ...)` 走信号链,
|
|
294
|
+
* 复用本轮 replyMsgId(与 stream_chunk 文本同 msgId 合并到同一气泡),
|
|
295
|
+
* 同时携带 kind / agentId / extra.chatId / chatKind 等协议字段,
|
|
296
|
+
* 避免前端因 kind 缺失而把媒体帧兜底为 typing_stop 完成态独立气泡。
|
|
297
|
+
*
|
|
298
|
+
* 协议约定:
|
|
299
|
+
* - 图片由顶层 `files` 字段独家承载(前端文件卡片渲染),
|
|
300
|
+
* 文本里**不再**额外拼 `` markdown,避免"两张图"重复渲染。
|
|
301
|
+
* - 非图片文件(pdf / 压缩包等)保留 `📎 [filename](url)` 文本链接,
|
|
302
|
+
* 方便用户复制公网 URL,同时仍透传 files 给前端文件卡片。
|
|
245
303
|
*/
|
|
246
304
|
const handleMediaFinal = async (replyText, mediaList) => {
|
|
247
|
-
|
|
305
|
+
// 去重:mediaUrls 常包含同一文件的多种引用(裸路径 / file:// 前缀),
|
|
306
|
+
// 归一后再交给下游处理,避免 files 数组与 publicUrls 数组各自重复一次。
|
|
307
|
+
const dedupedMediaList = dedupMediaUrls(mediaList);
|
|
308
|
+
const files = await mediaUrlsToFiles(dedupedMediaList, log);
|
|
248
309
|
const storageConfig = { apiKey: effectiveApiKey };
|
|
249
|
-
|
|
250
|
-
|
|
310
|
+
/** 仅非图片文件需要在文本里给出公网链接,图片由 files 渲染。 */
|
|
311
|
+
const nonImagePublicLinks = [];
|
|
312
|
+
for (const mediaUrl of dedupedMediaList) {
|
|
251
313
|
try {
|
|
252
314
|
const localPath = mediaUrl.startsWith("file://") ? mediaUrl.slice(7) : mediaUrl;
|
|
253
315
|
if (localPath.startsWith("/") || localPath.match(/^[A-Za-z]:\\/)) {
|
|
254
316
|
const { existsSync } = await import("node:fs");
|
|
255
317
|
if (existsSync(localPath)) {
|
|
256
318
|
const result = await uploadFileToServer(localPath, storageConfig);
|
|
257
|
-
|
|
258
|
-
log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${
|
|
319
|
+
const publicUrl = result.url || "";
|
|
320
|
+
log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${publicUrl}`);
|
|
321
|
+
// 仅非图片文件保留文本链接(图片由 files 渲染,避免重复)
|
|
322
|
+
if (publicUrl && !isImageUrl(publicUrl)) {
|
|
323
|
+
const match = publicUrl.match(/filePath=([^&]+)/);
|
|
324
|
+
const filePath = match ? decodeURIComponent(match[1]) : "";
|
|
325
|
+
const fileName = filePath.split("/").pop() || "file";
|
|
326
|
+
nonImagePublicLinks.push(`📎 [${fileName}](${publicUrl})`);
|
|
327
|
+
}
|
|
259
328
|
}
|
|
260
329
|
}
|
|
261
330
|
}
|
|
@@ -264,41 +333,33 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
264
333
|
}
|
|
265
334
|
}
|
|
266
335
|
let enrichedText = replyText;
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
const urlSection = publicUrls
|
|
270
|
-
.map((url, i) => {
|
|
271
|
-
const match = url.match(/filePath=([^&]+)/);
|
|
272
|
-
const filePath = match ? decodeURIComponent(match[1]) : "";
|
|
273
|
-
const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`;
|
|
274
|
-
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
275
|
-
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
|
|
276
|
-
// 图片使用 Markdown 图片语法,非图片使用链接语法
|
|
277
|
-
return isImage ? `` : `📎 [${fileName}](${url})`;
|
|
278
|
-
})
|
|
279
|
-
.join("\n");
|
|
336
|
+
if (nonImagePublicLinks.length > 0) {
|
|
337
|
+
const urlSection = nonImagePublicLinks.join("\n");
|
|
280
338
|
enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
|
|
281
339
|
}
|
|
282
|
-
//
|
|
283
|
-
if (
|
|
284
|
-
if (enrichedText.trim()) {
|
|
285
|
-
const delta = `\n\n${enrichedText}`;
|
|
286
|
-
streamedText += delta;
|
|
287
|
-
emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta'));
|
|
288
|
-
emittedUserVisible = true;
|
|
289
|
-
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`);
|
|
290
|
-
}
|
|
340
|
+
// 没有任何可见内容(既无文本又无文件)→ 直接返回,避免发空帧
|
|
341
|
+
if (!enrichedText.trim() && files.length === 0) {
|
|
291
342
|
return;
|
|
292
343
|
}
|
|
293
|
-
// ──
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
344
|
+
// ── 群聊模式:通过 stream_chunk 推送(带群聊 extra) ──
|
|
345
|
+
// 群聊额外把文本累加到 streamedText,让 markComplete 的 aggregatedText 能算上这段。
|
|
346
|
+
if (isGroupMode) {
|
|
347
|
+
const delta = enrichedText.trim() ? `\n\n${enrichedText}` : "";
|
|
348
|
+
if (delta)
|
|
349
|
+
streamedText += delta;
|
|
350
|
+
emitSignal(signalCtx, "stream_chunk", delta, { ...(files.length > 0 ? { files } : {}), ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
300
351
|
emittedUserVisible = true;
|
|
352
|
+
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${files.length} file(s), ` +
|
|
353
|
+
`nonImageLinks=${nonImagePublicLinks.length}`);
|
|
354
|
+
return;
|
|
301
355
|
}
|
|
356
|
+
// ── 私聊模式:同样通过 stream_chunk 走信号链 ──
|
|
357
|
+
// 关键:复用 signalCtx.replyMsgId → 与本轮 stream_chunk 文本同 msgId 合并,
|
|
358
|
+
// 且自动携带 kind/agentId/extra.chatId,前端不再兜底为 typing_stop。
|
|
359
|
+
emitSignal(signalCtx, "stream_chunk", enrichedText, files.length > 0 ? { files } : undefined);
|
|
360
|
+
emittedUserVisible = true;
|
|
361
|
+
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal: pushed ${files.length} file(s) via stream_chunk, ` +
|
|
362
|
+
`textLen=${enrichedText.length} nonImageLinks=${nonImagePublicLinks.length}`);
|
|
302
363
|
};
|
|
303
364
|
// ────────────────────────────────────────
|
|
304
365
|
// dispatcher
|
|
@@ -45,7 +45,7 @@ export function registerUploadTool(api) {
|
|
|
45
45
|
}
|
|
46
46
|
return {
|
|
47
47
|
name: UPLOAD_TOOL_NAME,
|
|
48
|
-
description: "
|
|
48
|
+
description: "重要:将 AI 生成的本机文件注册为可下载的引用,要求必须立马返回 `[<文件名称>]localfile://<绝对路径>` 格式的 Markdown 下载链接;用户可以下载。" +
|
|
49
49
|
"当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" +
|
|
50
50
|
"文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" +
|
|
51
51
|
"重要:工具返回的 Markdown 链接已是最终格式,请原样使用,不要改写为 https:// 或本机路径字符串。",
|
package/dist/src/utils/common.js
CHANGED
|
@@ -53,7 +53,11 @@ export function buildAuthHeaders(apiKey) {
|
|
|
53
53
|
* 统一的信号发送出口,收敛 typing / stream / tool / usage 控制帧的构造逻辑。
|
|
54
54
|
*
|
|
55
55
|
* - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
|
|
56
|
-
* - topLevelExtra 用于透传顶层 kind
|
|
56
|
+
* - topLevelExtra 用于透传顶层 kind 相关字段:
|
|
57
|
+
* · tool_start 的 toolName / toolPhase
|
|
58
|
+
* · tool_end 的 toolIsError
|
|
59
|
+
* · 群聊场景的 chatKind
|
|
60
|
+
* · 媒体附件的 files(与文本同帧投递,确保 kind/agentId/extra 齐全)
|
|
57
61
|
* - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。
|
|
58
62
|
* chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。
|
|
59
63
|
*/
|