refactor: AI 回复卡片渲染 UX 优化

问题:
- 思考过程默认折叠,流式时看不到实时思考
- StreamingIndicator 独立展示,与卡片内容脱节
- 思考和回复在同一卡片无阶段分隔
- 加载态只有光秃秃闪烁块
- 流式中的卡片和无流式的卡片看不出区别

优化:
- 思考过程: 流式时自动展开(defaultExpanded),完成后可手动折叠
- 三阶段标签: 💭思考 / 🔧工具 / ✏️回复,带颜色和脉冲动画
- 流式无内容时显示「正在思考.../正在生成回复...」带打字光标
- 有内容时末尾添加闪烁光标指示持续输出中
- 卡片流式时边框高亮(borderColor + boxShadow)
- StreamingIndicator 仅在没有可见内容时显示「正在连接 AI...」
This commit is contained in:
thzxx
2026-06-27 22:33:51 +08:00
parent 4924e2d339
commit 8a385b8e1a
2 changed files with 145 additions and 32 deletions
+120 -14
View File
@@ -1,5 +1,10 @@
/**
* AssistantMessage — Agent 回复消息
* AssistantMessage — Agent 回复消息卡片
*
* 分为三个阶段,流式时实时展示:
* 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content
* 2. 🔧 工具调用 — ToolCallCard 展示调用和结果
* 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出
*/
import { useState, useCallback } from 'react';
@@ -16,42 +21,143 @@ import { ToolCallCard } from './ToolCallCard';
import { formatTime } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
interface AssistantMessageProps { message: ChatMessage; isStreaming?: boolean; streamContent?: string; }
interface AssistantMessageProps {
message: ChatMessage;
isStreaming?: boolean;
streamContent?: string;
}
export function AssistantMessage({ message, isStreaming, streamContent }: AssistantMessageProps): React.JSX.Element {
const content = isStreaming ? streamContent ?? '' : message.content;
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const sendMessage = useAgentStore((s) => s.sendMessage);
const agentStatus = useAgentStore((s) => s.agentStatus);
const handleRegenerate = useCallback(() => {
const lastUserMsg = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg) sendMessage(lastUserMsg.content);
}, [sendMessage]);
const contextMenuItems = createContextMenuItems('message', { content }).map((item) => item.id === 'regenerate' ? { ...item, action: handleRegenerate } : item);
const contextMenuItems = createContextMenuItems('message', { content }).map((item) =>
item.id === 'regenerate' ? { ...item, action: handleRegenerate } : item,
);
const hasThinking = !!message.reasoningContent;
const hasTools = !!message.toolCalls?.length;
const hasContent = !!content;
const isThinking = agentStatus === 'thinking' && isStreaming;
return (
<Stack direction="row" spacing={1.5} sx={{ animation: 'fadeInUp 300ms ease-out' }} onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', color: 'primary.main', flexShrink: 0 }}><Bot size={16} /></Avatar>
<Box sx={{ flex: 1, minWidth: 0, maxWidth: 768, borderRadius: 3, px: 2, py: 1.5, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}>
{message.reasoningContent && <ThoughtBlock content={message.reasoningContent} />}
{message.toolCalls?.map((tc) => <ToolCallCard key={tc.id} toolCall={tc} />)}
{content && (
<Stack
direction="row"
spacing={1.5}
sx={{ animation: 'fadeInUp 300ms ease-out' }}
onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}
>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main', color: 'primary.main', flexShrink: 0 }}>
<Bot size={16} />
</Avatar>
<Box
sx={{
flex: 1, minWidth: 0, maxWidth: 768, borderRadius: 3, px: 2, py: 1.5,
bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider',
transition: 'border-color 200ms, box-shadow 200ms',
...(isStreaming ? { borderColor: 'rgba(129,140,248,0.3)', boxShadow: '0 0 0 1px rgba(129,140,248,0.1)' } : {}),
}}
>
{/* ===== 阶段 1: 思考过程 ===== */}
{hasThinking && (
<>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
💭 {isThinking ? '正在思考...' : '思考过程'}
</Typography>
{isThinking && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#fbbf24', animation: 'pulse 1.5s infinite' }} />
)}
</Stack>
<ThoughtBlock content={message.reasoningContent!} defaultExpanded={!!isStreaming} />
</>
)}
{/* ===== 阶段 2: 工具调用 ===== */}
{hasTools && (
<>
{hasThinking && <Box sx={{ height: 8 }} />}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<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} />
))}
</>
)}
{/* ===== 阶段 3: 回复内容 ===== */}
{(hasContent || isStreaming) && (
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5, mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
</Typography>
{isStreaming && !isThinking && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#34d399', animation: 'pulse 1.5s infinite' }} />
)}
</Stack>
)}
{hasContent ? (
<Box className="prose-metona">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight, rehypeRaw]} components={{
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? '');
const codeStr = String(children).replace(/\n$/, '');
if (!match) return <code className={className} {...props}>{children}</code>;
return <CodeBlock language={match[1]} code={codeStr} />;
},
}}>{content}</ReactMarkdown>
}}
>
{content}
</ReactMarkdown>
</Box>
) : isStreaming ? (
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
{isThinking ? '正在思考...' : '正在生成回复...'}
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
</Typography>
) : null}
{/* 流式打字光标 */}
{isStreaming && hasContent && (
<Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.25, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite', verticalAlign: 'middle' }} />
)}
</>
)}
{/* 时间戳 */}
{!isStreaming && (
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</Typography>
)}
{isStreaming && !streamContent && <Box component="span" sx={{ display: 'inline-block', width: 8, height: 16, ml: 0.5, bgcolor: 'primary.main', animation: 'blink 1s step-end infinite' }} />}
{!isStreaming && <Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>}
</Box>
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={contextMenuItems} onClose={() => setContextMenu(null)} />}
{contextMenu && (
<ContextMenu
type="message"
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
onClose={() => setContextMenu(null)}
/>
)}
</Stack>
);
}
+17 -10
View File
@@ -1,24 +1,31 @@
/**
* StreamingIndicator — 流式加载指示器
*
* 仅在流式开始、尚无任何内容输出时显示初始加载状态。
* 一旦有思考内容或文本输出,AssistantMessage 卡片内部自行展示进度。
*/
import { Box, Typography, CircularProgress } from '@mui/material';
import { Brain, Loader2 } from 'lucide-react';
import { Box, Typography } from '@mui/material';
import { Loader2 } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
export function StreamingIndicator(): React.JSX.Element | null {
const isStreaming = useAgentStore((s) => s.isStreaming);
const agentStatus = useAgentStore((s) => s.agentStatus);
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;
if (hasVisibleContent) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, animation: 'fadeIn 200ms ease-out' }}>
<Box sx={{ width: 32, height: 32, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'secondary.main', color: 'primary.main' }}>
<Brain size={16} style={{ animation: 'pulse 2s infinite' }} />
</Box>
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite', color: '#818cf8' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{agentStatus === 'thinking' ? '思考中...' : agentStatus === 'executing' ? '执行中...' : '处理中...'}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2, pl: 5, animation: 'fadeIn 200ms ease-out' }}>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite', color: '#818cf8' }} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 12 }}>
AI...
</Typography>
</Box>
);