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:
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 }} /> 中断
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user