lightclawbot 1.2.8 → 1.2.9

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.
@@ -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;
@@ -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
- // 构造标准消息体,格式与 gateway 入站消息保持一致
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: entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '',
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
  }
@@ -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:COS 上传 Markdown 链接 推送给用户。
290
+ * 处理 payload 中的 mediaUrls:去重COS 上传与文本同帧推送给用户。
241
291
  * 仅在 mediaList 非空时调用。
242
292
  *
243
- * 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。
244
- * 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。
293
+ * 私聊 / 群聊统一通过 `emitSignal('stream_chunk', ...)` 走信号链,
294
+ * 复用本轮 replyMsgId(与 stream_chunk 文本同 msgId 合并到同一气泡),
295
+ * 同时携带 kind / agentId / extra.chatId / chatKind 等协议字段,
296
+ * 避免前端因 kind 缺失而把媒体帧兜底为 typing_stop 完成态独立气泡。
297
+ *
298
+ * 协议约定:
299
+ * - 图片由顶层 `files` 字段独家承载(前端文件卡片渲染),
300
+ * 文本里**不再**额外拼 `![](url)` markdown,避免"两张图"重复渲染。
301
+ * - 非图片文件(pdf / 压缩包等)保留 `📎 [filename](url)` 文本链接,
302
+ * 方便用户复制公网 URL,同时仍透传 files 给前端文件卡片。
245
303
  */
246
304
  const handleMediaFinal = async (replyText, mediaList) => {
247
- const files = await mediaUrlsToFiles(mediaList, log);
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
- const publicUrls = [];
250
- for (const mediaUrl of mediaList) {
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
- publicUrls.push(result.url || "");
258
- log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${result.url}`);
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 (publicUrls.length > 0) {
268
- // 判断是否为图片类型(根据 URL 中的文件扩展名)
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})` : `📎 [${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
- // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ──
283
- if (isGroupMode) {
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
- // ── 私聊模式:通过 sendFiles / sendReply 推送 ──
294
- if (files.length > 0) {
295
- emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
296
- emittedUserVisible = true;
297
- }
298
- else if (enrichedText.trim()) {
299
- emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId);
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: " AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" +
48
+ description: "重要:将 AI 生成的本机文件注册为可下载的引用,要求必须立马返回 `[<文件名称>]localfile://<绝对路径>` 格式的 Markdown 下载链接;用户可以下载。" +
49
49
  "当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" +
50
50
  "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" +
51
51
  "重要:工具返回的 Markdown 链接已是最终格式,请原样使用,不要改写为 https:// 或本机路径字符串。",
@@ -53,7 +53,11 @@ export function buildAuthHeaders(apiKey) {
53
53
  * 统一的信号发送出口,收敛 typing / stream / tool / usage 控制帧的构造逻辑。
54
54
  *
55
55
  * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
56
- * - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
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
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightclawbot",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "type": "module",
6
6
  "files": [