lightclawbot
Advanced tools
@@ -213,2 +213,24 @@ /** | ||
| files = deduplicateFiles(allFiles); | ||
| // 修复:当用户消息有 MediaPath/MediaPaths(OpenClaw 存在附件文件) | ||
| // 但 files 里没有 uri 时,补上 localfile:// 格式的 uri | ||
| if (!files || files.some((f) => !f.uri)) { | ||
| const mediaPaths = Array.isArray(msg.MediaPaths) | ||
| ? msg.MediaPaths | ||
| : (typeof msg.MediaPath === "string" ? [msg.MediaPath] : []); | ||
| if (mediaPaths.length > 0) { | ||
| const existingFiles = files ?? []; | ||
| const needInit = existingFiles.length === 0; | ||
| const updatedFiles = needInit | ||
| ? mediaPaths.map((p, i) => ({ | ||
| name: existingFiles[i]?.name ?? p.split("/").pop() ?? `file-${i}`, | ||
| mimeType: existingFiles[i]?.mimeType, | ||
| uri: `localfile://${p}`, | ||
| })) | ||
| : existingFiles.map((f, i) => ({ | ||
| ...f, | ||
| uri: f.uri ?? (mediaPaths[i] ? `localfile://${mediaPaths[i]}` : undefined), | ||
| })); | ||
| files = updatedFiles; | ||
| } | ||
| } | ||
| } | ||
@@ -215,0 +237,0 @@ else { |
+53
-2
@@ -87,13 +87,48 @@ /** | ||
| const msgId = generateOutboundMsgId(); | ||
| // 构造标准消息体,格式与 gateway 入站消息保持一致 | ||
| const fromId = entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? ''; | ||
| // 构造标准消息体,格式与 gateway 入站消息保持一致。 | ||
| // | ||
| // 协议关键字段说明: | ||
| // - kind: 'stream_chunk' | ||
| // outbound 由 LLM 工具调用结果(如 message 工具发图/发文)触发, | ||
| // 归属于"流式回复信号链"的一部分,前端依赖 kind 推导消息状态 | ||
| // (stream_chunk → 'updating')。若缺省,前端 statusMap 会兜底到 | ||
| // typing_stop=='success',导致内容被独立成一条"已完成回复"气泡, | ||
| // 与上一段 LLM 文本(已 typing_stop)错位拼接。详见 stream-reply-sink。 | ||
| // - extra.chatId: '' | ||
| // 协议规定所有 EVENT_MESSAGE_PRIVATE 必须携带 extra.chatId(多会话分桶)。 | ||
| // outbound 触发时通常无明确 chatId 上下文(cron / message 工具 / REST 等), | ||
| // 按协议约定填空字符串落到默认会话。 | ||
| // - idempotencyKey: 由 ReliableEmitter 在 emitWithAck 内部注入,此处无需显式构造。 | ||
| const message = { | ||
| msgId, | ||
| // 优先使用 entry 中缓存的 botClientId,entry 不存在时从 registry 直接查询 | ||
| from: entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '', | ||
| from: fromId, | ||
| to: target, | ||
| content: text, | ||
| timestamp: Date.now(), | ||
| kind: 'stream_chunk', | ||
| // undefined 表示非回复消息,避免发送多余字段 | ||
| replyToMsgId: replyToId ?? undefined, | ||
| extra: { chatId: '' }, | ||
| }; | ||
| // 自闭合 typing_stop 帧:与 stream_chunk 共用同一个 msgId / chatId, | ||
| // content 必须为空字符串(前端 addOrUpdateMessage 会按 oldContent + content 拼接, | ||
| // 非空 content 会让 caption 文本被复制一遍)。 | ||
| // | ||
| // 设计理由: | ||
| // outbound 通道(如 message 工具调用结果)由 OpenClaw 框架以「独立 messageId」投递, | ||
| // 不会与上一段 LLM 流式回复合并。仅发 stream_chunk 而无 typing_stop, | ||
| // 会导致前端这条独立气泡永远停留在 'updating' 状态(loading 转圈)。 | ||
| // 故每条 outbound 消息都自带一帧 typing_stop 收尾,让该气泡正常进入 'success'。 | ||
| const buildClosingFrame = () => ({ | ||
| msgId, | ||
| from: fromId, | ||
| to: target, | ||
| content: '', | ||
| timestamp: Date.now(), | ||
| kind: 'typing_stop', | ||
| replyToMsgId: replyToId ?? undefined, | ||
| extra: { chatId: '' }, | ||
| }); | ||
| if (entry) { | ||
@@ -109,2 +144,12 @@ // 策略 1:Socket 已连接,优先走可靠发送(emitWithAck + 自动重试) | ||
| }); | ||
| // 紧跟一帧 typing_stop 闭合该 msgId 的流式状态。 | ||
| // ReliableEmitter 内部按 emit 顺序生成 idempotencyKey(字典序 == emit 顺序), | ||
| // 即便网络层 batch 抖动导致 packet 乱序到达,前端也能按字典序合并出 | ||
| // 「stream_chunk 在前 → typing_stop 在后」的稳定终态。 | ||
| const closingFrame = buildClosingFrame(); | ||
| emitter.emitWithAck(EVENT_MESSAGE_PRIVATE, closingFrame, msgId).then((ok) => { | ||
| if (!ok) { | ||
| logger.warn(`[${CHANNEL_KEY}] outbound closing frame ack timeout: msgId=${msgId}`); | ||
| } | ||
| }); | ||
| logger.info(`[${CHANNEL_KEY}] outbound sent via reliable WS: to=${target} msgId=${msgId}`); | ||
@@ -116,2 +161,4 @@ return msgId; | ||
| entry.socket.emit(EVENT_MESSAGE_PRIVATE, message); | ||
| // 同样补一帧 typing_stop;裸 emit 无 ack,无法保证送达,best-effort 即可。 | ||
| entry.socket.emit(EVENT_MESSAGE_PRIVATE, buildClosingFrame()); | ||
| logger.info(`[${CHANNEL_KEY}] outbound sent via WS (fallback): to=${target} msgId=${msgId}`); | ||
@@ -131,2 +178,6 @@ return msgId; | ||
| if (buffered) { | ||
| // 顺序 push 一帧 typing_stop,flush 时随业务消息一起按 FIFO 发出。 | ||
| // bufferMessage 失败(队列满)则跳过闭合帧 —— 主消息都没缓冲住, | ||
| // 闭合帧也没意义;前端会因永远收不到 stream_chunk 而不展示这条气泡。 | ||
| bufferMessage(resolvedAccountId, buildClosingFrame()); | ||
| logger.info(`[${CHANNEL_KEY}] outbound buffered (WS reconnecting): to=${target} msgId=${msgId}`); | ||
@@ -133,0 +184,0 @@ return msgId; |
@@ -36,2 +36,52 @@ /** | ||
| } | ||
| /** 已知的图片扩展名集合,用于判断是否走 files 渲染(而非 markdown 文本链接)。 */ | ||
| const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"]); | ||
| /** | ||
| * 判断 URL 指向的资源是否为图片(基于文件扩展名)。 | ||
| * | ||
| * 优先匹配 COS 公网 URL 中的 `filePath=xxx` query 参数; | ||
| * 回退到 URL 末段。判断失败一律按"非图片"处理(保留文本链接,前端兼容性更好)。 | ||
| */ | ||
| function isImageUrl(url) { | ||
| let candidate = url; | ||
| const filePathMatch = url.match(/filePath=([^&]+)/); | ||
| if (filePathMatch) { | ||
| try { | ||
| candidate = decodeURIComponent(filePathMatch[1]); | ||
| } | ||
| catch { | ||
| candidate = filePathMatch[1]; | ||
| } | ||
| } | ||
| const lastDot = candidate.lastIndexOf("."); | ||
| if (lastDot < 0) | ||
| return false; | ||
| const ext = candidate.slice(lastDot + 1).split(/[?#]/)[0].toLowerCase(); | ||
| return IMAGE_EXTS.has(ext); | ||
| } | ||
| /** | ||
| * 对 mediaUrls 入口去重:同一文件常以裸路径 `/path/to/x.png` 和带前缀 | ||
| * `file:///path/to/x.png` 两种形式同时出现(如 browser screenshot toolResult), | ||
| * 不归一就会让前端同时渲染两份"全部文件"卡片(即用户看到的"两个全部文件按钮")。 | ||
| * | ||
| * 归一规则: | ||
| * - 去掉 `file://` 前缀做"路径键"比较; | ||
| * - 保留首次出现的原始字符串(保留 `file://` 与否取决于 mediaList 顺序), | ||
| * 避免破坏后续 `existsSync` / `mediaUrlsToFiles` 对路径形态的处理。 | ||
| * | ||
| * 不做内容级去重(buffer hash),因为 data URL 与本地路径同图的归一代价较高, | ||
| * 且配合"图片不再重复拼 markdown"已能消除可见的重复渲染。 | ||
| */ | ||
| function dedupMediaUrls(urls) { | ||
| const seen = new Set(); | ||
| const result = []; | ||
| for (const url of urls) { | ||
| const key = url.startsWith("file://") ? url.slice(7) : url; | ||
| if (seen.has(key)) | ||
| continue; | ||
| seen.add(key); | ||
| result.push(url); | ||
| } | ||
| return result; | ||
| } | ||
| export function createStreamReplyConfig(opts, prefixOptions, signalCtx) { | ||
@@ -241,13 +291,25 @@ const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts; | ||
| /** | ||
| * 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 → 推送给用户。 | ||
| * 处理 payload 中的 mediaUrls:去重 → COS 上传 → 与文本同帧推送给用户。 | ||
| * 仅在 mediaList 非空时调用。 | ||
| * | ||
| * 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。 | ||
| * 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。 | ||
| * 私聊 / 群聊统一通过 `emitSignal('stream_chunk', ...)` 走信号链, | ||
| * 复用本轮 replyMsgId(与 stream_chunk 文本同 msgId 合并到同一气泡), | ||
| * 同时携带 kind / agentId / extra.chatId / chatKind 等协议字段, | ||
| * 避免前端因 kind 缺失而把媒体帧兜底为 typing_stop 完成态独立气泡。 | ||
| * | ||
| * 协议约定: | ||
| * - 图片由顶层 `files` 字段独家承载(前端文件卡片渲染), | ||
| * 文本里**不再**额外拼 `` markdown,避免"两张图"重复渲染。 | ||
| * - 非图片文件(pdf / 压缩包等)保留 `📎 [filename](url)` 文本链接, | ||
| * 方便用户复制公网 URL,同时仍透传 files 给前端文件卡片。 | ||
| */ | ||
| const handleMediaFinal = async (replyText, mediaList) => { | ||
| const files = await mediaUrlsToFiles(mediaList, log); | ||
| // 去重:mediaUrls 常包含同一文件的多种引用(裸路径 / file:// 前缀), | ||
| // 归一后再交给下游处理,避免 files 数组与 publicUrls 数组各自重复一次。 | ||
| const dedupedMediaList = dedupMediaUrls(mediaList); | ||
| const files = await mediaUrlsToFiles(dedupedMediaList, log); | ||
| const storageConfig = { apiKey: effectiveApiKey }; | ||
| const publicUrls = []; | ||
| for (const mediaUrl of mediaList) { | ||
| /** 仅非图片文件需要在文本里给出公网链接,图片由 files 渲染。 */ | ||
| const nonImagePublicLinks = []; | ||
| for (const mediaUrl of dedupedMediaList) { | ||
| try { | ||
@@ -259,4 +321,11 @@ const localPath = mediaUrl.startsWith("file://") ? mediaUrl.slice(7) : mediaUrl; | ||
| const result = await uploadFileToServer(localPath, storageConfig); | ||
| publicUrls.push(result.url || ""); | ||
| log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${result.url}`); | ||
| const publicUrl = result.url || ""; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${publicUrl}`); | ||
| // 仅非图片文件保留文本链接(图片由 files 渲染,避免重复) | ||
| if (publicUrl && !isImageUrl(publicUrl)) { | ||
| const match = publicUrl.match(/filePath=([^&]+)/); | ||
| const filePath = match ? decodeURIComponent(match[1]) : ""; | ||
| const fileName = filePath.split("/").pop() || "file"; | ||
| nonImagePublicLinks.push(`📎 [${fileName}](${publicUrl})`); | ||
| } | ||
| } | ||
@@ -270,37 +339,29 @@ } | ||
| let enrichedText = replyText; | ||
| if (publicUrls.length > 0) { | ||
| // 判断是否为图片类型(根据 URL 中的文件扩展名) | ||
| const urlSection = publicUrls | ||
| .map((url, i) => { | ||
| const match = url.match(/filePath=([^&]+)/); | ||
| const filePath = match ? decodeURIComponent(match[1]) : ""; | ||
| const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`; | ||
| const ext = fileName.split('.').pop()?.toLowerCase() || ''; | ||
| const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext); | ||
| // 图片使用 Markdown 图片语法,非图片使用链接语法 | ||
| return isImage ? `` : `📎 [${fileName}](${url})`; | ||
| }) | ||
| .join("\n"); | ||
| if (nonImagePublicLinks.length > 0) { | ||
| const urlSection = nonImagePublicLinks.join("\n"); | ||
| enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection; | ||
| } | ||
| // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ── | ||
| // 没有任何可见内容(既无文本又无文件)→ 直接返回,避免发空帧 | ||
| if (!enrichedText.trim() && files.length === 0) { | ||
| return; | ||
| } | ||
| // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra) ── | ||
| // 群聊额外把文本累加到 streamedText,让 markComplete 的 aggregatedText 能算上这段。 | ||
| if (isGroupMode) { | ||
| if (enrichedText.trim()) { | ||
| const delta = `\n\n${enrichedText}`; | ||
| const delta = enrichedText.trim() ? `\n\n${enrichedText}` : ""; | ||
| if (delta) | ||
| streamedText += delta; | ||
| emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta')); | ||
| emittedUserVisible = true; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`); | ||
| } | ||
| emitSignal(signalCtx, "stream_chunk", delta, { ...(files.length > 0 ? { files } : {}), ...groupSignalExtra }, buildGroupDataExtra('delta')); | ||
| emittedUserVisible = true; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${files.length} file(s), ` + | ||
| `nonImageLinks=${nonImagePublicLinks.length}`); | ||
| return; | ||
| } | ||
| // ── 私聊模式:通过 sendFiles / sendReply 推送 ── | ||
| if (files.length > 0) { | ||
| emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId); | ||
| emittedUserVisible = true; | ||
| } | ||
| else if (enrichedText.trim()) { | ||
| emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId); | ||
| emittedUserVisible = true; | ||
| } | ||
| // ── 私聊模式:同样通过 stream_chunk 走信号链 ── | ||
| // 关键:复用 signalCtx.replyMsgId → 与本轮 stream_chunk 文本同 msgId 合并, | ||
| // 且自动携带 kind/agentId/extra.chatId,前端不再兜底为 typing_stop。 | ||
| emitSignal(signalCtx, "stream_chunk", enrichedText, files.length > 0 ? { files } : undefined); | ||
| emittedUserVisible = true; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal: pushed ${files.length} file(s) via stream_chunk, ` + | ||
| `textLen=${enrichedText.length} nonImageLinks=${nonImagePublicLinks.length}`); | ||
| }; | ||
@@ -307,0 +368,0 @@ // ──────────────────────────────────────── |
@@ -48,3 +48,3 @@ /** | ||
| name: UPLOAD_TOOL_NAME, | ||
| description: "将 AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" + | ||
| description: "重要:将 AI 生成的本机文件注册为可下载的引用,要求必须立马返回 `[<文件名称>]localfile://<绝对路径>` 格式的 Markdown 下载链接;用户可以下载。" + | ||
| "当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" + | ||
@@ -51,0 +51,0 @@ "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" + |
@@ -56,3 +56,7 @@ import * as fs from 'node:fs'; | ||
| * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。 | ||
| * - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。 | ||
| * - topLevelExtra 用于透传顶层 kind 相关字段: | ||
| * · tool_start 的 toolName / toolPhase | ||
| * · tool_end 的 toolIsError | ||
| * · 群聊场景的 chatKind | ||
| * · 媒体附件的 files(与文本同帧投递,确保 kind/agentId/extra 齐全) | ||
| * - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。 | ||
@@ -59,0 +63,0 @@ * chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。 |
+1
-1
| { | ||
| "name": "lightclawbot", | ||
| "version": "1.2.8", | ||
| "version": "1.2.9", | ||
| "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging", | ||
@@ -5,0 +5,0 @@ "type": "module", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
813796
0.98%19355
0.72%15
7.14%