palz-connector 1.3.2 → 1.3.4
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/palz-connector.config.json +4 -3
- package/palz-connector.dev.config.json +2 -1
- package/palz-connector.prod.config.json +2 -1
- package/palz-connector.staging.config.json +2 -1
- package/src/bot.ts +117 -1
- package/src/config.ts +1 -0
- package/src/reply-dispatcher.ts +121 -17
- package/src/send.ts +9 -1
- package/src/tool-display.ts +273 -0
- package/src/types.ts +6 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"enabled": true,
|
|
3
|
-
"streamUrl": "ws://14.103.148.99:
|
|
4
|
-
"apiBaseUrl": "http://14.103.148.99:
|
|
3
|
+
"streamUrl": "ws://14.103.148.99:8090/ws/bot",
|
|
4
|
+
"apiBaseUrl": "http://14.103.148.99:8090/api",
|
|
5
5
|
"sessionTimeout": 1800000,
|
|
6
|
-
"groupContextCache": true
|
|
6
|
+
"groupContextCache": true,
|
|
7
|
+
"showProcess": true
|
|
7
8
|
}
|
package/src/bot.ts
CHANGED
|
@@ -43,6 +43,37 @@ function extractPlainText(content: OpenAIContent): string {
|
|
|
43
43
|
return "";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// ============ reasoning 激活状态缓存(内存) ============
|
|
47
|
+
|
|
48
|
+
/** 已激活 reasoning stream 的 session key 集合,避免每次请求重复发送 /reasoning stream */
|
|
49
|
+
const reasoningActivated = new Set<string>();
|
|
50
|
+
|
|
51
|
+
/** 已扫描过 session store 的 agent 集合,每个 agent 只扫描一次 */
|
|
52
|
+
const reasoningScannedAgents = new Set<string>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 从 session store 中预填已激活 reasoning stream 的 session key。
|
|
56
|
+
* 每个 agentId 只扫描一次,在首次处理该 agent 的请求时调用。
|
|
57
|
+
*/
|
|
58
|
+
function prefillReasoningActivated(core: any, agentId: string, log: (...args: any[]) => void): void {
|
|
59
|
+
if (reasoningScannedAgents.has(agentId)) return;
|
|
60
|
+
reasoningScannedAgents.add(agentId);
|
|
61
|
+
try {
|
|
62
|
+
const storePath = core.agent.session.resolveStorePath(undefined, { agentId });
|
|
63
|
+
const store = core.agent.session.loadSessionStore(storePath, { skipCache: true });
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const [key, entry] of Object.entries(store)) {
|
|
66
|
+
if ((entry as any)?.reasoningLevel === "stream") {
|
|
67
|
+
reasoningActivated.add(key);
|
|
68
|
+
count++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
log(`[REASONING PREFILL] agentId=${agentId} scanned, found ${count} session(s) with reasoning=stream`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log(`[REASONING PREFILL] agentId=${agentId} 扫描失败: ${String(err)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
46
77
|
// ============ 聊天队列(同一会话串行处理) ============
|
|
47
78
|
|
|
48
79
|
function createChatQueue() {
|
|
@@ -511,6 +542,8 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
511
542
|
log(`${tag}: ${step6bCtx}`);
|
|
512
543
|
span?.addEvent(step6bCtx);
|
|
513
544
|
|
|
545
|
+
const showProcess = !isGroup && config.showProcess === true;
|
|
546
|
+
|
|
514
547
|
const ctx = core.channel.reply.finalizeInboundContext({
|
|
515
548
|
Body: combinedBody,
|
|
516
549
|
BodyForAgent: messageBody,
|
|
@@ -552,9 +585,90 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
552
585
|
span?.addEvent(step6bOutput);
|
|
553
586
|
ctx.metadata = {
|
|
554
587
|
...ctx.metadata,
|
|
555
|
-
traceId: msg.msg_id,
|
|
588
|
+
traceId: msg.msg_id,
|
|
556
589
|
source: "palz-connector"
|
|
557
590
|
};
|
|
591
|
+
|
|
592
|
+
// 当 showProcess 开启时,确保 session 的 reasoningLevel 为 "stream"
|
|
593
|
+
// 通过模拟发送一条 directive-only 的 "/reasoning stream" 消息来激活,
|
|
594
|
+
// OpenClaw 的 handleDirectiveOnly 会通过 updateSessionStore(锁内 read-modify-write)
|
|
595
|
+
// 安全地持久化 reasoningLevel,完全避免并发覆盖问题。
|
|
596
|
+
// 使用内存 Set 记录已激活的 session,避免每次请求重复发送。
|
|
597
|
+
|
|
598
|
+
// 首次处理该 agent 时,从 session store 预填已激活的 session key
|
|
599
|
+
if (showProcess) {
|
|
600
|
+
prefillReasoningActivated(core, route.agentId, log);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (showProcess && !reasoningActivated.has(route.sessionKey)) {
|
|
604
|
+
try {
|
|
605
|
+
log(`${tag}: [REASONING ACTIVATE] 开始激活 reasoning stream, sessionKey=${route.sessionKey}`);
|
|
606
|
+
// 构造 directive-only 的 context,Body 仅包含 /reasoning stream
|
|
607
|
+
const reasoningCtx = core.channel.reply.finalizeInboundContext({
|
|
608
|
+
Body: "/reasoning stream",
|
|
609
|
+
RawBody: "/reasoning stream",
|
|
610
|
+
CommandBody: "/reasoning stream",
|
|
611
|
+
From: palzFrom,
|
|
612
|
+
To: palzTo,
|
|
613
|
+
SessionKey: route.sessionKey,
|
|
614
|
+
AccountId: route.accountId,
|
|
615
|
+
ChatType: chatType,
|
|
616
|
+
SenderId: msg.sender_id,
|
|
617
|
+
SenderName: senderName,
|
|
618
|
+
Provider: "palz-connector",
|
|
619
|
+
Surface: "palz-connector",
|
|
620
|
+
MessageSid: `${msg.msg_id}_reasoning_activate`,
|
|
621
|
+
Timestamp: Date.now(),
|
|
622
|
+
WasMentioned: true,
|
|
623
|
+
CommandAuthorized: true,
|
|
624
|
+
OriginatingChannel: "palz-connector",
|
|
625
|
+
OriginatingTo: palzTo,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// 创建静默 dispatcher,消费 ack 回复但不发送给 IM
|
|
629
|
+
// 通过 ack 内容判断是否真正激活成功
|
|
630
|
+
let activateSuccess = false;
|
|
631
|
+
const { dispatcher: silentDispatcher, replyOptions: silentReplyOptions, markDispatchIdle: silentMarkIdle } =
|
|
632
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
633
|
+
deliver: async (payload: any, info: any) => {
|
|
634
|
+
const text = payload.text ?? "";
|
|
635
|
+
log(`${tag}: [REASONING ACTIVATE] 收到 ack: "${text.slice(0, 200)}" kind=${info?.kind}`);
|
|
636
|
+
// handleDirectiveOnly 成功时返回含 "reasoning" 的 ack 文本(如 "Reasoning stream enabled.")
|
|
637
|
+
if (/reasoning/i.test(text)) {
|
|
638
|
+
activateSuccess = true;
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
onError: async (err: unknown) => {
|
|
642
|
+
log(`${tag}: [REASONING ACTIVATE] 错误: ${String(err)}`);
|
|
643
|
+
},
|
|
644
|
+
onIdle: async () => {
|
|
645
|
+
log(`${tag}: [REASONING ACTIVATE] dispatcher idle`);
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
await core.channel.reply.withReplyDispatcher({
|
|
650
|
+
dispatcher: silentDispatcher,
|
|
651
|
+
onSettled: () => silentMarkIdle(),
|
|
652
|
+
run: () =>
|
|
653
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
654
|
+
ctx: reasoningCtx,
|
|
655
|
+
cfg,
|
|
656
|
+
dispatcher: silentDispatcher,
|
|
657
|
+
replyOptions: silentReplyOptions,
|
|
658
|
+
}),
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
if (activateSuccess) {
|
|
662
|
+
reasoningActivated.add(route.sessionKey);
|
|
663
|
+
log(`${tag}: [REASONING ACTIVATE] 激活成功, sessionKey=${route.sessionKey}`);
|
|
664
|
+
} else {
|
|
665
|
+
log(`${tag}: [REASONING ACTIVATE] 未收到确认 ack, 下次请求将重试, sessionKey=${route.sessionKey}`);
|
|
666
|
+
}
|
|
667
|
+
} catch (err) {
|
|
668
|
+
log(`${tag}: [REASONING ACTIVATE] 激活失败: ${String(err)}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
558
672
|
// STEP 6c: 创建回复分发器
|
|
559
673
|
const dispatcherParams = {
|
|
560
674
|
accountId,
|
|
@@ -581,6 +695,8 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
581
695
|
msgType: msg.msg_type,
|
|
582
696
|
groupId,
|
|
583
697
|
mediaLocalRoots: resolveMediaLocalRoots(effectiveAgentId),
|
|
698
|
+
showProcess,
|
|
699
|
+
sessionKey: route.sessionKey,
|
|
584
700
|
});
|
|
585
701
|
|
|
586
702
|
// STEP 6d: 分发消息给 AI
|
package/src/config.ts
CHANGED
|
@@ -73,6 +73,7 @@ export function resolvePalzConfig(_cfg?: any): PalzConfig {
|
|
|
73
73
|
apiBaseUrl: file.apiBaseUrl || "",
|
|
74
74
|
sessionTimeout: file.sessionTimeout ?? DEFAULT_SESSION_TIMEOUT,
|
|
75
75
|
groupContextCache: file.groupContextCache !== false,
|
|
76
|
+
showProcess: file.showProcess === true,
|
|
76
77
|
};
|
|
77
78
|
if (!_configLoggedOnce) {
|
|
78
79
|
_configLoggedOnce = true;
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { getPalzRuntime } from "./runtime.js";
|
|
12
12
|
import { loadMediaAsOssUrl } from "./media.js";
|
|
13
13
|
import { sendToPalzIM } from "./send.js";
|
|
14
|
+
import { resolveToolDisplay, formatToolStartText, formatToolResultText, formatResultSummary } from "./tool-display.js";
|
|
14
15
|
import type { PalzConfig, StreamChunkOpts, ContentPart, OpenAIContent } from "./types.js";
|
|
15
16
|
|
|
16
17
|
const STREAM_THROTTLE_MS = 200;
|
|
@@ -44,6 +45,8 @@ export interface CreatePalzReplyDispatcherParams {
|
|
|
44
45
|
msgType?: string;
|
|
45
46
|
groupId?: string;
|
|
46
47
|
mediaLocalRoots?: readonly string[];
|
|
48
|
+
showProcess?: boolean;
|
|
49
|
+
sessionKey?: string;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParams) {
|
|
@@ -61,6 +64,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
61
64
|
msgType,
|
|
62
65
|
groupId,
|
|
63
66
|
mediaLocalRoots,
|
|
67
|
+
showProcess,
|
|
64
68
|
} = params;
|
|
65
69
|
|
|
66
70
|
const log = typeof runtime?.log === "function" ? runtime.log : console.log;
|
|
@@ -68,11 +72,27 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
68
72
|
const streamState = enableStreaming ? createStreamingState() : null;
|
|
69
73
|
const tag = `palz[${accountId}]`;
|
|
70
74
|
|
|
75
|
+
// 会话级串行队列:保证同一会话内 IM 消息按入队顺序到达
|
|
76
|
+
// (不同 dispatcher 实例对应不同会话,天然隔离)
|
|
77
|
+
let imSendQueue: Promise<unknown> = Promise.resolve();
|
|
78
|
+
const enqueueIMSend = (
|
|
79
|
+
content: OpenAIContent,
|
|
80
|
+
streamOpts: StreamChunkOpts | undefined,
|
|
81
|
+
palzMsgType: string | undefined,
|
|
82
|
+
label: string,
|
|
83
|
+
): Promise<unknown> => {
|
|
84
|
+
const next = imSendQueue.then(() => sendToIM(content, streamOpts, palzMsgType)).catch((err: any) => {
|
|
85
|
+
error(`${tag}: [IM_QUEUE] ${label} 发送失败: ${err?.message ?? String(err)}`);
|
|
86
|
+
});
|
|
87
|
+
imSendQueue = next;
|
|
88
|
+
return next;
|
|
89
|
+
};
|
|
90
|
+
|
|
71
91
|
log(
|
|
72
92
|
`${tag}: [DISPATCHER 创建] conv=${conversationId} sender=${senderId} streaming=${enableStreaming}${streamState ? ` streamId=${streamState.streamId}` : ""}`,
|
|
73
93
|
);
|
|
74
94
|
|
|
75
|
-
const sendToIM = async (content: OpenAIContent, streamOpts?: StreamChunkOpts) => {
|
|
95
|
+
const sendToIM = async (content: OpenAIContent, streamOpts?: StreamChunkOpts, palzMsgType?: string, toolContent?: Record<string, unknown>) => {
|
|
76
96
|
const contentPreview = typeof content === "string" ? content.slice(0, 120) : JSON.stringify(content).slice(0, 120);
|
|
77
97
|
const sendInput = {
|
|
78
98
|
conversationId,
|
|
@@ -80,6 +100,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
80
100
|
conversationType,
|
|
81
101
|
senderId,
|
|
82
102
|
stream: streamOpts ? { streamId: streamOpts.streamId, seq: streamOpts.seq, isFinal: streamOpts.isFinal, deltaLen: streamOpts.delta?.length } : undefined,
|
|
103
|
+
palzMsgType,
|
|
83
104
|
};
|
|
84
105
|
log(`${tag}: [DISPATCHER→sendToIM] 输入: ${JSON.stringify(sendInput)}`);
|
|
85
106
|
const result = await sendToPalzIM({
|
|
@@ -92,11 +113,58 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
92
113
|
stream: streamOpts,
|
|
93
114
|
msgType,
|
|
94
115
|
groupId,
|
|
116
|
+
palzMsgType,
|
|
117
|
+
toolContent,
|
|
95
118
|
});
|
|
96
119
|
log(`${tag}: [DISPATCHER←sendToIM] 输出: ${JSON.stringify(result)}`);
|
|
97
120
|
return result;
|
|
98
121
|
};
|
|
99
122
|
|
|
123
|
+
// ============ 工具调用 & 思考过程中间过程监听 ============
|
|
124
|
+
let unsubscribeToolEvents: (() => void) | undefined;
|
|
125
|
+
let accumulatedThinking = "";
|
|
126
|
+
|
|
127
|
+
// 生成 runId,传给 dispatch 并用于事件过滤
|
|
128
|
+
// 这样可以精确关联当前 dispatcher 产生的工具事件,避免多用户并发串消息
|
|
129
|
+
const toolRunId = showProcess
|
|
130
|
+
? `palz_run_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
|
131
|
+
: undefined;
|
|
132
|
+
|
|
133
|
+
if (showProcess) {
|
|
134
|
+
log(`${tag}: [TOOL_EVENTS] 开启工具调用中间过程监听 runId=${toolRunId}`);
|
|
135
|
+
try {
|
|
136
|
+
unsubscribeToolEvents = core.events.onAgentEvent((evt: any) => {
|
|
137
|
+
// 按 runId 精确过滤,避免并发消息串事件
|
|
138
|
+
if (evt.runId !== toolRunId) return;
|
|
139
|
+
|
|
140
|
+
// ---- 工具事件 ----
|
|
141
|
+
if (evt.stream === "tool") {
|
|
142
|
+
const data = evt.data ?? {};
|
|
143
|
+
const phase = data.phase;
|
|
144
|
+
const toolName = data.name ?? data.toolName;
|
|
145
|
+
|
|
146
|
+
if (phase === "start") {
|
|
147
|
+
const display = resolveToolDisplay(toolName, data.args);
|
|
148
|
+
const text = formatToolStartText(toolName, data.args);
|
|
149
|
+
log(`${tag}: [TOOL_EVENTS] tool_start name=${toolName} detail=${display.detail ?? "(none)"}`);
|
|
150
|
+
enqueueIMSend(text, undefined, "tool_start", "tool_start");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (phase === "result") {
|
|
154
|
+
const isError = data.isError === true;
|
|
155
|
+
const meta = typeof data.meta === "string" ? data.meta : undefined;
|
|
156
|
+
const text = formatToolResultText(toolName, data.result, isError, meta);
|
|
157
|
+
const resultSummary = isError ? "Error" : formatResultSummary(data.result);
|
|
158
|
+
log(`${tag}: [TOOL_EVENTS] tool_result name=${toolName} is_error=${isError} summary=${resultSummary}`);
|
|
159
|
+
enqueueIMSend(text, undefined, "tool_result", "tool_result");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
error(`${tag}: [TOOL_EVENTS] 注册监听失败: ${String(err)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
100
168
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
101
169
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
102
170
|
deliver: async (payload: any, info: any) => {
|
|
@@ -145,18 +213,24 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
145
213
|
|
|
146
214
|
if (streamState && kind === "final" && typeof content === "string") {
|
|
147
215
|
const delta = content.slice(streamState.lastSentLength);
|
|
216
|
+
const seq = streamState.seq++;
|
|
148
217
|
log(
|
|
149
|
-
`${tag}: [DELIVER 流式最终] seq=${
|
|
218
|
+
`${tag}: [DELIVER 流式最终] seq=${seq} totalLen=${content.length} deltaLen=${delta.length} delta="${delta.slice(0, 120)}"`,
|
|
219
|
+
);
|
|
220
|
+
await enqueueIMSend(
|
|
221
|
+
content,
|
|
222
|
+
{
|
|
223
|
+
streamId: streamState.streamId,
|
|
224
|
+
seq,
|
|
225
|
+
isFinal: true,
|
|
226
|
+
delta,
|
|
227
|
+
},
|
|
228
|
+
undefined,
|
|
229
|
+
`final seq=${seq}`,
|
|
150
230
|
);
|
|
151
|
-
await sendToIM(content, {
|
|
152
|
-
streamId: streamState.streamId,
|
|
153
|
-
seq: streamState.seq++,
|
|
154
|
-
isFinal: true,
|
|
155
|
-
delta,
|
|
156
|
-
});
|
|
157
231
|
} else {
|
|
158
232
|
log(`${tag}: [DELIVER 非流式] contentType=${typeof content === "string" ? "string" : "array"}`);
|
|
159
|
-
await
|
|
233
|
+
await enqueueIMSend(content, undefined, undefined, "deliver");
|
|
160
234
|
}
|
|
161
235
|
|
|
162
236
|
log(`${tag}: [DELIVER 完成] kind=${kind}`);
|
|
@@ -169,11 +243,18 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
169
243
|
},
|
|
170
244
|
onIdle: async () => {
|
|
171
245
|
log(`${tag}: [DISPATCHER idle]`);
|
|
246
|
+
if (unsubscribeToolEvents) {
|
|
247
|
+
log(`${tag}: [TOOL_EVENTS] 清理监听器`);
|
|
248
|
+
unsubscribeToolEvents();
|
|
249
|
+
unsubscribeToolEvents = undefined;
|
|
250
|
+
}
|
|
172
251
|
},
|
|
173
252
|
});
|
|
174
253
|
|
|
175
254
|
const palzReplyOptions = {
|
|
176
255
|
...replyOptions,
|
|
256
|
+
// 传入 runId 让 OpenClaw agent execution 使用,与 onAgentEvent 的 evt.runId 对应
|
|
257
|
+
...(toolRunId ? { runId: toolRunId } : {}),
|
|
177
258
|
onPartialReply: enableStreaming
|
|
178
259
|
? (payload: any) => {
|
|
179
260
|
const text = payload.text ?? "";
|
|
@@ -197,14 +278,37 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
197
278
|
`${tag}: [PARTIAL 输出] seq=${seq} totalLen=${text.length} deltaLen=${delta.length} delta="${delta.slice(0, 120)}" elapsed=${elapsed}ms`,
|
|
198
279
|
);
|
|
199
280
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
281
|
+
enqueueIMSend(
|
|
282
|
+
text,
|
|
283
|
+
{
|
|
284
|
+
streamId: streamState.streamId,
|
|
285
|
+
seq,
|
|
286
|
+
isFinal: false,
|
|
287
|
+
delta,
|
|
288
|
+
},
|
|
289
|
+
undefined,
|
|
290
|
+
`partial seq=${seq}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
: undefined,
|
|
294
|
+
onReasoningStream: showProcess
|
|
295
|
+
? (payload: any) => {
|
|
296
|
+
const text = payload.text ?? "";
|
|
297
|
+
if (text.trim()) {
|
|
298
|
+
accumulatedThinking = text;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
: undefined,
|
|
302
|
+
onReasoningEnd: showProcess
|
|
303
|
+
? () => {
|
|
304
|
+
if (!accumulatedThinking.trim()) return;
|
|
305
|
+
// 格式化思考文本:去掉 "Reasoning:\n" 前缀和 "_" 斜体标记
|
|
306
|
+
let text = accumulatedThinking;
|
|
307
|
+
accumulatedThinking = "";
|
|
308
|
+
text = text.replace(/^Reasoning:\n/, "");
|
|
309
|
+
text = text.replace(/^_|_$/gm, "");
|
|
310
|
+
log(`${tag}: [THINKING] 思考完成, len=${text.length}`);
|
|
311
|
+
enqueueIMSend(text, undefined, "thinking", "thinking");
|
|
208
312
|
}
|
|
209
313
|
: undefined,
|
|
210
314
|
};
|
package/src/send.ts
CHANGED
|
@@ -29,7 +29,7 @@ export async function sendToPalzIM(params: SendToIMParams): Promise<any> {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
async function _sendToPalzIMInner(params: SendToIMParams): Promise<any> {
|
|
32
|
-
const { config, conversationId, content, conversationType, msgId, senderId, stream, msgType, groupId } = params;
|
|
32
|
+
const { config, conversationId, content, conversationType, msgId, senderId, stream, msgType, groupId, palzMsgType, toolContent } = params;
|
|
33
33
|
const url = `${config.apiBaseUrl}/bot/send`;
|
|
34
34
|
const resolvedMsgId = msgId || nextMsgId();
|
|
35
35
|
const span = trace.getActiveSpan();
|
|
@@ -61,6 +61,14 @@ async function _sendToPalzIMInner(params: SendToIMParams): Promise<any> {
|
|
|
61
61
|
reqBody.delta = stream.delta;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
if (palzMsgType) {
|
|
65
|
+
reqBody.palz_msg_type = palzMsgType;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (toolContent) {
|
|
69
|
+
reqBody.tool_content = toolContent;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
const reqBodyStr = JSON.stringify(reqBody);
|
|
65
73
|
const reqLog = `[HTTP_REQ] POST ${url} body_length=${reqBodyStr.length}\n request_body=${reqBodyStr}`;
|
|
66
74
|
console.log(`palz-send: ${reqLog}`);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具调用显示辅助模块
|
|
3
|
+
*
|
|
4
|
+
* 提供工具名到 emoji/title/detailKeys 的映射,以及生成纯文本摘要和结构化内容的辅助函数。
|
|
5
|
+
* 参考 OpenClaw 的 tool-display-overrides.json 和 ui/tool-display.ts。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============ 工具映射表 ============
|
|
9
|
+
|
|
10
|
+
interface ToolDisplaySpec {
|
|
11
|
+
emoji: string;
|
|
12
|
+
title: string;
|
|
13
|
+
detailKeys?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TOOL_DISPLAY_MAP: Record<string, ToolDisplaySpec> = {
|
|
17
|
+
web_search: { emoji: "🔎", title: "Web Search", detailKeys: ["query", "count"] },
|
|
18
|
+
web_fetch: { emoji: "📄", title: "Web Fetch", detailKeys: ["url", "extractMode", "maxChars"] },
|
|
19
|
+
exec: { emoji: "🛠️", title: "Exec", detailKeys: ["command"] },
|
|
20
|
+
bash: { emoji: "🛠️", title: "Bash", detailKeys: ["command"] },
|
|
21
|
+
read: { emoji: "📖", title: "Read", detailKeys: ["path", "file_path"] },
|
|
22
|
+
write: { emoji: "✍️", title: "Write", detailKeys: ["path", "file_path"] },
|
|
23
|
+
edit: { emoji: "✍️", title: "Edit", detailKeys: ["path", "file_path"] },
|
|
24
|
+
glob: { emoji: "📂", title: "Glob", detailKeys: ["pattern"] },
|
|
25
|
+
grep: { emoji: "🔍", title: "Grep", detailKeys: ["pattern", "path"] },
|
|
26
|
+
memory_search: { emoji: "🧠", title: "Memory Search", detailKeys: ["query"] },
|
|
27
|
+
memory_get: { emoji: "📓", title: "Memory Get", detailKeys: ["path"] },
|
|
28
|
+
sessions_spawn: { emoji: "🧑🔧", title: "Sub-agent", detailKeys: ["label", "task"] },
|
|
29
|
+
sessions_send: { emoji: "📨", title: "Session Send", detailKeys: ["label", "sessionKey"] },
|
|
30
|
+
message: { emoji: "✉️", title: "Message", detailKeys: ["action", "to"] },
|
|
31
|
+
apply_patch: { emoji: "🩹", title: "Apply Patch", detailKeys: [] },
|
|
32
|
+
cron: { emoji: "⏰", title: "Cron", detailKeys: ["action", "cron"] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const FALLBACK_EMOJI = "🔧";
|
|
36
|
+
|
|
37
|
+
// ============ 工具名处理 ============
|
|
38
|
+
|
|
39
|
+
function normalizeToolName(name?: string): string {
|
|
40
|
+
return (name ?? "tool").trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultTitle(name: string): string {
|
|
44
|
+
const cleaned = name.replace(/_/g, " ").trim();
|
|
45
|
+
if (!cleaned) return "Tool";
|
|
46
|
+
return cleaned
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.map((part) =>
|
|
49
|
+
part.length <= 2 && part.toUpperCase() === part
|
|
50
|
+
? part
|
|
51
|
+
: `${part.charAt(0).toUpperCase()}${part.slice(1)}`,
|
|
52
|
+
)
|
|
53
|
+
.join(" ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============ 文本截断 ============
|
|
57
|
+
|
|
58
|
+
export function truncateText(text: string, headLen = 80, tailLen = 40): string {
|
|
59
|
+
if (!text) return text;
|
|
60
|
+
const maxLen = headLen + tailLen + 3; // 3 for "..."
|
|
61
|
+
if (text.length <= maxLen) return text;
|
|
62
|
+
return `${text.slice(0, headLen)}...${text.slice(-tailLen)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============ Detail 提取 ============
|
|
66
|
+
|
|
67
|
+
function extractDetail(args: unknown, detailKeys: string[]): string | undefined {
|
|
68
|
+
if (!args || typeof args !== "object" || !detailKeys.length) return undefined;
|
|
69
|
+
const record = args as Record<string, unknown>;
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
for (const key of detailKeys) {
|
|
72
|
+
const value = record[key];
|
|
73
|
+
if (value === undefined || value === null) continue;
|
|
74
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
75
|
+
if (!str) continue;
|
|
76
|
+
parts.push(truncateText(str, 60, 20));
|
|
77
|
+
}
|
|
78
|
+
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractFallbackDetail(args: unknown): string | undefined {
|
|
82
|
+
if (!args || typeof args !== "object") return undefined;
|
|
83
|
+
const record = args as Record<string, unknown>;
|
|
84
|
+
// Try 'action' field first as verb
|
|
85
|
+
if (typeof record.action === "string" && record.action.trim()) {
|
|
86
|
+
const otherParts: string[] = [];
|
|
87
|
+
for (const [key, value] of Object.entries(record)) {
|
|
88
|
+
if (key === "action") continue;
|
|
89
|
+
if (value === undefined || value === null) continue;
|
|
90
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
91
|
+
if (str) {
|
|
92
|
+
otherParts.push(truncateText(str, 40, 15));
|
|
93
|
+
break; // Only take the first extra field
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const verb = record.action as string;
|
|
97
|
+
return otherParts.length > 0 ? `${verb} ${otherParts[0]}` : verb;
|
|
98
|
+
}
|
|
99
|
+
// Otherwise take the first field value
|
|
100
|
+
for (const value of Object.values(record)) {
|
|
101
|
+
if (value === undefined || value === null) continue;
|
|
102
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
103
|
+
if (str) return truncateText(str, 60, 20);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============ 核心:resolveToolDisplay ============
|
|
109
|
+
|
|
110
|
+
export interface ToolDisplay {
|
|
111
|
+
emoji: string;
|
|
112
|
+
title: string;
|
|
113
|
+
detail?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveToolDisplay(name?: string, args?: unknown): ToolDisplay {
|
|
117
|
+
const normalized = normalizeToolName(name);
|
|
118
|
+
const key = normalized.toLowerCase();
|
|
119
|
+
const spec = TOOL_DISPLAY_MAP[key];
|
|
120
|
+
|
|
121
|
+
const emoji = spec?.emoji ?? FALLBACK_EMOJI;
|
|
122
|
+
const title = spec?.title ?? defaultTitle(normalized);
|
|
123
|
+
|
|
124
|
+
let detail: string | undefined;
|
|
125
|
+
if (spec?.detailKeys && spec.detailKeys.length > 0) {
|
|
126
|
+
detail = extractDetail(args, spec.detailKeys);
|
|
127
|
+
}
|
|
128
|
+
if (!detail) {
|
|
129
|
+
detail = extractFallbackDetail(args);
|
|
130
|
+
}
|
|
131
|
+
// Truncate entire detail to 200 chars
|
|
132
|
+
if (detail && detail.length > 200) {
|
|
133
|
+
detail = truncateText(detail, 120, 40);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { emoji, title, detail };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============ 纯文本格式化 ============
|
|
140
|
+
|
|
141
|
+
export function formatToolStartText(name?: string, args?: unknown): string {
|
|
142
|
+
const display = resolveToolDisplay(name, args);
|
|
143
|
+
const lines = [`${display.emoji} ${display.title} 开始调用`];
|
|
144
|
+
if (display.detail) {
|
|
145
|
+
lines.push(`参数: ${display.detail}`);
|
|
146
|
+
}
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatToolResultText(name?: string, result?: unknown, isError?: boolean, meta?: string): string {
|
|
151
|
+
const display = resolveToolDisplay(name);
|
|
152
|
+
const lines = [`${display.emoji} ${display.title} 调用完成`];
|
|
153
|
+
|
|
154
|
+
if (isError) {
|
|
155
|
+
lines.push("调用结果: 失败");
|
|
156
|
+
const errMsg = extractErrorMessage(result);
|
|
157
|
+
if (errMsg) {
|
|
158
|
+
lines.push(`错误信息: ${truncateText(errMsg, 80, 40)}`);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
lines.push("调用结果: 成功");
|
|
162
|
+
const resultMeta = meta ? truncateText(meta, 120, 40) : `${display.title}执行成功`;
|
|
163
|
+
lines.push(`结果摘要: ${resultMeta}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ============ 结果摘要 ============
|
|
170
|
+
|
|
171
|
+
export function formatResultSummary(result: unknown): string {
|
|
172
|
+
if (result === null || result === undefined) return "null";
|
|
173
|
+
if (typeof result === "string") return `String (${result.length} chars)`;
|
|
174
|
+
if (Array.isArray(result)) return `Array (${result.length} items)`;
|
|
175
|
+
if (typeof result === "object") {
|
|
176
|
+
const keys = Object.keys(result as object);
|
|
177
|
+
return `Object (${keys.length} keys)`;
|
|
178
|
+
}
|
|
179
|
+
return String(result);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractErrorMessage(result: unknown): string | undefined {
|
|
183
|
+
if (typeof result === "string") return result;
|
|
184
|
+
if (result && typeof result === "object") {
|
|
185
|
+
const record = result as Record<string, unknown>;
|
|
186
|
+
if (typeof record.error === "string") return record.error;
|
|
187
|
+
if (typeof record.message === "string") return record.message;
|
|
188
|
+
try {
|
|
189
|
+
return JSON.stringify(result).slice(0, 200);
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return result != null ? String(result) : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============ 结构化字段深度截断 ============
|
|
198
|
+
|
|
199
|
+
const TOOL_CONTENT_MAX_LEN = 2000;
|
|
200
|
+
const TOOL_CONTENT_STRING_MAX = 200;
|
|
201
|
+
const TOOL_CONTENT_ARRAY_MAX_ITEMS = 3;
|
|
202
|
+
|
|
203
|
+
export function truncateForToolContent(result: unknown, maxLen = TOOL_CONTENT_MAX_LEN): unknown {
|
|
204
|
+
if (result === null || result === undefined) return result;
|
|
205
|
+
|
|
206
|
+
// String: simple truncation
|
|
207
|
+
if (typeof result === "string") {
|
|
208
|
+
if (result.length <= maxLen) return result;
|
|
209
|
+
return `${result.slice(0, Math.min(1500, maxLen - 200))}...${result.slice(-200)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Primitive: pass through
|
|
213
|
+
if (typeof result !== "object") return result;
|
|
214
|
+
|
|
215
|
+
// Check if already small enough
|
|
216
|
+
try {
|
|
217
|
+
const serialized = JSON.stringify(result);
|
|
218
|
+
if (serialized && serialized.length <= maxLen) return result;
|
|
219
|
+
} catch {
|
|
220
|
+
return "[unserializable]";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Deep truncate
|
|
224
|
+
return deepTruncate(result, maxLen);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function deepTruncate(value: unknown, budget: number): unknown {
|
|
228
|
+
if (value === null || value === undefined) return value;
|
|
229
|
+
if (typeof value === "string") {
|
|
230
|
+
return value.length > TOOL_CONTENT_STRING_MAX
|
|
231
|
+
? `${value.slice(0, TOOL_CONTENT_STRING_MAX - 20)}...${value.slice(-15)}`
|
|
232
|
+
: value;
|
|
233
|
+
}
|
|
234
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
235
|
+
|
|
236
|
+
if (Array.isArray(value)) {
|
|
237
|
+
const truncated: unknown[] = [];
|
|
238
|
+
const limit = Math.min(value.length, TOOL_CONTENT_ARRAY_MAX_ITEMS);
|
|
239
|
+
for (let i = 0; i < limit; i++) {
|
|
240
|
+
truncated.push(deepTruncate(value[i], Math.floor(budget / limit)));
|
|
241
|
+
}
|
|
242
|
+
if (value.length > limit) {
|
|
243
|
+
truncated.push({ __truncated: true, __total: value.length });
|
|
244
|
+
}
|
|
245
|
+
return truncated;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (typeof value === "object") {
|
|
249
|
+
const result: Record<string, unknown> = {};
|
|
250
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
251
|
+
let accumulated = 2; // "{}"
|
|
252
|
+
const perKeyBudget = Math.floor(budget / Math.max(entries.length, 1));
|
|
253
|
+
|
|
254
|
+
for (const [key, val] of entries) {
|
|
255
|
+
const truncatedVal = deepTruncate(val, perKeyBudget);
|
|
256
|
+
let addedSize: number;
|
|
257
|
+
try {
|
|
258
|
+
addedSize = JSON.stringify(truncatedVal).length + key.length + 4; // key:"val",
|
|
259
|
+
} catch {
|
|
260
|
+
addedSize = 50;
|
|
261
|
+
}
|
|
262
|
+
accumulated += addedSize;
|
|
263
|
+
if (accumulated > budget) {
|
|
264
|
+
result.__truncated = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
result[key] = truncatedVal;
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return value;
|
|
273
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface PalzConfig {
|
|
|
51
51
|
sessionTimeout?: number;
|
|
52
52
|
/** 群聊上下文缓存开关:true=未@消息缓存为上下文(默认),false=所有群聊消息直接发送给AI */
|
|
53
53
|
groupContextCache?: boolean;
|
|
54
|
+
/** 是否将工具调用和思考过程发送到 IM,仅单聊生效,默认 false */
|
|
55
|
+
showProcess?: boolean;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export interface ResolvedPalzAccount {
|
|
@@ -90,4 +92,8 @@ export interface SendToIMParams {
|
|
|
90
92
|
msgType?: string;
|
|
91
93
|
/** 群组 ID,群聊时透传 */
|
|
92
94
|
groupId?: string;
|
|
95
|
+
/** Palz 自定义消息类型(tool_start / tool_result) */
|
|
96
|
+
palzMsgType?: string;
|
|
97
|
+
/** 工具调用结构化内容 */
|
|
98
|
+
toolContent?: Record<string, unknown>;
|
|
93
99
|
}
|