palz-connector 1.3.1 → 1.3.3
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 +118 -1
- package/src/config.ts +1 -0
- package/src/reply-dispatcher.ts +86 -1
- 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() {
|
|
@@ -495,6 +526,7 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
495
526
|
`sender_name: ${senderName}`,
|
|
496
527
|
`conversation_id: ${msg.conversation_id}`,
|
|
497
528
|
`conversation_type: ${msg.conversation_type || "direct"}`,
|
|
529
|
+
`to: ${palzTo}`,
|
|
498
530
|
`mentioned_bot: ${wasMentioned}`,
|
|
499
531
|
];
|
|
500
532
|
if (groupId) {
|
|
@@ -510,6 +542,8 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
510
542
|
log(`${tag}: ${step6bCtx}`);
|
|
511
543
|
span?.addEvent(step6bCtx);
|
|
512
544
|
|
|
545
|
+
const showProcess = !isGroup && config.showProcess === true;
|
|
546
|
+
|
|
513
547
|
const ctx = core.channel.reply.finalizeInboundContext({
|
|
514
548
|
Body: combinedBody,
|
|
515
549
|
BodyForAgent: messageBody,
|
|
@@ -551,9 +585,90 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
551
585
|
span?.addEvent(step6bOutput);
|
|
552
586
|
ctx.metadata = {
|
|
553
587
|
...ctx.metadata,
|
|
554
|
-
traceId: msg.msg_id,
|
|
588
|
+
traceId: msg.msg_id,
|
|
555
589
|
source: "palz-connector"
|
|
556
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
|
+
|
|
557
672
|
// STEP 6c: 创建回复分发器
|
|
558
673
|
const dispatcherParams = {
|
|
559
674
|
accountId,
|
|
@@ -580,6 +695,8 @@ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promi
|
|
|
580
695
|
msgType: msg.msg_type,
|
|
581
696
|
groupId,
|
|
582
697
|
mediaLocalRoots: resolveMediaLocalRoots(effectiveAgentId),
|
|
698
|
+
showProcess,
|
|
699
|
+
sessionKey: route.sessionKey,
|
|
583
700
|
});
|
|
584
701
|
|
|
585
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;
|
|
@@ -72,7 +76,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
72
76
|
`${tag}: [DISPATCHER 创建] conv=${conversationId} sender=${senderId} streaming=${enableStreaming}${streamState ? ` streamId=${streamState.streamId}` : ""}`,
|
|
73
77
|
);
|
|
74
78
|
|
|
75
|
-
const sendToIM = async (content: OpenAIContent, streamOpts?: StreamChunkOpts) => {
|
|
79
|
+
const sendToIM = async (content: OpenAIContent, streamOpts?: StreamChunkOpts, palzMsgType?: string, toolContent?: Record<string, unknown>) => {
|
|
76
80
|
const contentPreview = typeof content === "string" ? content.slice(0, 120) : JSON.stringify(content).slice(0, 120);
|
|
77
81
|
const sendInput = {
|
|
78
82
|
conversationId,
|
|
@@ -80,6 +84,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
80
84
|
conversationType,
|
|
81
85
|
senderId,
|
|
82
86
|
stream: streamOpts ? { streamId: streamOpts.streamId, seq: streamOpts.seq, isFinal: streamOpts.isFinal, deltaLen: streamOpts.delta?.length } : undefined,
|
|
87
|
+
palzMsgType,
|
|
83
88
|
};
|
|
84
89
|
log(`${tag}: [DISPATCHER→sendToIM] 输入: ${JSON.stringify(sendInput)}`);
|
|
85
90
|
const result = await sendToPalzIM({
|
|
@@ -92,11 +97,62 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
92
97
|
stream: streamOpts,
|
|
93
98
|
msgType,
|
|
94
99
|
groupId,
|
|
100
|
+
palzMsgType,
|
|
101
|
+
toolContent,
|
|
95
102
|
});
|
|
96
103
|
log(`${tag}: [DISPATCHER←sendToIM] 输出: ${JSON.stringify(result)}`);
|
|
97
104
|
return result;
|
|
98
105
|
};
|
|
99
106
|
|
|
107
|
+
// ============ 工具调用 & 思考过程中间过程监听 ============
|
|
108
|
+
let unsubscribeToolEvents: (() => void) | undefined;
|
|
109
|
+
let accumulatedThinking = "";
|
|
110
|
+
|
|
111
|
+
// 生成 runId,传给 dispatch 并用于事件过滤
|
|
112
|
+
// 这样可以精确关联当前 dispatcher 产生的工具事件,避免多用户并发串消息
|
|
113
|
+
const toolRunId = showProcess
|
|
114
|
+
? `palz_run_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
|
115
|
+
: undefined;
|
|
116
|
+
|
|
117
|
+
if (showProcess) {
|
|
118
|
+
log(`${tag}: [TOOL_EVENTS] 开启工具调用中间过程监听 runId=${toolRunId}`);
|
|
119
|
+
try {
|
|
120
|
+
unsubscribeToolEvents = core.events.onAgentEvent((evt: any) => {
|
|
121
|
+
// 按 runId 精确过滤,避免并发消息串事件
|
|
122
|
+
if (evt.runId !== toolRunId) return;
|
|
123
|
+
|
|
124
|
+
// ---- 工具事件 ----
|
|
125
|
+
if (evt.stream === "tool") {
|
|
126
|
+
const data = evt.data ?? {};
|
|
127
|
+
const phase = data.phase;
|
|
128
|
+
const toolName = data.name ?? data.toolName;
|
|
129
|
+
|
|
130
|
+
if (phase === "start") {
|
|
131
|
+
const display = resolveToolDisplay(toolName, data.args);
|
|
132
|
+
const text = formatToolStartText(toolName, data.args);
|
|
133
|
+
log(`${tag}: [TOOL_EVENTS] tool_start name=${toolName} detail=${display.detail ?? "(none)"}`);
|
|
134
|
+
sendToIM(text, undefined, "tool_start").catch((err: any) => {
|
|
135
|
+
error(`${tag}: [TOOL_EVENTS] tool_start 发送失败: ${err.message}`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (phase === "result") {
|
|
140
|
+
const isError = data.isError === true;
|
|
141
|
+
const meta = typeof data.meta === "string" ? data.meta : undefined;
|
|
142
|
+
const text = formatToolResultText(toolName, data.result, isError, meta);
|
|
143
|
+
const resultSummary = isError ? "Error" : formatResultSummary(data.result);
|
|
144
|
+
log(`${tag}: [TOOL_EVENTS] tool_result name=${toolName} is_error=${isError} summary=${resultSummary}`);
|
|
145
|
+
sendToIM(text, undefined, "tool_result").catch((err: any) => {
|
|
146
|
+
error(`${tag}: [TOOL_EVENTS] tool_result 发送失败: ${err.message}`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
error(`${tag}: [TOOL_EVENTS] 注册监听失败: ${String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
101
157
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
102
158
|
deliver: async (payload: any, info: any) => {
|
|
@@ -169,11 +225,18 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
169
225
|
},
|
|
170
226
|
onIdle: async () => {
|
|
171
227
|
log(`${tag}: [DISPATCHER idle]`);
|
|
228
|
+
if (unsubscribeToolEvents) {
|
|
229
|
+
log(`${tag}: [TOOL_EVENTS] 清理监听器`);
|
|
230
|
+
unsubscribeToolEvents();
|
|
231
|
+
unsubscribeToolEvents = undefined;
|
|
232
|
+
}
|
|
172
233
|
},
|
|
173
234
|
});
|
|
174
235
|
|
|
175
236
|
const palzReplyOptions = {
|
|
176
237
|
...replyOptions,
|
|
238
|
+
// 传入 runId 让 OpenClaw agent execution 使用,与 onAgentEvent 的 evt.runId 对应
|
|
239
|
+
...(toolRunId ? { runId: toolRunId } : {}),
|
|
177
240
|
onPartialReply: enableStreaming
|
|
178
241
|
? (payload: any) => {
|
|
179
242
|
const text = payload.text ?? "";
|
|
@@ -207,6 +270,28 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
|
|
|
207
270
|
});
|
|
208
271
|
}
|
|
209
272
|
: undefined,
|
|
273
|
+
onReasoningStream: showProcess
|
|
274
|
+
? (payload: any) => {
|
|
275
|
+
const text = payload.text ?? "";
|
|
276
|
+
if (text.trim()) {
|
|
277
|
+
accumulatedThinking = text;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
: undefined,
|
|
281
|
+
onReasoningEnd: showProcess
|
|
282
|
+
? () => {
|
|
283
|
+
if (!accumulatedThinking.trim()) return;
|
|
284
|
+
// 格式化思考文本:去掉 "Reasoning:\n" 前缀和 "_" 斜体标记
|
|
285
|
+
let text = accumulatedThinking;
|
|
286
|
+
accumulatedThinking = "";
|
|
287
|
+
text = text.replace(/^Reasoning:\n/, "");
|
|
288
|
+
text = text.replace(/^_|_$/gm, "");
|
|
289
|
+
log(`${tag}: [THINKING] 思考完成, len=${text.length}`);
|
|
290
|
+
sendToIM(text, undefined, "thinking").catch((err: any) => {
|
|
291
|
+
error(`${tag}: [THINKING] 发送失败: ${err.message}`);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
: undefined,
|
|
210
295
|
};
|
|
211
296
|
|
|
212
297
|
return { dispatcher, replyOptions: palzReplyOptions, markDispatchIdle };
|
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
|
}
|