lightclawbot 1.2.7 → 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.
@@ -55,9 +55,36 @@ let globalApiKeyMap = new Map();
55
55
  let globalDefaultApiKey = '';
56
56
  /** sessionKey → apiKey 直接映射(inbound 处理时写入,tool 执行时读取) */
57
57
  const sessionKeyToApiKey = new Map();
58
- /** 设置 apiKeyMap(gateway 启动时调用) */
59
- export function setApiKeyMap(map, defaultApiKey) {
60
- globalApiKeyMap = map;
58
+ /** 合并单条 account 的 apiKey 到全局映射(gateway 启动时调用) */
59
+ export function addApiKeyToMap(accountId, apiKey) {
60
+ globalApiKeyMap.set(accountId, apiKey);
61
+ // 仅在尚未设置 defaultApiKey 时设置(取第一个 account 的 key 作为默认)
62
+ if (!globalDefaultApiKey) {
63
+ globalDefaultApiKey = apiKey;
64
+ }
65
+ }
66
+ /**
67
+ * 一次性构建全局 apiKeyMap(插件初始化时调用)。
68
+ *
69
+ * 遍历配置中所有 accounts,将 accountId→apiKey 映射一次性写入全局 Map,
70
+ * 避免每个 account 启动 gateway 时覆盖其他 account 的映射。
71
+ *
72
+ * @param cfg - OpenClaw 全局配置对象
73
+ */
74
+ export function buildGlobalApiKeyMap(cfg) {
75
+ const channels = cfg?.channels;
76
+ const channelConfig = channels?.[CHANNEL_KEY];
77
+ if (!channelConfig?.accounts)
78
+ return;
79
+ const accounts = channelConfig.accounts;
80
+ let defaultApiKey = globalDefaultApiKey; // 保留已有的 default
81
+ for (const [accountId, accountConfig] of Object.entries(accounts)) {
82
+ if (accountConfig?.apiKey) {
83
+ globalApiKeyMap.set(accountId, accountConfig.apiKey);
84
+ if (!defaultApiKey)
85
+ defaultApiKey = accountConfig.apiKey;
86
+ }
87
+ }
61
88
  globalDefaultApiKey = defaultApiKey;
62
89
  }
63
90
  /** 记录 sessionKey → apiKey 映射(inbound 处理消息时调用) */
@@ -13,7 +13,7 @@
13
13
  * 媒体文件处理 → media.ts
14
14
  */
15
15
  import { NativeSocketClient } from './socket/native-socket.js';
16
- import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, setApiKeyMap, } from './config.js';
16
+ import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, addApiKeyToMap, buildGlobalApiKeyMap, } from './config.js';
17
17
  import { generateMsgId } from './dedup.js';
18
18
  import { createInboundHandler } from './inbound.js';
19
19
  import { bindSocketHandlers, registerSocket, unregisterSocket, flushPendingMessages } from './socket/index.js';
@@ -95,7 +95,7 @@ async function resolveBotClientId(apiKey, log) {
95
95
  * @param ctx - Gateway 上下文,包含账户配置、abort 信号和生命周期回调
96
96
  */
97
97
  export async function startGateway(ctx) {
98
- const { account, abortSignal, onReady, onDisconnect, onEvent, log } = ctx;
98
+ const { account, abortSignal, cfg, onReady, onDisconnect, onEvent, log } = ctx;
99
99
  const prefix = `[${CHANNEL_KEY}:${account.accountId}]`;
100
100
  // 判断是否存在有效的apikey
101
101
  if (!account.apiKey) {
@@ -113,11 +113,13 @@ export async function startGateway(ctx) {
113
113
  // 新配置格式:accountId 即为 uin,apiKey 与 uin 一一对应,直接构建映射表
114
114
  log?.info(`${prefix} Resolving botClientId for account ${account.accountId}...`);
115
115
  const { botClientId, ticket } = await resolveBotClientId(account.apiKey, log);
116
- // 构建 uin→apiKey 映射表(新格式下每个 account 只有一条记录)
117
- const apiKeyMap = new Map([[account.accountId, account.apiKey]]);
116
+ // 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作)
117
+ // 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题
118
+ buildGlobalApiKeyMap(cfg);
119
+ // 将当前 account 的 uin→apiKey 映射合并到全局 config 模块
120
+ // 确保 gateway 重启/reconnect 时映射仍然完整
121
+ addApiKeyToMap(account.accountId, account.apiKey);
118
122
  log?.info(`${prefix} Bot clientId: ${botClientId}, uin=${account.accountId} → apiKey=***${account.apiKey.slice(-4)}`);
119
- // 将映射表写入全局 config 模块,供 inbound 处理时按 uin 查找对应 apiKey
120
- setApiKeyMap(apiKeyMap, account.apiKey);
121
123
  /** Gateway 是否已被 abort(用于终止消息处理循环) */
122
124
  let isAborted = false;
123
125
  /** 当前活跃的 WebSocket 实例(断线时为 null) */
@@ -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;
@@ -338,8 +338,14 @@ function parseLines(lines, opts) {
338
338
  if (!msg || typeof msg !== "object")
339
339
  continue;
340
340
  // 过滤 openclaw delivery-mirror 消息(镜像投递,非真实消息)
341
- if (msg.model === "delivery-mirror")
342
- continue;
341
+ // 但对 cron 直接投递的消息放行(用户需要看到定时提醒内容)
342
+ if (msg.model === "delivery-mirror") {
343
+ const idempotencyKey = msg.idempotencyKey
344
+ ?? parsed.idempotencyKey;
345
+ if (!idempotencyKey || !idempotencyKey.startsWith("cron-direct-delivery:")) {
346
+ continue;
347
+ }
348
+ }
343
349
  // 过滤传输层伪装为 user 的系统注入消息
344
350
  if (isSystemInjectedUserMessage(msg))
345
351
  continue;
@@ -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
  }
@@ -17,6 +17,7 @@ import { CHANNEL_KEY } from "../config.js";
17
17
  import { uploadFileToServer } from "../file-storage.js";
18
18
  import { mediaUrlsToFiles } from "../media.js";
19
19
  import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js";
20
+ import { buildRunningStep, buildDoneStep, genStepId } from "./thinking-formatter.js";
20
21
  import { DEFAULT_PARTIAL_COALESCE } from "./types.js";
21
22
  import { emitSignal } from "../utils/common.js";
22
23
  import { readSessionHistoryTail } from "../history/index.js";
@@ -32,6 +33,56 @@ const LOCALIZED_ABORT_REPLY = "已停止当前回复。";
32
33
  function localizeAbortReplyText(text) {
33
34
  return OPENCLAW_ABORT_REPLY_RE.test(text.trim()) ? LOCALIZED_ABORT_REPLY : text;
34
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
+ }
35
86
  export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
36
87
  const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
37
88
  // ── 群聊扩展字段 ──
@@ -80,6 +131,21 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
80
131
  const counts = { tool: 0, block: 0, final: 0 };
81
132
  let toolStartCount = 0;
82
133
  let assistantMessageCount = 0;
134
+ // ── thinking_step 关联:onToolStart 生成 stepId,缓存到这里;
135
+ // onItemEvent 拿不到 itemId 时回退到这个 stepId,保证 running ↔ done 能配对。
136
+ let lastToolStepId = null;
137
+ let lastToolName = null;
138
+ // ── onItemEvent(start) 预热信息:
139
+ // 实测 SDK 时序是 onItemEvent(start) 先于 onToolStart(毫秒级),而工具调用串行,
140
+ // 所以用一对全局变量缓存最近一次 start 的 meta/title,onToolStart 取走后由 sink 使用。
141
+ // args 摸不出内容时(如 browser 工具 args.action="open" 没信息量),running 用这些兜底。
142
+ let pendingItemMeta = null;
143
+ let pendingItemTitle = null;
144
+ // ── thinking_step 时间线序号 ──
145
+ // seqCounter: 全局自增,每次 running 帧 +1
146
+ // stepIdToSeq: running 时记录 stepId → seq;done 帧复用此 seq,保证合并行的位置稳定
147
+ let thinkingSeqCounter = 0;
148
+ const thinkingStepSeqMap = new Map();
83
149
  // ── thinking/reasoning 观测(不面向用户,仅为首字延迟归因) ──
84
150
  let reasoningChars = 0;
85
151
  let reasoningFirstAt = 0;
@@ -221,25 +287,44 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
221
287
  }
222
288
  };
223
289
  /**
224
- * 处理 payload 中的 mediaUrls:COS 上传 Markdown 链接 推送给用户。
290
+ * 处理 payload 中的 mediaUrls:去重COS 上传与文本同帧推送给用户。
225
291
  * 仅在 mediaList 非空时调用。
226
292
  *
227
- * 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。
228
- * 私聊模式:通过 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 给前端文件卡片。
229
303
  */
230
304
  const handleMediaFinal = async (replyText, mediaList) => {
231
- const files = await mediaUrlsToFiles(mediaList, log);
305
+ // 去重:mediaUrls 常包含同一文件的多种引用(裸路径 / file:// 前缀),
306
+ // 归一后再交给下游处理,避免 files 数组与 publicUrls 数组各自重复一次。
307
+ const dedupedMediaList = dedupMediaUrls(mediaList);
308
+ const files = await mediaUrlsToFiles(dedupedMediaList, log);
232
309
  const storageConfig = { apiKey: effectiveApiKey };
233
- const publicUrls = [];
234
- for (const mediaUrl of mediaList) {
310
+ /** 仅非图片文件需要在文本里给出公网链接,图片由 files 渲染。 */
311
+ const nonImagePublicLinks = [];
312
+ for (const mediaUrl of dedupedMediaList) {
235
313
  try {
236
314
  const localPath = mediaUrl.startsWith("file://") ? mediaUrl.slice(7) : mediaUrl;
237
315
  if (localPath.startsWith("/") || localPath.match(/^[A-Za-z]:\\/)) {
238
316
  const { existsSync } = await import("node:fs");
239
317
  if (existsSync(localPath)) {
240
318
  const result = await uploadFileToServer(localPath, storageConfig);
241
- publicUrls.push(result.url || "");
242
- 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
+ }
243
328
  }
244
329
  }
245
330
  }
@@ -248,41 +333,33 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
248
333
  }
249
334
  }
250
335
  let enrichedText = replyText;
251
- if (publicUrls.length > 0) {
252
- // 判断是否为图片类型(根据 URL 中的文件扩展名)
253
- const urlSection = publicUrls
254
- .map((url, i) => {
255
- const match = url.match(/filePath=([^&]+)/);
256
- const filePath = match ? decodeURIComponent(match[1]) : "";
257
- const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`;
258
- const ext = fileName.split('.').pop()?.toLowerCase() || '';
259
- const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
260
- // 图片使用 Markdown 图片语法,非图片使用链接语法
261
- return isImage ? `![${fileName}](${url})` : `📎 [${fileName}](${url})`;
262
- })
263
- .join("\n");
336
+ if (nonImagePublicLinks.length > 0) {
337
+ const urlSection = nonImagePublicLinks.join("\n");
264
338
  enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
265
339
  }
266
- // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ──
267
- if (isGroupMode) {
268
- if (enrichedText.trim()) {
269
- const delta = `\n\n${enrichedText}`;
270
- streamedText += delta;
271
- emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta'));
272
- emittedUserVisible = true;
273
- log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`);
274
- }
340
+ // 没有任何可见内容(既无文本又无文件)→ 直接返回,避免发空帧
341
+ if (!enrichedText.trim() && files.length === 0) {
275
342
  return;
276
343
  }
277
- // ── 私聊模式:通过 sendFiles / sendReply 推送 ──
278
- if (files.length > 0) {
279
- emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
280
- emittedUserVisible = true;
281
- }
282
- else if (enrichedText.trim()) {
283
- 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'));
284
351
  emittedUserVisible = true;
352
+ log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${files.length} file(s), ` +
353
+ `nonImageLinks=${nonImagePublicLinks.length}`);
354
+ return;
285
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}`);
286
363
  };
287
364
  // ────────────────────────────────────────
288
365
  // dispatcher
@@ -485,11 +562,19 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
485
562
  }
486
563
  // 在 typing_stop 之前 emit 一帧 usage(如有)。
487
564
  // 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
488
- // abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage,
489
- // 此时不 emit,前端按"无用量数据"渲染。
565
+ //
566
+ // 守卫条件(缺一不可):
567
+ // 1. 非 abort
568
+ // 2. sessionKey 已知(否则无 transcript 可读)
569
+ // 3. 本 sink 真正承载了一次完整的 LLM 输出:hadLLMActivity && (hadStreamedText || hadDispatch)
570
+ //
571
+ // 第 3 条尤为关键:当用户连发消息被 openclaw 合并为 followup 时,被合并那条的 sink
572
+ // 不会有任何 LLM 活动(hadLLMActivity=false),但 transcript 里有上一轮的 usage —— 若
573
+ // 不加守卫就会把上一轮的 usage 误透给这条消息,前端把它当作"该消息已结束"的终态信号,
574
+ // 导致后续真正的回复内容不再绑定到这条消息上。
490
575
  let usageToEmit;
491
- // abort + sessionKey 已知 从 transcript 读 usage
492
- if (!isAborted && sessionKey) {
576
+ const sinkProducedOutput = hadLLMActivity && (hadStreamedText || hadDispatch);
577
+ if (!isAborted && sessionKey && sinkProducedOutput) {
493
578
  try {
494
579
  // 找到本轮最后一条 assistant
495
580
  const tailMessages = readSessionHistoryTail(sessionKey, {
@@ -579,15 +664,43 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
579
664
  onToolStart: (payload) => {
580
665
  if (completed)
581
666
  return;
667
+ // SDK 在 phase=start 和 phase=update 都会回调;只在 start 时认为是"新一次工具调用",
668
+ // update 仅作为同一 stepId 的中间态,不重新计 seq、不发 running 帧(避免重复)。
669
+ const toolPhase = payload.phase ?? "";
670
+ if (toolPhase !== "start") {
671
+ // 仍然透传 tool_start 帧(向下兼容旧前端的 update 渲染),但不动 thinking_step。
672
+ const toolNameForUpdate = payload.name ?? "unknown";
673
+ log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolNameForUpdate} phase=${toolPhase} (skip thinking_step)`);
674
+ emitSignal(signalCtx, "tool_start", "\n\n", { toolName: toolNameForUpdate, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
675
+ return;
676
+ }
582
677
  toolStartCount++;
583
678
  // 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。
584
679
  flushCoalesceBuffer("tool");
585
680
  const toolName = payload.name ?? "unknown";
586
- const toolPhase = payload.phase ?? "";
681
+ const args = payload.args;
587
682
  const isSubagentSpawn = SUBAGENT_TOOL_NAMES.has(toolName);
588
- log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase}` +
683
+ const argsKeys = args ? Object.keys(args).join(",") : "(none)";
684
+ log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase} argsKeys=${argsKeys}` +
589
685
  (isSubagentSpawn ? " (subagent-spawn)" : ""));
590
686
  emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
687
+ // 追加 thinking_step running 帧:和 tool_start 同步发出,前端可二选一渲染。
688
+ // stepId 缓存到 lastToolStepId,onItemEvent(completed) 没拿到 itemId 时回退使用。
689
+ const stepId = genStepId(toolStartCount, toolName);
690
+ lastToolStepId = stepId;
691
+ lastToolName = toolName;
692
+ // 分配并记录 seq;done 帧复用此 seq 保证合并行的时间线位置稳定。
693
+ thinkingSeqCounter += 1;
694
+ const seq = thinkingSeqCounter;
695
+ thinkingStepSeqMap.set(stepId, seq);
696
+ // 取走上一次 onItemEvent(start) 预热的 meta/title 作为 running 文案兜底,
697
+ // 取后立即清空,避免被下一次调用误用。
698
+ const fallbackMeta = pendingItemMeta ?? undefined;
699
+ const fallbackTitle = pendingItemTitle ?? undefined;
700
+ pendingItemMeta = null;
701
+ pendingItemTitle = null;
702
+ const runningStep = buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle);
703
+ emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: runningStep });
591
704
  },
592
705
  onReplyStart: () => {
593
706
  if (completed)
@@ -667,6 +780,45 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
667
780
  if (completed)
668
781
  return;
669
782
  log?.info(`[${CHANNEL_KEY}] [stream] onItemEvent: ${JSON.stringify(payload)}`);
783
+ // SDK 在 exec 工具上会同时发 kind=tool 和 kind=command 两份事件,载荷高度重复。
784
+ // 只采 kind=tool(缺省视为 tool),过滤掉 kind=command 避免时间线重复行。
785
+ const kind = payload.kind ?? "tool";
786
+ if (kind === "command")
787
+ return;
788
+ // start 帧:预热缓存 meta/title,供紧随其后的 onToolStart 拼 running 文案。
789
+ // (实测时序:onItemEvent(start) → onToolStart,间隔毫秒级;工具调用串行不会交叉。)
790
+ const phase = payload.phase ?? "";
791
+ if (phase === "start") {
792
+ pendingItemMeta = payload.meta?.trim() || null;
793
+ pendingItemTitle = payload.title?.trim() || null;
794
+ return;
795
+ }
796
+ // 仅终态事件触发 thinking_step done/error 帧;中间态(running / progress)暂不映射,
797
+ // 避免协议面被 SDK 内部 phase 噪音污染。
798
+ const status = payload.status ?? "";
799
+ const isTerminal = status === "completed" ||
800
+ status === "done" ||
801
+ status === "failed" ||
802
+ status === "error" ||
803
+ status === "cancelled";
804
+ if (!isTerminal)
805
+ return;
806
+ // stepId 关联策略(重要):
807
+ // 实测 openclaw `onItemEvent` 里的 `payload.itemId` 与 `onToolStart` 时生成的
808
+ // stepId 完全对不上(SDK 内部随机分配),如果直接把 itemId 当 stepId 透传出去,
809
+ // 前端按 stepId 合并行就永远落空,结果就是"running 一行 + done 又起一行"。
810
+ // 因此优先复用 running 时缓存的 lastToolStepId,保证 done 帧能就地覆盖到 running 那一行。
811
+ // 仅当不存在 lastToolStepId(极端兜底)时才退回到 itemId / "item-unknown"。
812
+ const fallbackStepId = `item-${payload.itemId ?? "unknown"}`;
813
+ const stepId = lastToolStepId ?? payload.itemId ?? fallbackStepId;
814
+ // 复用 running 时分配的 seq,保证合并行不漂移。
815
+ let seq = thinkingStepSeqMap.get(stepId);
816
+ if (seq === undefined) {
817
+ thinkingSeqCounter += 1;
818
+ seq = thinkingSeqCounter;
819
+ }
820
+ const doneStep = buildDoneStep(payload, stepId, seq, lastToolName ?? undefined);
821
+ emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: doneStep });
670
822
  },
671
823
  onTypingCleanup: () => {
672
824
  log?.info(`[${CHANNEL_KEY}] [stream] onTypingCleanup: counts=${JSON.stringify(counts)} ` +
@@ -0,0 +1,325 @@
1
+ /**
2
+ * LightClaw — 思考过程帧(thinking_step)格式化模块
3
+ *
4
+ * 职责:把 openclaw 的工具回调(onToolStart / onItemEvent)翻译成
5
+ * 协议契约里的 `extra.step` 载荷。前端据此渲染:
6
+ *
7
+ * 💭 接下来将先执行端口检查,确认浏览器启动状态。 ← stream_chunk(旁白)
8
+ * 🔧 执行了 exec: lsof -i :9222 ← thinking_step running
9
+ * 💭 已找到 9222 端口 PID = 12345 ← thinking_step done
10
+ *
11
+ * 协议见 docs/thinking-step-protocol.md。
12
+ */
13
+ // ── 工具名 → 类型/中文短句映射 ──
14
+ //
15
+ // 命中规则按"前缀"匹配,避免 sessions_spawn / sessions.spawn 这种命名差异。
16
+ const TOOL_TYPE_RULES = [
17
+ { test: (n) => n === "exec" || n.startsWith("exec_"), type: "cmd", verb: "执行命令" },
18
+ { test: (n) => n.startsWith("browser"), type: "browser", verb: "操作浏览器" },
19
+ { test: (n) => n === "apply_patch" || n.startsWith("str_replace") || n === "edit_file" || n === "write_file", type: "patch", verb: "编辑文件" },
20
+ { test: (n) => n === "read" || n === "read_file" || n === "view_code_item", type: "tool", verb: "查看文件" },
21
+ { test: (n) => n.startsWith("web_") || n === "fetch", type: "tool", verb: "访问网页" },
22
+ { test: (n) => n.includes("session") && n.includes("spawn"), type: "subagent", verb: "派发子任务" },
23
+ { test: (n) => n === "subagents", type: "subagent", verb: "派发子任务" },
24
+ { test: (n) => n === "update_plan" || n === "plan", type: "plan", verb: "更新计划" },
25
+ ];
26
+ /** 把工具名映射成行级类型 + 默认中文动作短句。 */
27
+ function classifyTool(toolName) {
28
+ for (const rule of TOOL_TYPE_RULES) {
29
+ if (rule.test(toolName))
30
+ return { type: rule.type, verb: rule.verb };
31
+ }
32
+ return { type: "tool", verb: `调用 ${toolName}` };
33
+ }
34
+ /** stepId 生成器:调用方应在每次 onToolStart 时调用一次,把返回值缓存到 lastToolStepId 上。 */
35
+ export function genStepId(seed, toolName) {
36
+ return `step-${seed}-${toolName.replace(/[^a-zA-Z0-9_]/g, "_")}`;
37
+ }
38
+ /**
39
+ * 把 args 摘要成一行人类可读的字符串。优先级:
40
+ * command/cmd/shell > path/file_path/target_file > url/targetUrl > query/keyword > name/title > action > 任意非空字符串
41
+ *
42
+ * 注意 `action` 这种枚举型动词字段(值通常是 "open" / "click" 等单词)被刻意降权,
43
+ * 否则会把更有信息量的 url/targetUrl 压住,导致 running 文案退化为 "执行了 操作浏览器: open"。
44
+ *
45
+ * 超长内容会被截断到 160 字符(后接 …),避免占满前端时间线一行。
46
+ */
47
+ const ARG_SUMMARY_MAX_LEN = 160;
48
+ const ARG_PRIORITY_KEYS = [
49
+ // 命令/路径类(最有信息量)
50
+ "command",
51
+ "cmd",
52
+ "shell",
53
+ "path",
54
+ "file_path",
55
+ "filepath",
56
+ "filePath",
57
+ "target_file",
58
+ // URL 类(browser 工具常用 targetUrl)
59
+ "url",
60
+ "targetUrl",
61
+ "target_url",
62
+ "href",
63
+ // 检索/关键词类
64
+ "query",
65
+ "keyword",
66
+ "text",
67
+ // 标识符类
68
+ "name",
69
+ "title",
70
+ // 元素定位类(browser 点击/输入常用 targetId / selector)
71
+ "targetId",
72
+ "target_id",
73
+ "selector",
74
+ // 枚举动词(信息量最低,放最后兜底)
75
+ "action",
76
+ ];
77
+ function truncate(text) {
78
+ if (text.length <= ARG_SUMMARY_MAX_LEN)
79
+ return text;
80
+ return `${text.slice(0, ARG_SUMMARY_MAX_LEN)}…`;
81
+ }
82
+ function summarizeArgs(args) {
83
+ if (!args || typeof args !== "object")
84
+ return "";
85
+ // 1. 优先级 key 命中
86
+ for (const key of ARG_PRIORITY_KEYS) {
87
+ const val = args[key];
88
+ if (typeof val === "string" && val.trim()) {
89
+ return truncate(val.trim());
90
+ }
91
+ }
92
+ // 2. 任何非空字符串字段
93
+ for (const [, val] of Object.entries(args)) {
94
+ if (typeof val === "string" && val.trim()) {
95
+ return truncate(val.trim());
96
+ }
97
+ }
98
+ // 3. 兜底:序列化整个对象
99
+ try {
100
+ const json = JSON.stringify(args);
101
+ if (json && json !== "{}")
102
+ return truncate(json);
103
+ }
104
+ catch {
105
+ // 循环引用等场景,忽略
106
+ }
107
+ return "";
108
+ }
109
+ // ── browser 工具特化文案 ──
110
+ //
111
+ // 根因:browser 工具下不同 action 对应的有效字段差异很大——
112
+ // - open 用 targetUrl(用户能识别的 URL)
113
+ // - click 用 targetId(DOM 元素内部句柄,如 "t1",无语义信息)
114
+ // - type 用 text(输入内容)
115
+ // - scroll/back/forward/screenshot 等几乎没有可读字段
116
+ //
117
+ // 通用 ARG_PRIORITY_KEYS 把这些字段放同一优先级,导致 click 文案退化为
118
+ // "执行了 操作浏览器: t1" 这类用户完全不知所云的内容。这里按 action 分支
119
+ // 显式映射 verb 与可见字段,避免把内部句柄搬到时间线上。
120
+ const BROWSER_ACTION_VERBS = {
121
+ open: "打开网页",
122
+ navigate: "打开网页",
123
+ goto: "打开网页",
124
+ click: "点击页面元素",
125
+ tap: "点击页面元素",
126
+ type: "输入文本",
127
+ fill: "输入文本",
128
+ input: "输入文本",
129
+ scroll: "滚动页面",
130
+ back: "返回上一页",
131
+ forward: "前进",
132
+ reload: "刷新页面",
133
+ refresh: "刷新页面",
134
+ screenshot: "页面截图",
135
+ snapshot: "页面截图",
136
+ close: "关闭页面",
137
+ };
138
+ /**
139
+ * 按 browser action 生成 {verb, summary}。
140
+ * - 命中已知 action:返回特化 verb,且 summary 仅取真正有用户语义的字段(targetId 等内部句柄不进文案)。
141
+ * - 未命中(含 args 缺失 / action 不在白名单):返回 null,由调用方回退到通用 summarizeArgs。
142
+ */
143
+ function summarizeBrowserArgs(args) {
144
+ if (!args || typeof args !== "object")
145
+ return null;
146
+ const action = args.action;
147
+ if (typeof action !== "string" || !action.trim())
148
+ return null;
149
+ const verb = BROWSER_ACTION_VERBS[action.toLowerCase()];
150
+ if (!verb)
151
+ return null;
152
+ const pickString = (...keys) => {
153
+ for (const k of keys) {
154
+ const v = args[k];
155
+ if (typeof v === "string" && v.trim())
156
+ return truncate(v.trim());
157
+ }
158
+ return "";
159
+ };
160
+ let summary = "";
161
+ switch (action.toLowerCase()) {
162
+ case "open":
163
+ case "navigate":
164
+ case "goto":
165
+ summary = pickString("targetUrl", "url", "target_url", "href");
166
+ break;
167
+ case "click":
168
+ case "tap":
169
+ // targetId 是 DOM 内部句柄(如 "t1"),毫无可读性,刻意不进文案。
170
+ // 优先用 agent 自填的 description / label / name;都没有就只显示 verb。
171
+ summary = pickString("description", "label", "name", "selector");
172
+ break;
173
+ case "type":
174
+ case "fill":
175
+ case "input":
176
+ summary = pickString("text", "content", "value");
177
+ break;
178
+ // scroll / back / forward / reload / screenshot / close 一般无需附带 summary
179
+ default:
180
+ summary = "";
181
+ }
182
+ return { verb, summary };
183
+ }
184
+ /**
185
+ * 把 SDK onItemEvent 给的 title 清掉常见的 toolName 前缀,避免 "browser browser https://..." 这种重复。
186
+ * 例如 title="browser https://www.baidu.com/..."、toolName="browser" → 返回 "https://www.baidu.com/..."。
187
+ */
188
+ function stripToolNamePrefix(title, toolName) {
189
+ if (!title || !toolName)
190
+ return title;
191
+ const trimmed = title.trim();
192
+ if (trimmed.toLowerCase().startsWith(`${toolName.toLowerCase()} `)) {
193
+ return trimmed.slice(toolName.length + 1).trim();
194
+ }
195
+ return trimmed;
196
+ }
197
+ /**
198
+ * T1:工具开始 → running 帧。
199
+ *
200
+ * 文案优先级(自上而下,命中即返回):
201
+ * 1. args 摘要(command/path/url/targetUrl/... 见 ARG_PRIORITY_KEYS)
202
+ * 2. fallbackMeta(来自 SDK onItemEvent 的 payload.meta,通常是裸 URL/路径)
203
+ * 3. fallbackTitle(去掉 toolName 前缀后的剩余部分)
204
+ * 4. 兜底:仅 verb,不带摘要
205
+ *
206
+ * 设计目的:让 running 帧一次性带上有信息量的内容(如 URL),避免后续 done 帧
207
+ * 出现 "覆盖还是不覆盖" 的两难。done 帧仍按原策略:summary/title/meta 全空 → text 留空。
208
+ *
209
+ * @param stepId 调用方维护的步骤 ID(onToolStart 用 genStepId 生成)
210
+ * @param seq 时间线位置序号(由 sink 维护的全局自增计数器)
211
+ * @param toolName 原始工具名
212
+ * @param args onToolStart 透传的工具参数(SDK 已提供)
213
+ * @param fallbackMeta onItemEvent(start) 透传的 meta,args 摸不出内容时兜底
214
+ * @param fallbackTitle onItemEvent(start) 透传的 title,args/meta 都摸不出内容时兜底
215
+ */
216
+ export function buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle) {
217
+ const { type, verb: defaultVerb } = classifyTool(toolName);
218
+ // browser 工具特化:按 action 选择 verb / summary,避免 targetId 这类内部句柄进入文案。
219
+ // 未命中(无 args / action 不在白名单)时返回 null,回退到通用路径。
220
+ let verb = defaultVerb;
221
+ let summary = "";
222
+ let browserHit = false;
223
+ if (type === "browser") {
224
+ const browserSummary = summarizeBrowserArgs(args);
225
+ if (browserSummary) {
226
+ verb = browserSummary.verb;
227
+ summary = browserSummary.summary;
228
+ browserHit = true;
229
+ }
230
+ }
231
+ // 1. 通用 args 摘要(browser 已特化命中则跳过,避免被通用规则覆盖回 targetId 等无语义内容)
232
+ if (!browserHit) {
233
+ summary = summarizeArgs(args);
234
+ }
235
+ // 2. args 没摸出内容 → meta 兜底
236
+ // 注意:browser 特化已命中但 summary 为空(如 screenshot / scroll / back 等无副词 action)时,
237
+ // 不再回退到 meta / title。原因是 SDK 在这些场景的 meta 多为内部句柄(如 "5" / "7" / "1_124"),
238
+ // 一旦兜底就会出现 "执行了 页面截图: 5" 这类噪音。命中即视为"verb 已经讲清楚了"。
239
+ if (!summary && !browserHit && fallbackMeta && fallbackMeta.trim()) {
240
+ summary = truncate(fallbackMeta.trim());
241
+ }
242
+ // 3. meta 也没有 → title 去前缀后兜底(同上,browser 特化命中后不再走 title)
243
+ if (!summary && !browserHit && fallbackTitle && fallbackTitle.trim()) {
244
+ const cleaned = stripToolNamePrefix(fallbackTitle, toolName);
245
+ if (cleaned)
246
+ summary = truncate(cleaned);
247
+ }
248
+ const text = summary ? `执行了 ${verb}: ${summary}` : `执行了 ${verb}`;
249
+ return {
250
+ stepId,
251
+ seq,
252
+ type,
253
+ text,
254
+ status: "running",
255
+ toolName,
256
+ detail: summary || undefined,
257
+ };
258
+ }
259
+ /**
260
+ * T3:工具结束 → done/error 帧。
261
+ *
262
+ * 入参来自 openclaw `onItemEvent` 的 payload,重点字段:
263
+ * - itemId 备用 stepId(实测 SDK 随机生成,与 running 时的 stepId 对不上,
264
+ * 所以本函数优先用 fallbackStepId,由调用方传入 running 时缓存的 lastToolStepId)
265
+ * - status "completed" / "failed" / "error" / ...
266
+ * - summary 一行式结果摘要("已进入腾讯云轻量应用服务器产品页")
267
+ * - title SDK 给的成品文案("browser https://www.baidu.com/..."),多数场景是 running 时 URL 的回声
268
+ * - meta SDK 内部分类(多数场景与 title 同源,是 running 文案的回声)
269
+ * - progressText 中间态进度文案(不适合做最终态摘要)
270
+ *
271
+ * 文案规则(见 docs/thinking-step-protocol.md,v7 收紧):
272
+ * - 成功:**只用 `summary`**(agent 真正写给用户看的结果摘要)。
273
+ * summary 为空时 text 留空,前端识别为"无新文案",仅切状态/图标 spinner→✓,
274
+ * 保留 running 行的原文本(避免 SDK title/meta 是 running URL 回声,覆盖后出现
275
+ * "browser https://www.baidu.com/..." 这类劣化文案)。
276
+ * - 失败:summary > `${verb}失败`。**不再**裸取 title / meta / progressText 作为
277
+ * 主文案,因为实测 SDK 在 onItemEvent(end, status=error) 的 title 常常是
278
+ * `"browser"` / `"browser target t1, ref 1_124"` 这类内部句柄回声,对用户无意义。
279
+ * verb 已按工具类型分类(如 "操作浏览器" / "执行命令"),保证最低有可读语义。
280
+ * title/meta/progressText 改为 detail 折叠展示,方便排障但不污染主文案。
281
+ */
282
+ export function buildDoneStep(payload, fallbackStepId, seq, fallbackToolName) {
283
+ // toolName 优先用 fallbackToolName(来自 running 时缓存的 lastToolName,已是正确值),
284
+ // payload.name 在 onItemEvent 里经常为空 / 取值奇怪,避免把 verb 分类带偏。
285
+ const toolName = fallbackToolName ?? payload.name ?? "unknown";
286
+ const { type, verb } = classifyTool(toolName);
287
+ const isError = payload.status === "failed" ||
288
+ payload.status === "error" ||
289
+ payload.status === "cancelled";
290
+ const summary = (payload.summary ?? "").trim();
291
+ const title = (payload.title ?? "").trim();
292
+ const meta = (payload.meta ?? "").trim();
293
+ const progress = (payload.progressText ?? "").trim();
294
+ // 成功路径:只采纳 summary(agent 总结的真摘要)。title/meta 实测多为 running 文案回声,
295
+ // 不再作为成功 done 的 text 来源;progress 是中间态,也不适合做最终态。
296
+ // 失败路径:v7 收紧 —— 不再裸取 title / meta / progressText(实测多为 SDK 内部句柄回声,
297
+ // 如 `"browser"` / `"browser target t1, ref 1_124"` 这类对用户无意义的内容)。
298
+ // 只采纳 summary(agent 写的失败原因);缺失则用 `${verb}失败`,verb 已按工具
299
+ // 类型分类(如 "操作浏览器" / "执行命令"),保证有可读语义。
300
+ // title/meta/progressText 改为 detail 折叠展示,方便排障但不出现在主文案。
301
+ const text = isError
302
+ ? summary || `${verb}失败`
303
+ : summary;
304
+ // detail 优先级:失败时把 SDK 的原始 title / meta / progress 拼起来供折叠查看(排障用);
305
+ // 成功时仅把进度文案塞进去(与 v5 一致)。
306
+ let detail;
307
+ if (isError) {
308
+ const debugParts = [title, meta, progress].filter((s) => s && s !== text);
309
+ detail = debugParts.length > 0 ? debugParts.join(" · ") : undefined;
310
+ }
311
+ else {
312
+ detail = progress && progress !== text ? progress : undefined;
313
+ }
314
+ return {
315
+ // stepId 优先用 fallbackStepId(调用方传入的 lastToolStepId),保证前端按 stepId
316
+ // 合并到 running 那一行;payload.itemId 因 SDK 随机分配,仅作最后兜底。
317
+ stepId: fallbackStepId || payload.itemId || "unknown",
318
+ seq,
319
+ type,
320
+ text,
321
+ status: isError ? "error" : "done",
322
+ toolName,
323
+ detail,
324
+ };
325
+ }
@@ -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.7",
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": [