From 8a385b8e1aee3c26310cf26fabb30375f6bd18f9 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sat, 27 Jun 2026 22:33:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20AI=20=E5=9B=9E=E5=A4=8D=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=B8=B2=E6=9F=93=20UX=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 思考过程默认折叠,流式时看不到实时思考 - StreamingIndicator 独立展示,与卡片内容脱节 - 思考和回复在同一卡片无阶段分隔 - 加载态只有光秃秃闪烁块 - 流式中的卡片和无流式的卡片看不出区别 优化: - 思考过程: 流式时自动展开(defaultExpanded),完成后可手动折叠 - 三阶段标签: 💭思考 / 🔧工具 / ✏️回复,带颜色和脉冲动画 - 流式无内容时显示「正在思考.../正在生成回复...」带打字光标 - 有内容时末尾添加闪烁光标指示持续输出中 - 卡片流式时边框高亮(borderColor + boxShadow) - StreamingIndicator 仅在没有可见内容时显示「正在连接 AI...」 --- src/components/chat/AssistantMessage.tsx | 150 ++++++++++++++++++--- src/components/chat/StreamingIndicator.tsx | 27 ++-- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index 60b8949..f2a7091 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -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 ( - { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }}> - - - {message.reasoningContent && } - {message.toolCalls?.map((tc) => )} - {content && ( - - {children}; - return ; - }, - }}>{content} - + { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }} + > + + + + + + {/* ===== 阶段 1: 思考过程 ===== */} + {hasThinking && ( + <> + + + 💭 {isThinking ? '正在思考...' : '思考过程'} + + {isThinking && ( + + )} + + + + )} + + {/* ===== 阶段 2: 工具调用 ===== */} + {hasTools && ( + <> + {hasThinking && } + + + 🔧 工具调用 + + + {message.toolCalls!.map((tc) => ( + + ))} + + )} + + {/* ===== 阶段 3: 回复内容 ===== */} + {(hasContent || isStreaming) && ( + <> + {(hasThinking || hasTools) && ( + + + ✏️ 回复 + + {isStreaming && !isThinking && ( + + )} + + )} + + {hasContent ? ( + + {children}; + return ; + }, + }} + > + {content} + + + ) : isStreaming ? ( + + {isThinking ? '正在思考...' : '正在生成回复...'} + + + ) : null} + + {/* 流式打字光标 */} + {isStreaming && hasContent && ( + + )} + + )} + + {/* 时间戳 */} + {!isStreaming && ( + + {formatTime(message.timestamp)} + )} - {isStreaming && !streamContent && } - {!isStreaming && {formatTime(message.timestamp)}} - {contextMenu && setContextMenu(null)} />} + + {contextMenu && ( + setContextMenu(null)} + /> + )} ); } diff --git a/src/components/chat/StreamingIndicator.tsx b/src/components/chat/StreamingIndicator.tsx index 9623331..e86c79f 100644 --- a/src/components/chat/StreamingIndicator.tsx +++ b/src/components/chat/StreamingIndicator.tsx @@ -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 ( - - - - - - - {agentStatus === 'thinking' ? '思考中...' : agentStatus === 'executing' ? '执行中...' : '处理中...'} + + + + 正在连接 AI... );