lightclawbot
Advanced tools
| /** | ||
| * LightClaw — 思考过程帧(thinking_step)格式化模块 | ||
| * | ||
| * 职责:把 openclaw 的工具回调(onToolStart / onItemEvent)翻译成 | ||
| * 协议契约里的 `extra.step` 载荷。前端据此渲染: | ||
| * | ||
| * 💭 接下来将先执行端口检查,确认浏览器启动状态。 ← stream_chunk(旁白) | ||
| * 🔧 执行了 exec: lsof -i :9222 ← thinking_step running | ||
| * 💭 已找到 9222 端口 PID = 12345 ← thinking_step done | ||
| * | ||
| * 协议见 docs/thinking-step-protocol.md。 | ||
| */ | ||
| // ── 工具名 → 类型/中文短句映射 ── | ||
| // | ||
| // 命中规则按"前缀"匹配,避免 sessions_spawn / sessions.spawn 这种命名差异。 | ||
| const TOOL_TYPE_RULES = [ | ||
| { test: (n) => n === "exec" || n.startsWith("exec_"), type: "cmd", verb: "执行命令" }, | ||
| { test: (n) => n.startsWith("browser"), type: "browser", verb: "操作浏览器" }, | ||
| { test: (n) => n === "apply_patch" || n.startsWith("str_replace") || n === "edit_file" || n === "write_file", type: "patch", verb: "编辑文件" }, | ||
| { test: (n) => n === "read" || n === "read_file" || n === "view_code_item", type: "tool", verb: "查看文件" }, | ||
| { test: (n) => n.startsWith("web_") || n === "fetch", type: "tool", verb: "访问网页" }, | ||
| { test: (n) => n.includes("session") && n.includes("spawn"), type: "subagent", verb: "派发子任务" }, | ||
| { test: (n) => n === "subagents", type: "subagent", verb: "派发子任务" }, | ||
| { test: (n) => n === "update_plan" || n === "plan", type: "plan", verb: "更新计划" }, | ||
| ]; | ||
| /** 把工具名映射成行级类型 + 默认中文动作短句。 */ | ||
| function classifyTool(toolName) { | ||
| for (const rule of TOOL_TYPE_RULES) { | ||
| if (rule.test(toolName)) | ||
| return { type: rule.type, verb: rule.verb }; | ||
| } | ||
| return { type: "tool", verb: `调用 ${toolName}` }; | ||
| } | ||
| /** stepId 生成器:调用方应在每次 onToolStart 时调用一次,把返回值缓存到 lastToolStepId 上。 */ | ||
| export function genStepId(seed, toolName) { | ||
| return `step-${seed}-${toolName.replace(/[^a-zA-Z0-9_]/g, "_")}`; | ||
| } | ||
| /** | ||
| * 把 args 摘要成一行人类可读的字符串。优先级: | ||
| * command/cmd/shell > path/file_path/target_file > url/targetUrl > query/keyword > name/title > action > 任意非空字符串 | ||
| * | ||
| * 注意 `action` 这种枚举型动词字段(值通常是 "open" / "click" 等单词)被刻意降权, | ||
| * 否则会把更有信息量的 url/targetUrl 压住,导致 running 文案退化为 "执行了 操作浏览器: open"。 | ||
| * | ||
| * 超长内容会被截断到 160 字符(后接 …),避免占满前端时间线一行。 | ||
| */ | ||
| const ARG_SUMMARY_MAX_LEN = 160; | ||
| const ARG_PRIORITY_KEYS = [ | ||
| // 命令/路径类(最有信息量) | ||
| "command", | ||
| "cmd", | ||
| "shell", | ||
| "path", | ||
| "file_path", | ||
| "filepath", | ||
| "filePath", | ||
| "target_file", | ||
| // URL 类(browser 工具常用 targetUrl) | ||
| "url", | ||
| "targetUrl", | ||
| "target_url", | ||
| "href", | ||
| // 检索/关键词类 | ||
| "query", | ||
| "keyword", | ||
| "text", | ||
| // 标识符类 | ||
| "name", | ||
| "title", | ||
| // 元素定位类(browser 点击/输入常用 targetId / selector) | ||
| "targetId", | ||
| "target_id", | ||
| "selector", | ||
| // 枚举动词(信息量最低,放最后兜底) | ||
| "action", | ||
| ]; | ||
| function truncate(text) { | ||
| if (text.length <= ARG_SUMMARY_MAX_LEN) | ||
| return text; | ||
| return `${text.slice(0, ARG_SUMMARY_MAX_LEN)}…`; | ||
| } | ||
| function summarizeArgs(args) { | ||
| if (!args || typeof args !== "object") | ||
| return ""; | ||
| // 1. 优先级 key 命中 | ||
| for (const key of ARG_PRIORITY_KEYS) { | ||
| const val = args[key]; | ||
| if (typeof val === "string" && val.trim()) { | ||
| return truncate(val.trim()); | ||
| } | ||
| } | ||
| // 2. 任何非空字符串字段 | ||
| for (const [, val] of Object.entries(args)) { | ||
| if (typeof val === "string" && val.trim()) { | ||
| return truncate(val.trim()); | ||
| } | ||
| } | ||
| // 3. 兜底:序列化整个对象 | ||
| try { | ||
| const json = JSON.stringify(args); | ||
| if (json && json !== "{}") | ||
| return truncate(json); | ||
| } | ||
| catch { | ||
| // 循环引用等场景,忽略 | ||
| } | ||
| return ""; | ||
| } | ||
| // ── browser 工具特化文案 ── | ||
| // | ||
| // 根因:browser 工具下不同 action 对应的有效字段差异很大—— | ||
| // - open 用 targetUrl(用户能识别的 URL) | ||
| // - click 用 targetId(DOM 元素内部句柄,如 "t1",无语义信息) | ||
| // - type 用 text(输入内容) | ||
| // - scroll/back/forward/screenshot 等几乎没有可读字段 | ||
| // | ||
| // 通用 ARG_PRIORITY_KEYS 把这些字段放同一优先级,导致 click 文案退化为 | ||
| // "执行了 操作浏览器: t1" 这类用户完全不知所云的内容。这里按 action 分支 | ||
| // 显式映射 verb 与可见字段,避免把内部句柄搬到时间线上。 | ||
| const BROWSER_ACTION_VERBS = { | ||
| open: "打开网页", | ||
| navigate: "打开网页", | ||
| goto: "打开网页", | ||
| click: "点击页面元素", | ||
| tap: "点击页面元素", | ||
| type: "输入文本", | ||
| fill: "输入文本", | ||
| input: "输入文本", | ||
| scroll: "滚动页面", | ||
| back: "返回上一页", | ||
| forward: "前进", | ||
| reload: "刷新页面", | ||
| refresh: "刷新页面", | ||
| screenshot: "页面截图", | ||
| snapshot: "页面截图", | ||
| close: "关闭页面", | ||
| }; | ||
| /** | ||
| * 按 browser action 生成 {verb, summary}。 | ||
| * - 命中已知 action:返回特化 verb,且 summary 仅取真正有用户语义的字段(targetId 等内部句柄不进文案)。 | ||
| * - 未命中(含 args 缺失 / action 不在白名单):返回 null,由调用方回退到通用 summarizeArgs。 | ||
| */ | ||
| function summarizeBrowserArgs(args) { | ||
| if (!args || typeof args !== "object") | ||
| return null; | ||
| const action = args.action; | ||
| if (typeof action !== "string" || !action.trim()) | ||
| return null; | ||
| const verb = BROWSER_ACTION_VERBS[action.toLowerCase()]; | ||
| if (!verb) | ||
| return null; | ||
| const pickString = (...keys) => { | ||
| for (const k of keys) { | ||
| const v = args[k]; | ||
| if (typeof v === "string" && v.trim()) | ||
| return truncate(v.trim()); | ||
| } | ||
| return ""; | ||
| }; | ||
| let summary = ""; | ||
| switch (action.toLowerCase()) { | ||
| case "open": | ||
| case "navigate": | ||
| case "goto": | ||
| summary = pickString("targetUrl", "url", "target_url", "href"); | ||
| break; | ||
| case "click": | ||
| case "tap": | ||
| // targetId 是 DOM 内部句柄(如 "t1"),毫无可读性,刻意不进文案。 | ||
| // 优先用 agent 自填的 description / label / name;都没有就只显示 verb。 | ||
| summary = pickString("description", "label", "name", "selector"); | ||
| break; | ||
| case "type": | ||
| case "fill": | ||
| case "input": | ||
| summary = pickString("text", "content", "value"); | ||
| break; | ||
| // scroll / back / forward / reload / screenshot / close 一般无需附带 summary | ||
| default: | ||
| summary = ""; | ||
| } | ||
| return { verb, summary }; | ||
| } | ||
| /** | ||
| * 把 SDK onItemEvent 给的 title 清掉常见的 toolName 前缀,避免 "browser browser https://..." 这种重复。 | ||
| * 例如 title="browser https://www.baidu.com/..."、toolName="browser" → 返回 "https://www.baidu.com/..."。 | ||
| */ | ||
| function stripToolNamePrefix(title, toolName) { | ||
| if (!title || !toolName) | ||
| return title; | ||
| const trimmed = title.trim(); | ||
| if (trimmed.toLowerCase().startsWith(`${toolName.toLowerCase()} `)) { | ||
| return trimmed.slice(toolName.length + 1).trim(); | ||
| } | ||
| return trimmed; | ||
| } | ||
| /** | ||
| * T1:工具开始 → running 帧。 | ||
| * | ||
| * 文案优先级(自上而下,命中即返回): | ||
| * 1. args 摘要(command/path/url/targetUrl/... 见 ARG_PRIORITY_KEYS) | ||
| * 2. fallbackMeta(来自 SDK onItemEvent 的 payload.meta,通常是裸 URL/路径) | ||
| * 3. fallbackTitle(去掉 toolName 前缀后的剩余部分) | ||
| * 4. 兜底:仅 verb,不带摘要 | ||
| * | ||
| * 设计目的:让 running 帧一次性带上有信息量的内容(如 URL),避免后续 done 帧 | ||
| * 出现 "覆盖还是不覆盖" 的两难。done 帧仍按原策略:summary/title/meta 全空 → text 留空。 | ||
| * | ||
| * @param stepId 调用方维护的步骤 ID(onToolStart 用 genStepId 生成) | ||
| * @param seq 时间线位置序号(由 sink 维护的全局自增计数器) | ||
| * @param toolName 原始工具名 | ||
| * @param args onToolStart 透传的工具参数(SDK 已提供) | ||
| * @param fallbackMeta onItemEvent(start) 透传的 meta,args 摸不出内容时兜底 | ||
| * @param fallbackTitle onItemEvent(start) 透传的 title,args/meta 都摸不出内容时兜底 | ||
| */ | ||
| export function buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle) { | ||
| const { type, verb: defaultVerb } = classifyTool(toolName); | ||
| // browser 工具特化:按 action 选择 verb / summary,避免 targetId 这类内部句柄进入文案。 | ||
| // 未命中(无 args / action 不在白名单)时返回 null,回退到通用路径。 | ||
| let verb = defaultVerb; | ||
| let summary = ""; | ||
| let browserHit = false; | ||
| if (type === "browser") { | ||
| const browserSummary = summarizeBrowserArgs(args); | ||
| if (browserSummary) { | ||
| verb = browserSummary.verb; | ||
| summary = browserSummary.summary; | ||
| browserHit = true; | ||
| } | ||
| } | ||
| // 1. 通用 args 摘要(browser 已特化命中则跳过,避免被通用规则覆盖回 targetId 等无语义内容) | ||
| if (!browserHit) { | ||
| summary = summarizeArgs(args); | ||
| } | ||
| // 2. args 没摸出内容 → meta 兜底 | ||
| // 注意:browser 特化已命中但 summary 为空(如 screenshot / scroll / back 等无副词 action)时, | ||
| // 不再回退到 meta / title。原因是 SDK 在这些场景的 meta 多为内部句柄(如 "5" / "7" / "1_124"), | ||
| // 一旦兜底就会出现 "执行了 页面截图: 5" 这类噪音。命中即视为"verb 已经讲清楚了"。 | ||
| if (!summary && !browserHit && fallbackMeta && fallbackMeta.trim()) { | ||
| summary = truncate(fallbackMeta.trim()); | ||
| } | ||
| // 3. meta 也没有 → title 去前缀后兜底(同上,browser 特化命中后不再走 title) | ||
| if (!summary && !browserHit && fallbackTitle && fallbackTitle.trim()) { | ||
| const cleaned = stripToolNamePrefix(fallbackTitle, toolName); | ||
| if (cleaned) | ||
| summary = truncate(cleaned); | ||
| } | ||
| const text = summary ? `执行了 ${verb}: ${summary}` : `执行了 ${verb}`; | ||
| return { | ||
| stepId, | ||
| seq, | ||
| type, | ||
| text, | ||
| status: "running", | ||
| toolName, | ||
| detail: summary || undefined, | ||
| }; | ||
| } | ||
| /** | ||
| * T3:工具结束 → done/error 帧。 | ||
| * | ||
| * 入参来自 openclaw `onItemEvent` 的 payload,重点字段: | ||
| * - itemId 备用 stepId(实测 SDK 随机生成,与 running 时的 stepId 对不上, | ||
| * 所以本函数优先用 fallbackStepId,由调用方传入 running 时缓存的 lastToolStepId) | ||
| * - status "completed" / "failed" / "error" / ... | ||
| * - summary 一行式结果摘要("已进入腾讯云轻量应用服务器产品页") | ||
| * - title SDK 给的成品文案("browser https://www.baidu.com/..."),多数场景是 running 时 URL 的回声 | ||
| * - meta SDK 内部分类(多数场景与 title 同源,是 running 文案的回声) | ||
| * - progressText 中间态进度文案(不适合做最终态摘要) | ||
| * | ||
| * 文案规则(见 docs/thinking-step-protocol.md,v7 收紧): | ||
| * - 成功:**只用 `summary`**(agent 真正写给用户看的结果摘要)。 | ||
| * summary 为空时 text 留空,前端识别为"无新文案",仅切状态/图标 spinner→✓, | ||
| * 保留 running 行的原文本(避免 SDK title/meta 是 running URL 回声,覆盖后出现 | ||
| * "browser https://www.baidu.com/..." 这类劣化文案)。 | ||
| * - 失败:summary > `${verb}失败`。**不再**裸取 title / meta / progressText 作为 | ||
| * 主文案,因为实测 SDK 在 onItemEvent(end, status=error) 的 title 常常是 | ||
| * `"browser"` / `"browser target t1, ref 1_124"` 这类内部句柄回声,对用户无意义。 | ||
| * verb 已按工具类型分类(如 "操作浏览器" / "执行命令"),保证最低有可读语义。 | ||
| * title/meta/progressText 改为 detail 折叠展示,方便排障但不污染主文案。 | ||
| */ | ||
| export function buildDoneStep(payload, fallbackStepId, seq, fallbackToolName) { | ||
| // toolName 优先用 fallbackToolName(来自 running 时缓存的 lastToolName,已是正确值), | ||
| // payload.name 在 onItemEvent 里经常为空 / 取值奇怪,避免把 verb 分类带偏。 | ||
| const toolName = fallbackToolName ?? payload.name ?? "unknown"; | ||
| const { type, verb } = classifyTool(toolName); | ||
| const isError = payload.status === "failed" || | ||
| payload.status === "error" || | ||
| payload.status === "cancelled"; | ||
| const summary = (payload.summary ?? "").trim(); | ||
| const title = (payload.title ?? "").trim(); | ||
| const meta = (payload.meta ?? "").trim(); | ||
| const progress = (payload.progressText ?? "").trim(); | ||
| // 成功路径:只采纳 summary(agent 总结的真摘要)。title/meta 实测多为 running 文案回声, | ||
| // 不再作为成功 done 的 text 来源;progress 是中间态,也不适合做最终态。 | ||
| // 失败路径:v7 收紧 —— 不再裸取 title / meta / progressText(实测多为 SDK 内部句柄回声, | ||
| // 如 `"browser"` / `"browser target t1, ref 1_124"` 这类对用户无意义的内容)。 | ||
| // 只采纳 summary(agent 写的失败原因);缺失则用 `${verb}失败`,verb 已按工具 | ||
| // 类型分类(如 "操作浏览器" / "执行命令"),保证有可读语义。 | ||
| // title/meta/progressText 改为 detail 折叠展示,方便排障但不出现在主文案。 | ||
| const text = isError | ||
| ? summary || `${verb}失败` | ||
| : summary; | ||
| // detail 优先级:失败时把 SDK 的原始 title / meta / progress 拼起来供折叠查看(排障用); | ||
| // 成功时仅把进度文案塞进去(与 v5 一致)。 | ||
| let detail; | ||
| if (isError) { | ||
| const debugParts = [title, meta, progress].filter((s) => s && s !== text); | ||
| detail = debugParts.length > 0 ? debugParts.join(" · ") : undefined; | ||
| } | ||
| else { | ||
| detail = progress && progress !== text ? progress : undefined; | ||
| } | ||
| return { | ||
| // stepId 优先用 fallbackStepId(调用方传入的 lastToolStepId),保证前端按 stepId | ||
| // 合并到 running 那一行;payload.itemId 因 SDK 随机分配,仅作最后兜底。 | ||
| stepId: fallbackStepId || payload.itemId || "unknown", | ||
| seq, | ||
| type, | ||
| text, | ||
| status: isError ? "error" : "done", | ||
| toolName, | ||
| detail, | ||
| }; | ||
| } |
+30
-3
@@ -58,5 +58,32 @@ /** | ||
| const sessionKeyToApiKey = new Map(); | ||
| /** 设置 apiKeyMap(gateway 启动时调用) */ | ||
| export function setApiKeyMap(map, defaultApiKey) { | ||
| globalApiKeyMap = map; | ||
| /** 合并单条 account 的 apiKey 到全局映射(gateway 启动时调用) */ | ||
| export function addApiKeyToMap(accountId, apiKey) { | ||
| globalApiKeyMap.set(accountId, apiKey); | ||
| // 仅在尚未设置 defaultApiKey 时设置(取第一个 account 的 key 作为默认) | ||
| if (!globalDefaultApiKey) { | ||
| globalDefaultApiKey = apiKey; | ||
| } | ||
| } | ||
| /** | ||
| * 一次性构建全局 apiKeyMap(插件初始化时调用)。 | ||
| * | ||
| * 遍历配置中所有 accounts,将 accountId→apiKey 映射一次性写入全局 Map, | ||
| * 避免每个 account 启动 gateway 时覆盖其他 account 的映射。 | ||
| * | ||
| * @param cfg - OpenClaw 全局配置对象 | ||
| */ | ||
| export function buildGlobalApiKeyMap(cfg) { | ||
| const channels = cfg?.channels; | ||
| const channelConfig = channels?.[CHANNEL_KEY]; | ||
| if (!channelConfig?.accounts) | ||
| return; | ||
| const accounts = channelConfig.accounts; | ||
| let defaultApiKey = globalDefaultApiKey; // 保留已有的 default | ||
| for (const [accountId, accountConfig] of Object.entries(accounts)) { | ||
| if (accountConfig?.apiKey) { | ||
| globalApiKeyMap.set(accountId, accountConfig.apiKey); | ||
| if (!defaultApiKey) | ||
| defaultApiKey = accountConfig.apiKey; | ||
| } | ||
| } | ||
| globalDefaultApiKey = defaultApiKey; | ||
@@ -63,0 +90,0 @@ } |
@@ -16,3 +16,3 @@ /** | ||
| import { NativeSocketClient } from './socket/native-socket.js'; | ||
| import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, setApiKeyMap, } from './config.js'; | ||
| 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'; | ||
| import { generateMsgId } from './dedup.js'; | ||
@@ -99,3 +99,3 @@ import { createInboundHandler } from './inbound.js'; | ||
| export async function startGateway(ctx) { | ||
| const { account, abortSignal, onReady, onDisconnect, onEvent, log } = ctx; | ||
| const { account, abortSignal, cfg, onReady, onDisconnect, onEvent, log } = ctx; | ||
| const prefix = `[${CHANNEL_KEY}:${account.accountId}]`; | ||
@@ -118,7 +118,9 @@ // 判断是否存在有效的apikey | ||
| const { botClientId, ticket } = await resolveBotClientId(account.apiKey, log); | ||
| // 构建 uin→apiKey 映射表(新格式下每个 account 只有一条记录) | ||
| const apiKeyMap = new Map([[account.accountId, account.apiKey]]); | ||
| // 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作) | ||
| // 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题 | ||
| buildGlobalApiKeyMap(cfg); | ||
| // 将当前 account 的 uin→apiKey 映射合并到全局 config 模块 | ||
| // 确保 gateway 重启/reconnect 时映射仍然完整 | ||
| addApiKeyToMap(account.accountId, account.apiKey); | ||
| log?.info(`${prefix} Bot clientId: ${botClientId}, uin=${account.accountId} → apiKey=***${account.apiKey.slice(-4)}`); | ||
| // 将映射表写入全局 config 模块,供 inbound 处理时按 uin 查找对应 apiKey | ||
| setApiKeyMap(apiKeyMap, account.apiKey); | ||
| /** Gateway 是否已被 abort(用于终止消息处理循环) */ | ||
@@ -125,0 +127,0 @@ let isAborted = false; |
@@ -341,4 +341,10 @@ /** | ||
| // 过滤 openclaw delivery-mirror 消息(镜像投递,非真实消息) | ||
| if (msg.model === "delivery-mirror") | ||
| continue; | ||
| // 但对 cron 直接投递的消息放行(用户需要看到定时提醒内容) | ||
| if (msg.model === "delivery-mirror") { | ||
| const idempotencyKey = msg.idempotencyKey | ||
| ?? parsed.idempotencyKey; | ||
| if (!idempotencyKey || !idempotencyKey.startsWith("cron-direct-delivery:")) { | ||
| continue; | ||
| } | ||
| } | ||
| // 过滤传输层伪装为 user 的系统注入消息 | ||
@@ -345,0 +351,0 @@ if (isSystemInjectedUserMessage(msg)) |
@@ -20,2 +20,3 @@ /** | ||
| import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js"; | ||
| import { buildRunningStep, buildDoneStep, genStepId } from "./thinking-formatter.js"; | ||
| import { DEFAULT_PARTIAL_COALESCE } from "./types.js"; | ||
@@ -84,2 +85,17 @@ import { emitSignal } from "../utils/common.js"; | ||
| let assistantMessageCount = 0; | ||
| // ── thinking_step 关联:onToolStart 生成 stepId,缓存到这里; | ||
| // onItemEvent 拿不到 itemId 时回退到这个 stepId,保证 running ↔ done 能配对。 | ||
| let lastToolStepId = null; | ||
| let lastToolName = null; | ||
| // ── onItemEvent(start) 预热信息: | ||
| // 实测 SDK 时序是 onItemEvent(start) 先于 onToolStart(毫秒级),而工具调用串行, | ||
| // 所以用一对全局变量缓存最近一次 start 的 meta/title,onToolStart 取走后由 sink 使用。 | ||
| // args 摸不出内容时(如 browser 工具 args.action="open" 没信息量),running 用这些兜底。 | ||
| let pendingItemMeta = null; | ||
| let pendingItemTitle = null; | ||
| // ── thinking_step 时间线序号 ── | ||
| // seqCounter: 全局自增,每次 running 帧 +1 | ||
| // stepIdToSeq: running 时记录 stepId → seq;done 帧复用此 seq,保证合并行的位置稳定 | ||
| let thinkingSeqCounter = 0; | ||
| const thinkingStepSeqMap = new Map(); | ||
| // ── thinking/reasoning 观测(不面向用户,仅为首字延迟归因) ── | ||
@@ -490,7 +506,15 @@ let reasoningChars = 0; | ||
| // 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。 | ||
| // abort / 静默 dispatch / NO_REPLY 等异常路径下 transcript 可能没有可读 usage, | ||
| // 此时不 emit,前端按"无用量数据"渲染。 | ||
| // | ||
| // 守卫条件(缺一不可): | ||
| // 1. 非 abort | ||
| // 2. sessionKey 已知(否则无 transcript 可读) | ||
| // 3. 本 sink 真正承载了一次完整的 LLM 输出:hadLLMActivity && (hadStreamedText || hadDispatch) | ||
| // | ||
| // 第 3 条尤为关键:当用户连发消息被 openclaw 合并为 followup 时,被合并那条的 sink | ||
| // 不会有任何 LLM 活动(hadLLMActivity=false),但 transcript 里有上一轮的 usage —— 若 | ||
| // 不加守卫就会把上一轮的 usage 误透给这条消息,前端把它当作"该消息已结束"的终态信号, | ||
| // 导致后续真正的回复内容不再绑定到这条消息上。 | ||
| let usageToEmit; | ||
| // 非 abort + sessionKey 已知 → 从 transcript 读 usage | ||
| if (!isAborted && sessionKey) { | ||
| const sinkProducedOutput = hadLLMActivity && (hadStreamedText || hadDispatch); | ||
| if (!isAborted && sessionKey && sinkProducedOutput) { | ||
| try { | ||
@@ -585,2 +609,12 @@ // 找到本轮最后一条 assistant | ||
| return; | ||
| // SDK 在 phase=start 和 phase=update 都会回调;只在 start 时认为是"新一次工具调用", | ||
| // update 仅作为同一 stepId 的中间态,不重新计 seq、不发 running 帧(避免重复)。 | ||
| const toolPhase = payload.phase ?? ""; | ||
| if (toolPhase !== "start") { | ||
| // 仍然透传 tool_start 帧(向下兼容旧前端的 update 渲染),但不动 thinking_step。 | ||
| const toolNameForUpdate = payload.name ?? "unknown"; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolNameForUpdate} phase=${toolPhase} (skip thinking_step)`); | ||
| emitSignal(signalCtx, "tool_start", "\n\n", { toolName: toolNameForUpdate, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta')); | ||
| return; | ||
| } | ||
| toolStartCount++; | ||
@@ -590,7 +624,25 @@ // 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。 | ||
| const toolName = payload.name ?? "unknown"; | ||
| const toolPhase = payload.phase ?? ""; | ||
| const args = payload.args; | ||
| const isSubagentSpawn = SUBAGENT_TOOL_NAMES.has(toolName); | ||
| log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase}` + | ||
| const argsKeys = args ? Object.keys(args).join(",") : "(none)"; | ||
| log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase} argsKeys=${argsKeys}` + | ||
| (isSubagentSpawn ? " (subagent-spawn)" : "")); | ||
| emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta')); | ||
| // 追加 thinking_step running 帧:和 tool_start 同步发出,前端可二选一渲染。 | ||
| // stepId 缓存到 lastToolStepId,onItemEvent(completed) 没拿到 itemId 时回退使用。 | ||
| const stepId = genStepId(toolStartCount, toolName); | ||
| lastToolStepId = stepId; | ||
| lastToolName = toolName; | ||
| // 分配并记录 seq;done 帧复用此 seq 保证合并行的时间线位置稳定。 | ||
| thinkingSeqCounter += 1; | ||
| const seq = thinkingSeqCounter; | ||
| thinkingStepSeqMap.set(stepId, seq); | ||
| // 取走上一次 onItemEvent(start) 预热的 meta/title 作为 running 文案兜底, | ||
| // 取后立即清空,避免被下一次调用误用。 | ||
| const fallbackMeta = pendingItemMeta ?? undefined; | ||
| const fallbackTitle = pendingItemTitle ?? undefined; | ||
| pendingItemMeta = null; | ||
| pendingItemTitle = null; | ||
| const runningStep = buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle); | ||
| emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: runningStep }); | ||
| }, | ||
@@ -675,2 +727,41 @@ onReplyStart: () => { | ||
| log?.info(`[${CHANNEL_KEY}] [stream] onItemEvent: ${JSON.stringify(payload)}`); | ||
| // SDK 在 exec 工具上会同时发 kind=tool 和 kind=command 两份事件,载荷高度重复。 | ||
| // 只采 kind=tool(缺省视为 tool),过滤掉 kind=command 避免时间线重复行。 | ||
| const kind = payload.kind ?? "tool"; | ||
| if (kind === "command") | ||
| return; | ||
| // start 帧:预热缓存 meta/title,供紧随其后的 onToolStart 拼 running 文案。 | ||
| // (实测时序:onItemEvent(start) → onToolStart,间隔毫秒级;工具调用串行不会交叉。) | ||
| const phase = payload.phase ?? ""; | ||
| if (phase === "start") { | ||
| pendingItemMeta = payload.meta?.trim() || null; | ||
| pendingItemTitle = payload.title?.trim() || null; | ||
| return; | ||
| } | ||
| // 仅终态事件触发 thinking_step done/error 帧;中间态(running / progress)暂不映射, | ||
| // 避免协议面被 SDK 内部 phase 噪音污染。 | ||
| const status = payload.status ?? ""; | ||
| const isTerminal = status === "completed" || | ||
| status === "done" || | ||
| status === "failed" || | ||
| status === "error" || | ||
| status === "cancelled"; | ||
| if (!isTerminal) | ||
| return; | ||
| // stepId 关联策略(重要): | ||
| // 实测 openclaw `onItemEvent` 里的 `payload.itemId` 与 `onToolStart` 时生成的 | ||
| // stepId 完全对不上(SDK 内部随机分配),如果直接把 itemId 当 stepId 透传出去, | ||
| // 前端按 stepId 合并行就永远落空,结果就是"running 一行 + done 又起一行"。 | ||
| // 因此优先复用 running 时缓存的 lastToolStepId,保证 done 帧能就地覆盖到 running 那一行。 | ||
| // 仅当不存在 lastToolStepId(极端兜底)时才退回到 itemId / "item-unknown"。 | ||
| const fallbackStepId = `item-${payload.itemId ?? "unknown"}`; | ||
| const stepId = lastToolStepId ?? payload.itemId ?? fallbackStepId; | ||
| // 复用 running 时分配的 seq,保证合并行不漂移。 | ||
| let seq = thinkingStepSeqMap.get(stepId); | ||
| if (seq === undefined) { | ||
| thinkingSeqCounter += 1; | ||
| seq = thinkingSeqCounter; | ||
| } | ||
| const doneStep = buildDoneStep(payload, stepId, seq, lastToolName ?? undefined); | ||
| emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: doneStep }); | ||
| }, | ||
@@ -677,0 +768,0 @@ onTypingCleanup: () => { |
+1
-1
| { | ||
| "name": "lightclawbot", | ||
| "version": "1.2.7", | ||
| "version": "1.2.8", | ||
| "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.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
805861
2.93%147
0.68%19217
2.4%