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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.3.1",
4
+ "version": "1.3.3",
5
5
  "description": "Palz IM 接入 OpenClaw",
6
6
  "channels": [
7
7
  "palz-connector"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palz-connector",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "enabled": true,
3
- "streamUrl": "ws://14.103.148.99:9090/ws/bot",
4
- "apiBaseUrl": "http://14.103.148.99:9090/api",
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
  }
@@ -3,5 +3,6 @@
3
3
  "streamUrl": "wss://claw-server.csaiagent.com/ws/bot",
4
4
  "apiBaseUrl": "https://claw-server.csaiagent.com/api",
5
5
  "sessionTimeout": 1800000,
6
- "groupContextCache": true
6
+ "groupContextCache": true,
7
+ "showProcess":true
7
8
  }
@@ -3,5 +3,6 @@
3
3
  "streamUrl": "wss://claw-server.csagentai.com/ws/bot",
4
4
  "apiBaseUrl": "https://claw-server.csagentai.com/api",
5
5
  "sessionTimeout": 1800000,
6
- "groupContextCache": true
6
+ "groupContextCache": true,
7
+ "showProcess":true
7
8
  }
@@ -3,5 +3,6 @@
3
3
  "streamUrl": "wss://claw-server.csjkagent.com/ws/bot",
4
4
  "apiBaseUrl": "https://claw-server.csjkagent.com/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;
@@ -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
  }