From 34011a6fa358a4958ce80ae0448267255aca2612 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Fri, 29 May 2026 08:46:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=9D=A2=E6=9D=BFUI=EF=BC=8C=E5=A4=96=E9=83=A8=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E8=BF=87=E6=BB=A4=E5=B7=A5=E5=85=B7=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 飞书/微信渠道不再推送 ToolResult/ToolPending 消息 - 聊天面板过滤 tool_call 消息,工具调用仅在工具面板展示 - 工具面板增加折叠预览、JSON格式化、状态动画等视觉优化 Co-Authored-By: Claude Opus 4.7 --- src/channels/feishu.rs | 5 + src/channels/wechat.rs | 5 + web/src/App.tsx | 44 +++++- web/src/components/Panel/ToolPanel.tsx | 186 ++++++++++++++++++------- 4 files changed, 184 insertions(+), 56 deletions(-) diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 351d25c..59f8c1f 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use tokio::sync::{RwLock, broadcast}; use crate::bus::{MediaItem, MessageBus, OutboundMessage}; +use crate::bus::message::OutboundEventKind; use crate::channels::base::{Channel, ChannelError}; use crate::config::{FeishuChannelConfig, LLMProviderConfig}; use crate::text::{char_count, truncate_with_ellipsis}; @@ -2401,6 +2402,10 @@ impl Channel for FeishuChannel { } async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { + if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending) { + return Ok(()); + } + let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } else { diff --git a/src/channels/wechat.rs b/src/channels/wechat.rs index 404cebf..226aae5 100644 --- a/src/channels/wechat.rs +++ b/src/channels/wechat.rs @@ -13,6 +13,7 @@ use tokio::task::JoinHandle; use wechatbot::{BotOptions, SendContent, WeChatBot}; use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage}; +use crate::bus::message::OutboundEventKind; use crate::channels::base::{Channel, ChannelError}; use crate::config::{LLMProviderConfig, WechatChannelConfig}; @@ -286,6 +287,10 @@ impl Channel for WechatChannel { } async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { + if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending) { + return Ok(()); + } + let text = msg.content.trim().to_string(); let mut text_sent = false; diff --git a/web/src/App.tsx b/web/src/App.tsx index 035963c..e232b24 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { Zap, Cpu, MessageSquare } from 'lucide-react' import { ChatContainer } from './components/Chat/ChatContainer' import { TopicList } from './components/Sidebar/TopicList' @@ -7,7 +7,7 @@ import { ToolPanel } from './components/Panel/ToolPanel' import { ConnectionStatus } from './components/ConnectionStatus' import { useWebSocket } from './hooks/useWebSocket' import { useChat } from './hooks/useChat' -import type { Command } from './types/protocol' +import type { ChatMessage, Command } from './types/protocol' const WS_URL = 'ws://127.0.0.1:19876/ws' @@ -156,7 +156,45 @@ function App() { [sendMessage, handleCommand, switchTopic, selectTopic] ) - const chatMessages = messages.filter((message) => message.type !== 'tool_result') + const chatMessages = useMemo(() => { + const result: ChatMessage[] = [] + const toolCallIndex = new Map() + + for (const msg of messages) { + if (msg.type === 'tool_call') { + toolCallIndex.set(msg.toolCallId || msg.id, result.length) + result.push({ + ...msg, + type: 'merged_tool', + status: 'calling', + callContent: msg.content, + resultContent: '', + }) + } else if (msg.type === 'tool_result') { + const idx = toolCallIndex.get(msg.toolCallId || msg.id) + if (idx !== undefined) { + result[idx] = { + ...result[idx], + status: 'result', + resultContent: msg.content, + } + } + } else if (msg.type === 'tool_pending') { + const idx = toolCallIndex.get(msg.toolCallId || msg.id) + if (idx !== undefined) { + result[idx] = { + ...result[idx], + status: 'pending', + resultContent: msg.content, + } + } + } else { + result.push(msg) + } + } + + return result + }, [messages]) const toolMessages = messages return ( diff --git a/web/src/components/Panel/ToolPanel.tsx b/web/src/components/Panel/ToolPanel.tsx index 104804f..63d1aac 100644 --- a/web/src/components/Panel/ToolPanel.tsx +++ b/web/src/components/Panel/ToolPanel.tsx @@ -15,6 +15,16 @@ interface ToolCallItem { callContent: string } +function formatResultText(content: string): string { + if (!content) return '' + try { + const parsed = JSON.parse(content) + return JSON.stringify(parsed, null, 2) + } catch { + return content + } +} + function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] { const map = new Map() @@ -68,31 +78,42 @@ export function ToolPanel({ messages }: ToolPanelProps) { }) } - const getStatusIcon = (status: ToolCallItem['status']) => { + const getStatusConfig = (status: ToolCallItem['status']) => { switch (status) { case 'calling': - return + return { + icon: Play, + iconColor: 'text-amber-400', + bgClass: 'bg-amber-400', + borderClass: 'border-amber-500/30', + label: '执行中', + labelClass: 'text-amber-400', + } case 'result': - return + return { + icon: Check, + iconColor: 'text-emerald-400', + bgClass: 'bg-emerald-400', + borderClass: 'border-emerald-500/30', + label: '已完成', + labelClass: 'text-emerald-400', + } case 'pending': - return - } - } - - const getStatusText = (status: ToolCallItem['status']) => { - switch (status) { - case 'calling': - return '执行中' - case 'result': - return '已完成' - case 'pending': - return '待确认' + return { + icon: AlertTriangle, + iconColor: 'text-orange-400', + bgClass: 'bg-orange-400', + borderClass: 'border-orange-500/30', + label: '待确认', + labelClass: 'text-orange-400', + } } } if (toolCalls.length === 0) { return (
+
工具调用 @@ -109,6 +130,7 @@ export function ToolPanel({ messages }: ToolPanelProps) { return (
+
工具调用 @@ -118,48 +140,106 @@ export function ToolPanel({ messages }: ToolPanelProps) {
- {toolCalls.map((tool) => ( -
- - {expandedTools.has(tool.toolCallId) && ( -
- {tool.arguments ? ( -
-
参数:
-
{String(JSON.stringify(tool.arguments, null, 2))}
-
- ) : null} -
-
结果:
-
- {tool.resultContent || tool.callContent} -
+
- )} -
- ))} + + {isExpanded ? ( + + ) : ( + + )} + + + + {/* 结果预览区 — 始终可见 */} + {hasResult && ( +
+
toggleExpand(tool.toolCallId)} + > + {isExpanded ? ( +
{formattedContent}
+ ) : ( + {previewLines} + )} +
+ {!isExpanded && hasMore && ( +
+ 点击展开全部 ({displayContent.split('\n').length} 行) +
+ )} +
+ )} + + {/* 展开区域:参数 */} + {isExpanded && tool.arguments ? ( +
+
参数:
+
+                      {JSON.stringify(tool.arguments, null, 2)}
+                    
+
+ ) : null} +
+ ) + })}
) } + +const animStyles = ` +@keyframes tool-result-in { + from { max-height: 0; opacity: 0; } + to { max-height: 80px; opacity: 1; } +} + +.tool-card { + transition: border-color 0.5s ease; +} + +.tool-status-icon { + transition: transform 0.3s ease; +} + +.tool-result-enter { + animation: tool-result-in 0.4s ease-out; +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +`