feat: 优化工具面板UI,外部渠道过滤工具消息
- 飞书/微信渠道不再推送 ToolResult/ToolPending 消息 - 聊天面板过滤 tool_call 消息,工具调用仅在工具面板展示 - 工具面板增加折叠预览、JSON格式化、状态动画等视觉优化 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5e5de7ce9f
commit
34011a6fa3
@ -11,6 +11,7 @@ use serde::Deserialize;
|
|||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
|
||||||
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
||||||
|
use crate::bus::message::OutboundEventKind;
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
||||||
use crate::text::{char_count, truncate_with_ellipsis};
|
use crate::text::{char_count, truncate_with_ellipsis};
|
||||||
@ -2401,6 +2402,10 @@ impl Channel for FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
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_") {
|
let receive_id = if msg.chat_id.starts_with("oc_") {
|
||||||
&msg.chat_id
|
&msg.chat_id
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use tokio::task::JoinHandle;
|
|||||||
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
||||||
|
|
||||||
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
||||||
|
use crate::bus::message::OutboundEventKind;
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
||||||
|
|
||||||
@ -286,6 +287,10 @@ impl Channel for WechatChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
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 text = msg.content.trim().to_string();
|
||||||
let mut text_sent = false;
|
let mut text_sent = false;
|
||||||
|
|
||||||
|
|||||||
@ -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 { Zap, Cpu, MessageSquare } from 'lucide-react'
|
||||||
import { ChatContainer } from './components/Chat/ChatContainer'
|
import { ChatContainer } from './components/Chat/ChatContainer'
|
||||||
import { TopicList } from './components/Sidebar/TopicList'
|
import { TopicList } from './components/Sidebar/TopicList'
|
||||||
@ -7,7 +7,7 @@ import { ToolPanel } from './components/Panel/ToolPanel'
|
|||||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||||
import { useWebSocket } from './hooks/useWebSocket'
|
import { useWebSocket } from './hooks/useWebSocket'
|
||||||
import { useChat } from './hooks/useChat'
|
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'
|
const WS_URL = 'ws://127.0.0.1:19876/ws'
|
||||||
|
|
||||||
@ -156,7 +156,45 @@ function App() {
|
|||||||
[sendMessage, handleCommand, switchTopic, selectTopic]
|
[sendMessage, handleCommand, switchTopic, selectTopic]
|
||||||
)
|
)
|
||||||
|
|
||||||
const chatMessages = messages.filter((message) => message.type !== 'tool_result')
|
const chatMessages = useMemo(() => {
|
||||||
|
const result: ChatMessage[] = []
|
||||||
|
const toolCallIndex = new Map<string, number>()
|
||||||
|
|
||||||
|
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
|
const toolMessages = messages
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -15,6 +15,16 @@ interface ToolCallItem {
|
|||||||
callContent: string
|
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[] {
|
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
||||||
const map = new Map<string, ToolCallItem>()
|
const map = new Map<string, ToolCallItem>()
|
||||||
|
|
||||||
@ -68,31 +78,42 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (status: ToolCallItem['status']) => {
|
const getStatusConfig = (status: ToolCallItem['status']) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'calling':
|
case 'calling':
|
||||||
return <Play className="h-3 w-3 text-amber-400 animate-pulse" />
|
return {
|
||||||
|
icon: Play,
|
||||||
|
iconColor: 'text-amber-400',
|
||||||
|
bgClass: 'bg-amber-400',
|
||||||
|
borderClass: 'border-amber-500/30',
|
||||||
|
label: '执行中',
|
||||||
|
labelClass: 'text-amber-400',
|
||||||
|
}
|
||||||
case 'result':
|
case 'result':
|
||||||
return <Check className="h-3 w-3 text-emerald-400" />
|
return {
|
||||||
|
icon: Check,
|
||||||
|
iconColor: 'text-emerald-400',
|
||||||
|
bgClass: 'bg-emerald-400',
|
||||||
|
borderClass: 'border-emerald-500/30',
|
||||||
|
label: '已完成',
|
||||||
|
labelClass: 'text-emerald-400',
|
||||||
|
}
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return <AlertTriangle className="h-3 w-3 text-orange-400" />
|
return {
|
||||||
}
|
icon: AlertTriangle,
|
||||||
}
|
iconColor: 'text-orange-400',
|
||||||
|
bgClass: 'bg-orange-400',
|
||||||
const getStatusText = (status: ToolCallItem['status']) => {
|
borderClass: 'border-orange-500/30',
|
||||||
switch (status) {
|
label: '待确认',
|
||||||
case 'calling':
|
labelClass: 'text-orange-400',
|
||||||
return '执行中'
|
}
|
||||||
case 'result':
|
|
||||||
return '已完成'
|
|
||||||
case 'pending':
|
|
||||||
return '待确认'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolCalls.length === 0) {
|
if (toolCalls.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
<style>{animStyles}</style>
|
||||||
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
||||||
工具调用
|
工具调用
|
||||||
@ -109,6 +130,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
<style>{animStyles}</style>
|
||||||
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
<div className="border-b border-white/8 p-4 font-semibold text-white flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
<Terminal className="h-4 w-4 text-[#00f0ff]" />
|
||||||
工具调用
|
工具调用
|
||||||
@ -118,48 +140,106 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{toolCalls.map((tool) => (
|
{toolCalls.map((tool) => {
|
||||||
<div
|
const config = getStatusConfig(tool.status)
|
||||||
key={tool.toolCallId}
|
const StatusIcon = config.icon
|
||||||
className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
|
const isExpanded = expandedTools.has(tool.toolCallId)
|
||||||
>
|
const hasResult = tool.resultContent.length > 0
|
||||||
<button
|
const displayContent = tool.resultContent || tool.callContent
|
||||||
onClick={() => toggleExpand(tool.toolCallId)}
|
const formattedContent = formatResultText(displayContent)
|
||||||
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
|
const previewLines = displayContent.split('\n').slice(0, 2).join('\n')
|
||||||
|
const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tool.toolCallId}
|
||||||
|
className={`rounded-xl border bg-[#1a1a25]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
{getStatusIcon(tool.status)}
|
onClick={() => toggleExpand(tool.toolCallId)}
|
||||||
<span className="font-medium text-zinc-300">{tool.toolName}</span>
|
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
|
||||||
<span className="text-xs text-zinc-500">
|
>
|
||||||
{getStatusText(tool.status)}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
</span>
|
<span className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}>
|
||||||
</div>
|
<StatusIcon className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`} />
|
||||||
{expandedTools.has(tool.toolCallId) ? (
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
<span className="font-medium text-zinc-300 truncate">{tool.toolName}</span>
|
||||||
) : (
|
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
|
||||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
{config.label}
|
||||||
)}
|
</span>
|
||||||
</button>
|
|
||||||
{expandedTools.has(tool.toolCallId) && (
|
|
||||||
<div className="border-t border-white/8 px-3 py-2 bg-black/20">
|
|
||||||
{tool.arguments ? (
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="text-xs font-medium text-zinc-500 mb-1">参数:</div>
|
|
||||||
<pre className="rounded-lg bg-black/40 p-2 text-xs overflow-x-auto text-zinc-400 font-mono">{String(JSON.stringify(tool.arguments, null, 2))}</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium text-zinc-500 mb-1">结果:</div>
|
|
||||||
<div className="max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 text-xs whitespace-pre-wrap text-zinc-400 font-mono">
|
|
||||||
{tool.resultContent || tool.callContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="flex-shrink-0 ml-2">
|
||||||
)}
|
{isExpanded ? (
|
||||||
</div>
|
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
||||||
))}
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 结果预览区 — 始终可见 */}
|
||||||
|
{hasResult && (
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<div
|
||||||
|
className={`rounded-lg bg-black/30 px-2.5 py-2 text-xs text-zinc-400 font-mono cursor-pointer hover:bg-black/40 transition-colors ${
|
||||||
|
isExpanded ? '' : 'line-clamp-2'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleExpand(tool.toolCallId)}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<pre className="whitespace-pre-wrap break-all m-0">{formattedContent}</pre>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isExpanded && hasMore && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-1 px-1">
|
||||||
|
点击展开全部 ({displayContent.split('\n').length} 行)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 展开区域:参数 */}
|
||||||
|
{isExpanded && tool.arguments ? (
|
||||||
|
<div className="border-t border-white/8 px-3 py-2 bg-black/20">
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">参数:</div>
|
||||||
|
<pre className="rounded-lg bg-black/40 p-2 text-xs overflow-x-auto text-zinc-400 font-mono whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(tool.arguments, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user