lightclawbot 1.2.7 → 1.2.8
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
CHANGED
|
@@ -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
|
-
/**
|
|
59
|
-
export function
|
|
60
|
-
globalApiKeyMap
|
|
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 处理消息时调用) */
|
package/dist/src/gateway.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
117
|
-
|
|
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) */
|
|
@@ -338,8 +338,14 @@ function parseLines(lines, opts) {
|
|
|
338
338
|
if (!msg || typeof msg !== "object")
|
|
339
339
|
continue;
|
|
340
340
|
// 过滤 openclaw delivery-mirror 消息(镜像投递,非真实消息)
|
|
341
|
-
|
|
342
|
-
|
|
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;
|
|
@@ -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";
|
|
@@ -80,6 +81,21 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
80
81
|
const counts = { tool: 0, block: 0, final: 0 };
|
|
81
82
|
let toolStartCount = 0;
|
|
82
83
|
let assistantMessageCount = 0;
|
|
84
|
+
// ── thinking_step 关联:onToolStart 生成 stepId,缓存到这里;
|
|
85
|
+
// onItemEvent 拿不到 itemId 时回退到这个 stepId,保证 running ↔ done 能配对。
|
|
86
|
+
let lastToolStepId = null;
|
|
87
|
+
let lastToolName = null;
|
|
88
|
+
// ── onItemEvent(start) 预热信息:
|
|
89
|
+
// 实测 SDK 时序是 onItemEvent(start) 先于 onToolStart(毫秒级),而工具调用串行,
|
|
90
|
+
// 所以用一对全局变量缓存最近一次 start 的 meta/title,onToolStart 取走后由 sink 使用。
|
|
91
|
+
// args 摸不出内容时(如 browser 工具 args.action="open" 没信息量),running 用这些兜底。
|
|
92
|
+
let pendingItemMeta = null;
|
|
93
|
+
let pendingItemTitle = null;
|
|
94
|
+
// ── thinking_step 时间线序号 ──
|
|
95
|
+
// seqCounter: 全局自增,每次 running 帧 +1
|
|
96
|
+
// stepIdToSeq: running 时记录 stepId → seq;done 帧复用此 seq,保证合并行的位置稳定
|
|
97
|
+
let thinkingSeqCounter = 0;
|
|
98
|
+
const thinkingStepSeqMap = new Map();
|
|
83
99
|
// ── thinking/reasoning 观测(不面向用户,仅为首字延迟归因) ──
|
|
84
100
|
let reasoningChars = 0;
|
|
85
101
|
let reasoningFirstAt = 0;
|
|
@@ -485,11 +501,19 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
485
501
|
}
|
|
486
502
|
// 在 typing_stop 之前 emit 一帧 usage(如有)。
|
|
487
503
|
// 数据源:从 transcript jsonl 读最近一条 assistant 消息的 usage(与 history 模块同源)。
|
|
488
|
-
//
|
|
489
|
-
//
|
|
504
|
+
//
|
|
505
|
+
// 守卫条件(缺一不可):
|
|
506
|
+
// 1. 非 abort
|
|
507
|
+
// 2. sessionKey 已知(否则无 transcript 可读)
|
|
508
|
+
// 3. 本 sink 真正承载了一次完整的 LLM 输出:hadLLMActivity && (hadStreamedText || hadDispatch)
|
|
509
|
+
//
|
|
510
|
+
// 第 3 条尤为关键:当用户连发消息被 openclaw 合并为 followup 时,被合并那条的 sink
|
|
511
|
+
// 不会有任何 LLM 活动(hadLLMActivity=false),但 transcript 里有上一轮的 usage —— 若
|
|
512
|
+
// 不加守卫就会把上一轮的 usage 误透给这条消息,前端把它当作"该消息已结束"的终态信号,
|
|
513
|
+
// 导致后续真正的回复内容不再绑定到这条消息上。
|
|
490
514
|
let usageToEmit;
|
|
491
|
-
|
|
492
|
-
if (!isAborted && sessionKey) {
|
|
515
|
+
const sinkProducedOutput = hadLLMActivity && (hadStreamedText || hadDispatch);
|
|
516
|
+
if (!isAborted && sessionKey && sinkProducedOutput) {
|
|
493
517
|
try {
|
|
494
518
|
// 找到本轮最后一条 assistant
|
|
495
519
|
const tailMessages = readSessionHistoryTail(sessionKey, {
|
|
@@ -579,15 +603,43 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
579
603
|
onToolStart: (payload) => {
|
|
580
604
|
if (completed)
|
|
581
605
|
return;
|
|
606
|
+
// SDK 在 phase=start 和 phase=update 都会回调;只在 start 时认为是"新一次工具调用",
|
|
607
|
+
// update 仅作为同一 stepId 的中间态,不重新计 seq、不发 running 帧(避免重复)。
|
|
608
|
+
const toolPhase = payload.phase ?? "";
|
|
609
|
+
if (toolPhase !== "start") {
|
|
610
|
+
// 仍然透传 tool_start 帧(向下兼容旧前端的 update 渲染),但不动 thinking_step。
|
|
611
|
+
const toolNameForUpdate = payload.name ?? "unknown";
|
|
612
|
+
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolNameForUpdate} phase=${toolPhase} (skip thinking_step)`);
|
|
613
|
+
emitSignal(signalCtx, "tool_start", "\n\n", { toolName: toolNameForUpdate, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
582
616
|
toolStartCount++;
|
|
583
617
|
// 强制 flush:前端按"先文本→后工具卡"渲染,buffer 残留若不先发出去会被工具卡插队。
|
|
584
618
|
flushCoalesceBuffer("tool");
|
|
585
619
|
const toolName = payload.name ?? "unknown";
|
|
586
|
-
const
|
|
620
|
+
const args = payload.args;
|
|
587
621
|
const isSubagentSpawn = SUBAGENT_TOOL_NAMES.has(toolName);
|
|
588
|
-
|
|
622
|
+
const argsKeys = args ? Object.keys(args).join(",") : "(none)";
|
|
623
|
+
log?.info(`[${CHANNEL_KEY}] [stream] onToolStart: name=${toolName} phase=${toolPhase} argsKeys=${argsKeys}` +
|
|
589
624
|
(isSubagentSpawn ? " (subagent-spawn)" : ""));
|
|
590
625
|
emitSignal(signalCtx, "tool_start", "\n\n", { toolName, toolPhase, ...groupSignalExtra }, buildGroupDataExtra('delta'));
|
|
626
|
+
// 追加 thinking_step running 帧:和 tool_start 同步发出,前端可二选一渲染。
|
|
627
|
+
// stepId 缓存到 lastToolStepId,onItemEvent(completed) 没拿到 itemId 时回退使用。
|
|
628
|
+
const stepId = genStepId(toolStartCount, toolName);
|
|
629
|
+
lastToolStepId = stepId;
|
|
630
|
+
lastToolName = toolName;
|
|
631
|
+
// 分配并记录 seq;done 帧复用此 seq 保证合并行的时间线位置稳定。
|
|
632
|
+
thinkingSeqCounter += 1;
|
|
633
|
+
const seq = thinkingSeqCounter;
|
|
634
|
+
thinkingStepSeqMap.set(stepId, seq);
|
|
635
|
+
// 取走上一次 onItemEvent(start) 预热的 meta/title 作为 running 文案兜底,
|
|
636
|
+
// 取后立即清空,避免被下一次调用误用。
|
|
637
|
+
const fallbackMeta = pendingItemMeta ?? undefined;
|
|
638
|
+
const fallbackTitle = pendingItemTitle ?? undefined;
|
|
639
|
+
pendingItemMeta = null;
|
|
640
|
+
pendingItemTitle = null;
|
|
641
|
+
const runningStep = buildRunningStep(stepId, seq, toolName, args, fallbackMeta, fallbackTitle);
|
|
642
|
+
emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: runningStep });
|
|
591
643
|
},
|
|
592
644
|
onReplyStart: () => {
|
|
593
645
|
if (completed)
|
|
@@ -667,6 +719,45 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
|
|
|
667
719
|
if (completed)
|
|
668
720
|
return;
|
|
669
721
|
log?.info(`[${CHANNEL_KEY}] [stream] onItemEvent: ${JSON.stringify(payload)}`);
|
|
722
|
+
// SDK 在 exec 工具上会同时发 kind=tool 和 kind=command 两份事件,载荷高度重复。
|
|
723
|
+
// 只采 kind=tool(缺省视为 tool),过滤掉 kind=command 避免时间线重复行。
|
|
724
|
+
const kind = payload.kind ?? "tool";
|
|
725
|
+
if (kind === "command")
|
|
726
|
+
return;
|
|
727
|
+
// start 帧:预热缓存 meta/title,供紧随其后的 onToolStart 拼 running 文案。
|
|
728
|
+
// (实测时序:onItemEvent(start) → onToolStart,间隔毫秒级;工具调用串行不会交叉。)
|
|
729
|
+
const phase = payload.phase ?? "";
|
|
730
|
+
if (phase === "start") {
|
|
731
|
+
pendingItemMeta = payload.meta?.trim() || null;
|
|
732
|
+
pendingItemTitle = payload.title?.trim() || null;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// 仅终态事件触发 thinking_step done/error 帧;中间态(running / progress)暂不映射,
|
|
736
|
+
// 避免协议面被 SDK 内部 phase 噪音污染。
|
|
737
|
+
const status = payload.status ?? "";
|
|
738
|
+
const isTerminal = status === "completed" ||
|
|
739
|
+
status === "done" ||
|
|
740
|
+
status === "failed" ||
|
|
741
|
+
status === "error" ||
|
|
742
|
+
status === "cancelled";
|
|
743
|
+
if (!isTerminal)
|
|
744
|
+
return;
|
|
745
|
+
// stepId 关联策略(重要):
|
|
746
|
+
// 实测 openclaw `onItemEvent` 里的 `payload.itemId` 与 `onToolStart` 时生成的
|
|
747
|
+
// stepId 完全对不上(SDK 内部随机分配),如果直接把 itemId 当 stepId 透传出去,
|
|
748
|
+
// 前端按 stepId 合并行就永远落空,结果就是"running 一行 + done 又起一行"。
|
|
749
|
+
// 因此优先复用 running 时缓存的 lastToolStepId,保证 done 帧能就地覆盖到 running 那一行。
|
|
750
|
+
// 仅当不存在 lastToolStepId(极端兜底)时才退回到 itemId / "item-unknown"。
|
|
751
|
+
const fallbackStepId = `item-${payload.itemId ?? "unknown"}`;
|
|
752
|
+
const stepId = lastToolStepId ?? payload.itemId ?? fallbackStepId;
|
|
753
|
+
// 复用 running 时分配的 seq,保证合并行不漂移。
|
|
754
|
+
let seq = thinkingStepSeqMap.get(stepId);
|
|
755
|
+
if (seq === undefined) {
|
|
756
|
+
thinkingSeqCounter += 1;
|
|
757
|
+
seq = thinkingSeqCounter;
|
|
758
|
+
}
|
|
759
|
+
const doneStep = buildDoneStep(payload, stepId, seq, lastToolName ?? undefined);
|
|
760
|
+
emitSignal(signalCtx, "thinking_step", "", groupSignalExtra, { ...buildGroupDataExtra('delta'), step: doneStep });
|
|
670
761
|
},
|
|
671
762
|
onTypingCleanup: () => {
|
|
672
763
|
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
|
+
}
|