refactor: 移除多余依赖,统一 MUI 为唯一 UI 库

- 移除 radix-ui、clsx、tailwind-merge、class-variance-authority、react-router-dom

- 前后端全面适配 MUI 组件,清理残留 Tailwind 工具类引用

- 优化 Agent Loop 引擎、IPC 处理器、Provider Adapter 等后端模块

- 新增 Agent 网络工具通用设计文档 v2
This commit is contained in:
thzxx
2026-07-05 19:15:48 +08:00
parent ba85328c4f
commit f4532a2bb2
50 changed files with 1474 additions and 2042 deletions
+15 -10
View File
@@ -18,17 +18,18 @@ import type { ChatMessage } from '@renderer/stores/agent-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { ThoughtBlock } from './ThoughtBlock';
import { ToolCallCard } from './ToolCallCard';
import { ToolResultBlock } from './ToolResultBlock';
import { formatTime } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
interface AssistantMessageProps {
message: ChatMessage;
isStreaming?: boolean;
streamContent?: string;
}
export function AssistantMessage({ message, isStreaming, streamContent }: AssistantMessageProps): React.JSX.Element {
const content = isStreaming ? streamContent ?? '' : message.content;
export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
const content = message.content;
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -61,7 +62,7 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{/* ===== 阶段 1: 思考过程 ===== */}
{hasThinking && (
<>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
💭 {isThinking ? '正在思考...' : '思考过程'}
</Typography>
@@ -77,13 +78,18 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{hasTools && (
<>
{hasThinking && <Box sx={{ height: 8 }} />}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
🔧
</Typography>
</Stack>
{message.toolCalls!.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
<Box key={tc.id}>
<ToolCallCard toolCall={tc} />
{tc.status === 'success' && tc.result != null && (
<ToolResultBlock toolCall={tc} />
)}
</Box>
))}
</>
)}
@@ -92,7 +98,7 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{(hasContent || isStreaming) && (
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5, mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mt: 0.5, mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
</Typography>
@@ -144,7 +150,6 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{contextMenu && (
<ContextMenu
type="message"
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
@@ -164,7 +169,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
return (
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary' }}>
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{language}</span>
<Tooltip title={copied ? '已复制' : '复制'}>
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
@@ -186,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string {
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props.children);
return extractTextContent((children as React.ReactElement).props as React.ReactNode);
}
return '';
}
+2 -2
View File
@@ -251,7 +251,7 @@ export function ChatInput(): React.JSX.Element {
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 14, lineHeight: '28px', resize: 'none', fontFamily: 'inherit', minHeight: 42, maxHeight: 160 }}
/>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1, minHeight: 32 }}>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
{/* 左侧:附件按钮 */}
<Tooltip title={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
@@ -260,7 +260,7 @@ export function ChatInput(): React.JSX.Element {
</Tooltip>
{/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
<Square size={12} style={{ marginRight: 6 }} />
+54 -5
View File
@@ -4,19 +4,21 @@
* 根据消息 role 路由到对应的子组件渲染。
*/
import { Box, Typography, Stack } from '@mui/material';
import { Terminal } from 'lucide-react';
import type { ChatMessage } from '@renderer/stores/agent-store';
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { SystemMessage } from './SystemMessage';
import { formatTime } from '@renderer/lib/formatters';
interface MessageItemProps {
message: ChatMessage;
isLast?: boolean;
isStreaming?: boolean;
streamContent?: string;
}
export function MessageItem({ message, isLast, isStreaming, streamContent }: MessageItemProps): React.JSX.Element {
export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
switch (message.role) {
case 'user':
return <UserMessage message={message} />;
@@ -26,16 +28,63 @@ export function MessageItem({ message, isLast, isStreaming, streamContent }: Mes
<AssistantMessage
message={message}
isStreaming={isLast && isStreaming}
streamContent={streamContent}
/>
);
case 'tool':
// 工具消息渲染为系统消息样式
return <SystemMessage message={message} />;
// 工具消息渲染为独立的结果块
return <ToolMessage message={message} />;
case 'system':
default:
return <SystemMessage message={message} />;
}
}
/** ToolMessage — 独立的工具结果消息(role=tool) */
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: '#22d3ee',
bgcolor: 'secondary.main',
px: 1.5,
py: 1,
mb: 2,
animation: 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Terminal size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</Typography>
</Stack>
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
bgcolor: 'background.default',
color: 'text.secondary',
fontFamily: "'SF Mono',monospace",
maxHeight: 120,
m: 0,
whiteSpace: 'pre-wrap',
}}
>
{message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content}
</Box>
</Box>
);
}
+2 -3
View File
@@ -11,10 +11,9 @@ import { StreamingIndicator } from './StreamingIndicator';
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const streamingContent = useAgentStore((s) => s.streamingContent);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingContent]);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
if (messages.length === 0 && !isStreaming) {
return (
@@ -33,7 +32,7 @@ export function MessageList(): React.JSX.Element {
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3 }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => (
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} streamContent={streamingContent} />
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
+5 -2
View File
@@ -11,14 +11,17 @@ import { useAgentStore } from '@renderer/stores/agent-store';
export function StreamingIndicator(): React.JSX.Element | null {
const isStreaming = useAgentStore((s) => s.isStreaming);
const streamingContent = useAgentStore((s) => s.streamingContent);
const messages = useAgentStore((s) => s.messages);
// 已有内容输出时隐藏(AssistantMessage 内部展示)
if (!isStreaming) return null;
const lastMsg = messages[messages.length - 1];
const hasVisibleContent = !!streamingContent || !!lastMsg?.reasoningContent || !!lastMsg?.toolCalls?.length;
// 仅当最后一条是 assistant 消息且有内容时才隐藏
// 如果最后一条是 user 消息(刚发送,等待 AI 响应),应显示加载指示器
const isLastAssistant = lastMsg?.role === 'assistant';
const hasVisibleContent = isLastAssistant &&
(!!lastMsg?.content || !!lastMsg?.reasoningContent || !!lastMsg?.toolCalls?.length);
if (hasVisibleContent) return null;
return (
+2 -1
View File
@@ -2,7 +2,7 @@
* ThoughtBlock — 思考过程展示
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Collapse } from '@mui/material';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
@@ -10,6 +10,7 @@ interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => { setExpanded(defaultExpanded); }, [defaultExpanded]);
if (!content) return <></>;
return (
+1 -7
View File
@@ -27,7 +27,7 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.75 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.75, alignItems: 'center' }}>
<Wrench size={12} style={{ color: '#a855f7' }} />
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
<Chip
@@ -51,12 +51,6 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
{toolCall.status === 'error' && toolCall.error && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>{toolCall.error}</Typography>
)}
{toolCall.status === 'success' && toolCall.result != null && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{typeof toolCall.result === 'string' ? toolCall.result.slice(0, 200) : JSON.stringify(toolCall.result).slice(0, 200)}
</Typography>
)}
</Box>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
return (
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<FileText size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} </Typography>
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
+1 -1
View File
@@ -61,7 +61,7 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
</Box>
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
</Stack>
);
}