فهرست منبع

Merge pull request #2333 from visuddhinanda/development

Development
visuddhinanda 9 ماه پیش
والد
کامیت
a9cc964712

+ 308 - 287
dashboard-v4/dashboard/src/components/chat/AiChat.tsx

@@ -36,14 +36,19 @@ const { TextArea } = Input;
 const { Text } = Typography;
 
 // 类型定义
-interface Message {
+export interface MessageVersion {
+  content: string;
+  model: string;
+}
+
+export interface Message {
   id: number;
-  type: "user" | "ai";
+  type: "user" | "ai" | "error";
   content: string;
   timestamp: string;
   model?: string;
-  versions?: string[]; // 存储所有版本的内容
-  currentVersionIndex?: number; // 当前显示的版本索引
+  versions?: MessageVersion[];
+  currentVersionIndex?: number;
 }
 
 interface OpenAIMessage {
@@ -51,11 +56,6 @@ interface OpenAIMessage {
   content: string;
 }
 
-interface AIModel {
-  key: string;
-  label: string;
-}
-
 interface StreamTypeController {
   addToken: (token: string) => void;
   complete: () => void;
@@ -86,11 +86,14 @@ const AIChatComponent = ({
   const [selectedModel, setSelectedModel] = useState<string>("");
   const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
   const [editingContent, setEditingContent] = useState<string>("");
+  const [refreshingMessageId, setRefreshingMessageId] = useState<number | null>(
+    null
+  );
 
   const messagesEndRef = useRef<HTMLDivElement>(null);
   const [isTyping, setIsTyping] = useState<boolean>(false);
   const [currentTypingMessage, setCurrentTypingMessage] = useState<string>("");
-  const [models, setModels] = useState<IAiModel[]>(); // 可用的AI模型
+  const [models, setModels] = useState<IAiModel[]>();
 
   const scrollToBottom = useCallback(() => {
     messagesEndRef.current?.scrollIntoView({
@@ -120,35 +123,9 @@ const AIChatComponent = ({
     if (initMessage) {
       setMessages([]);
       setInputValue(initMessage);
-      sendMessage();
     }
   }, [initMessage]);
 
-  // 打字机效果 - 支持流式输入
-  const typeWriter = useCallback(
-    (text: string, callback: () => void): NodeJS.Timeout => {
-      setIsTyping(true);
-      setCurrentTypingMessage("");
-      let index = 0;
-
-      const timer = setInterval(() => {
-        if (index < text.length) {
-          setCurrentTypingMessage((prev) => prev + text.charAt(index));
-          index++;
-        } else {
-          clearInterval(timer);
-          setIsTyping(false);
-          setCurrentTypingMessage("");
-          callback();
-        }
-      }, 30);
-
-      return timer;
-    },
-    []
-  );
-
-  // 流式打字机效果
   const streamTypeWriter = useCallback(
     (
       onToken?: (content: string) => void,
@@ -179,17 +156,16 @@ const AIChatComponent = ({
     []
   );
 
-  // 调用OpenAI API - 支持流式输出
   const callOpenAI = useCallback(
     async (
       messages: OpenAIMessage[],
       isRegenerate: boolean = false,
       messageIndex?: number
-    ): Promise<void> => {
-      setIsLoading(false); // 开始流式输出时取消loading状态
+    ): Promise<{ success: boolean; content?: string; error?: string }> => {
+      setIsLoading(false);
       if (typeof process.env.REACT_APP_OPENAI_PROXY === "undefined") {
         console.error("no REACT_APP_OPENAI_PROXY");
-        return;
+        return { success: false, error: "API配置错误" };
       }
       try {
         const payload = {
@@ -217,7 +193,6 @@ const AIChatComponent = ({
           throw new Error(`HTTP error! status: ${response.status}`);
         }
 
-        // 处理流式响应
         const reader = response.body?.getReader();
         if (!reader) {
           throw new Error("无法获取响应流");
@@ -226,42 +201,47 @@ const AIChatComponent = ({
         const decoder = new TextDecoder();
         let buffer = "";
 
-        // 创建流式打字机效果
         const typeController = streamTypeWriter(
-          (content: string) => {
-            // 每次添加token时的回调
-          },
+          (content: string) => {},
           (finalContent: string) => {
-            // 完成时的回调
             if (isRegenerate && messageIndex !== undefined) {
-              // 重新生成时,添加到版本历史中
               setMessages((prev) => {
                 const newMessages = [...prev];
                 const targetMessage = newMessages[messageIndex];
                 if (targetMessage) {
                   if (!targetMessage.versions) {
-                    targetMessage.versions = [targetMessage.content];
+                    targetMessage.versions = [
+                      {
+                        content: targetMessage.content,
+                        model: targetMessage.model || "",
+                      },
+                    ];
                     targetMessage.currentVersionIndex = 0;
                   }
-                  targetMessage.versions.push(finalContent);
+                  targetMessage.versions.push({
+                    content: finalContent,
+                    model: selectedModel,
+                  });
                   targetMessage.currentVersionIndex =
                     targetMessage.versions.length - 1;
                   targetMessage.content = finalContent;
+                  targetMessage.model = selectedModel;
                 }
+                setRefreshingMessageId(null);
                 return newMessages;
               });
             } else {
-              // 新消息
               const aiMessage: Message = {
                 id: Date.now(),
                 type: "ai",
                 content: finalContent,
                 timestamp: new Date().toLocaleTimeString(),
                 model: selectedModel,
-                versions: [finalContent],
+                versions: [{ content: finalContent, model: selectedModel }],
                 currentVersionIndex: 0,
               };
               setMessages((prev) => [...prev, aiMessage]);
+              setRefreshingMessageId(null);
             }
           }
         );
@@ -272,7 +252,7 @@ const AIChatComponent = ({
 
             if (done) {
               typeController.complete();
-              break;
+              return { success: true, content: currentTypingMessage };
             }
 
             buffer += decoder.decode(value, { stream: true });
@@ -286,7 +266,7 @@ const AIChatComponent = ({
 
                 if (data === "[DONE]") {
                   typeController.complete();
-                  return;
+                  return { success: true, content: currentTypingMessage };
                 }
 
                 try {
@@ -305,74 +285,16 @@ const AIChatComponent = ({
         } catch (error) {
           console.error("读取流数据失败:", error);
           typeController.complete();
-          throw error;
+          return { success: false, error: "读取响应流失败" };
         }
       } catch (error) {
         console.error("API调用失败:", error);
-
-        // 如果真实API失败,回退到模拟响应
-        const mockResponse = await simulateAIResponse(messages);
-        typeWriter(mockResponse, () => {
-          if (isRegenerate && messageIndex !== undefined) {
-            setMessages((prev) => {
-              const newMessages = [...prev];
-              const targetMessage = newMessages[messageIndex];
-              if (targetMessage) {
-                if (!targetMessage.versions) {
-                  targetMessage.versions = [targetMessage.content];
-                  targetMessage.currentVersionIndex = 0;
-                }
-                targetMessage.versions.push(mockResponse);
-                targetMessage.currentVersionIndex =
-                  targetMessage.versions.length - 1;
-                targetMessage.content = mockResponse;
-              }
-              return newMessages;
-            });
-          } else {
-            const aiMessage: Message = {
-              id: Date.now(),
-              type: "ai",
-              content: mockResponse,
-              timestamp: new Date().toLocaleTimeString(),
-              model: selectedModel,
-              versions: [mockResponse],
-              currentVersionIndex: 0,
-            };
-            setMessages((prev) => [...prev, aiMessage]);
-          }
-        });
+        return { success: false, error: "API调用失败,请重试" };
       }
     },
-    [selectedModel, streamTypeWriter, typeWriter]
+    [models, selectedModel, streamTypeWriter, currentTypingMessage]
   );
 
-  // 模拟AI响应(作为备用方案)
-  const simulateAIResponse = useCallback(
-    async (conversationHistory: OpenAIMessage[]): Promise<string> => {
-      return new Promise((resolve) => {
-        setTimeout(() => {
-          const lastUserMessage =
-            conversationHistory[conversationHistory.length - 1]?.content || "";
-          const responses = [
-            '这是一个很好的问题。让我来为你详细解答这个关于 "' +
-              lastUserMessage +
-              '" 的问题。\n\n首先,我需要说明的是,这个话题涉及多个方面的考虑。从技术层面来看,我们需要考虑实现的可行性和复杂度。从用户体验的角度,我们要确保解决方案既实用又易于理解。\n\n希望这个回答对你有帮助!',
-            '我理解你的意思。根据我的知识,关于 "' +
-              lastUserMessage +
-              '" 这个问题,我可以从以下几个角度来分析:\n\n1. 首先是基本概念的理解\n2. 然后是实际应用场景\n3. 最后是注意事项和建议\n\n这样的分析方法能够帮助我们更全面地理解这个问题。',
-            '感谢你的提问。关于 "' +
-              lastUserMessage +
-              '" 这个话题,我的看法是这样的:\n\n这确实是一个值得深入探讨的问题。在我看来,解决这类问题的关键在于找到平衡点,既要考虑效率,也要考虑可维护性。\n\n让我知道如果你需要更详细的解释!',
-          ];
-          resolve(responses[Math.floor(Math.random() * responses.length)]);
-        }, 1000);
-      });
-    },
-    []
-  );
-
-  // 发送消息到AI
   const sendMessage = useCallback(
     async (messageText: string = inputValue): Promise<void> => {
       if (!messageText.trim()) return;
@@ -382,7 +304,7 @@ const AIChatComponent = ({
         type: "user",
         content: messageText,
         timestamp: new Date().toLocaleTimeString(),
-        versions: [messageText],
+        versions: [{ content: messageText, model: "" }],
         currentVersionIndex: 0,
       };
 
@@ -390,33 +312,53 @@ const AIChatComponent = ({
       setInputValue("");
       setIsLoading(true);
       onChat && onChat();
+
+      // Scroll to the new user message
+      scrollToBottom();
+
       try {
-        // 构建对话历史
         const conversationHistory: OpenAIMessage[] = [
           { role: "system", content: systemPrompt },
           ...messages.map((msg) => {
-            const newMsg: OpenAIMessage = {
+            const data: OpenAIMessage = {
               role: msg.type === "user" ? "user" : "assistant",
               content: msg.content,
             };
-            return newMsg;
+            return data;
           }),
           { role: "user", content: messageText },
         ];
 
-        // 调用OpenAI API
-        await callOpenAI(conversationHistory);
+        const result = await callOpenAI(conversationHistory);
+        if (!result.success) {
+          setMessages((prev) => [
+            ...prev,
+            {
+              id: Date.now(),
+              type: "error",
+              content: result.error || "请求失败,请重试",
+              timestamp: new Date().toLocaleTimeString(),
+            },
+          ]);
+          setIsLoading(false);
+        }
       } catch (error) {
         console.error("发送消息失败:", error);
-        message.error("发送消息失败,请重试");
+        setMessages((prev) => [
+          ...prev,
+          {
+            id: Date.now(),
+            type: "error",
+            content: "请求失败,请重试",
+            timestamp: new Date().toLocaleTimeString(),
+          },
+        ]);
         setIsLoading(false);
-        setIsTyping(false);
       }
     },
-    [inputValue, messages, systemPrompt, callOpenAI]
+    [inputValue, messages, systemPrompt, callOpenAI, scrollToBottom]
   );
 
-  // 复制消息内容
   const copyMessage = useCallback(async (content: string): Promise<void> => {
     try {
       await navigator.clipboard.writeText(content);
@@ -427,36 +369,89 @@ const AIChatComponent = ({
     }
   }, []);
 
-  // 刷新AI回答
   const refreshAIResponse = useCallback(
     async (messageIndex: number): Promise<void> => {
       const userMessage = messages[messageIndex - 1];
       if (userMessage && userMessage.type === "user") {
-        // 重新构建到该消息为止的对话历史
+        setRefreshingMessageId(messages[messageIndex].id);
         const conversationHistory: OpenAIMessage[] = [
           { role: "system", content: systemPrompt },
           ...messages.slice(0, messageIndex - 1).map((msg) => {
-            const newMsg: OpenAIMessage = {
+            const data: OpenAIMessage = {
               role: msg.type === "user" ? "user" : "assistant",
               content: msg.content,
             };
-            return newMsg;
+            return data;
           }),
           { role: "user", content: userMessage.content },
         ];
 
         try {
-          await callOpenAI(conversationHistory, true, messageIndex);
+          const result = await callOpenAI(
+            conversationHistory,
+            true,
+            messageIndex
+          );
+          if (!result.success) {
+            setMessages((prev) => {
+              const newMessages = [...prev];
+              newMessages[messageIndex] = {
+                id: Date.now(),
+                type: "error",
+                content: result.error || "重新生成失败,请重试",
+                timestamp: new Date().toLocaleTimeString(),
+              };
+              setRefreshingMessageId(null);
+              return newMessages;
+            });
+          } else {
+            // Ensure the message type is set to "ai" on successful refresh
+            setMessages((prev) => {
+              const newMessages = [...prev];
+              const targetMessage = newMessages[messageIndex];
+              if (targetMessage) {
+                targetMessage.type = "ai"; // Update type to "ai"
+                targetMessage.content = result.content || "";
+                targetMessage.model = selectedModel;
+                if (!targetMessage.versions) {
+                  targetMessage.versions = [
+                    {
+                      content: targetMessage.content,
+                      model: targetMessage.model || "",
+                    },
+                  ];
+                  targetMessage.currentVersionIndex = 0;
+                }
+                targetMessage.versions.push({
+                  content: result.content || "",
+                  model: selectedModel,
+                });
+                targetMessage.currentVersionIndex =
+                  targetMessage.versions.length - 1;
+              }
+              setRefreshingMessageId(null);
+              return newMessages;
+            });
+          }
         } catch (error) {
           console.error("刷新回答失败:", error);
-          message.error("刷新回答失败,请重试");
+          setMessages((prev) => {
+            const newMessages = [...prev];
+            newMessages[messageIndex] = {
+              id: Date.now(),
+              type: "error",
+              content: "重新生成失败,请重试",
+              timestamp: new Date().toLocaleTimeString(),
+            };
+            setRefreshingMessageId(null);
+            return newMessages;
+          });
         }
       }
     },
-    [messages, systemPrompt, callOpenAI]
+    [messages, systemPrompt, callOpenAI, selectedModel]
   );
 
-  // 切换消息版本
   const switchMessageVersion = useCallback(
     (messageIndex: number, direction: "prev" | "next"): void => {
       setMessages((prev) => {
@@ -479,7 +474,8 @@ const AIChatComponent = ({
 
           if (newIndex !== currentIndex) {
             message.currentVersionIndex = newIndex;
-            message.content = message.versions[newIndex];
+            message.content = message.versions[newIndex].content;
+            message.model = message.versions[newIndex].model;
           }
         }
         return newMessages;
@@ -488,7 +484,6 @@ const AIChatComponent = ({
     []
   );
 
-  // 开始编辑用户消息
   const startEditingMessage = useCallback(
     (messageIndex: number): void => {
       const message = messages[messageIndex];
@@ -500,7 +495,6 @@ const AIChatComponent = ({
     [messages]
   );
 
-  // 确认编辑
   const confirmEdit = useCallback((): void => {
     if (editingMessageId !== null) {
       setMessages((prev) => {
@@ -511,10 +505,10 @@ const AIChatComponent = ({
         if (messageIndex !== -1) {
           const message = newMessages[messageIndex];
           if (!message.versions) {
-            message.versions = [message.content];
+            message.versions = [{ content: message.content, model: "" }];
             message.currentVersionIndex = 0;
           }
-          message.versions.push(editingContent);
+          message.versions.push({ content: editingContent, model: "" });
           message.currentVersionIndex = message.versions.length - 1;
           message.content = editingContent;
         }
@@ -525,13 +519,11 @@ const AIChatComponent = ({
     }
   }, [editingMessageId, editingContent]);
 
-  // 取消编辑
   const cancelEdit = useCallback((): void => {
     setEditingMessageId(null);
     setEditingContent("");
   }, []);
 
-  // 处理键盘事件
   const handleKeyPress = useCallback(
     (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
       if (e.key === "Enter" && !e.shiftKey) {
@@ -542,7 +534,6 @@ const AIChatComponent = ({
     [sendMessage]
   );
 
-  // 处理编辑时的键盘事件
   const handleEditKeyPress = useCallback(
     (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
       if (e.key === "Enter" && e.ctrlKey) {
@@ -555,7 +546,6 @@ const AIChatComponent = ({
     [confirmEdit, cancelEdit]
   );
 
-  // 模型选择菜单
   const modelMenu: MenuProps = {
     selectedKeys: [selectedModel],
     onClick: ({ key }) => setSelectedModel(key),
@@ -565,7 +555,6 @@ const AIChatComponent = ({
     })),
   };
 
-  // 刷新按钮的下拉菜单
   const refreshMenu = useCallback(
     (messageIndex: number): MenuProps => ({
       onClick: ({ key }) => {
@@ -606,67 +595,146 @@ const AIChatComponent = ({
         backgroundColor: "#f5f5f5",
       }}
     >
-      {/* 聊天显示窗口 */}
       <div style={{ flex: 1, overflowY: "auto", padding: "16px" }}>
         <Space direction="vertical" size="middle" style={{ width: "100%" }}>
-          {messages.map((msg, index) => (
-            <div
-              key={msg.id}
-              style={{
-                display: "flex",
-                justifyContent: msg.type === "user" ? "flex-end" : "flex-start",
-              }}
-            >
-              <div
-                style={{
-                  maxWidth: "70%",
-                  backgroundColor: msg.type === "user" ? "#1890ff" : "#ffffff",
-                  color: msg.type === "user" ? "white" : "black",
-                  borderRadius: "8px",
-                  padding: "16px",
-                  position: "relative",
-                  border: msg.type === "ai" ? "1px solid #d9d9d9" : "none",
-                  boxShadow:
-                    msg.type === "ai"
-                      ? "0 1px 2px rgba(0, 0, 0, 0.03)"
-                      : "none",
-                }}
-              >
-                <div style={{ display: "flex", alignItems: "flex-start" }}>
-                  <Space>
-                    <div>
+          {messages.map(
+            (msg, index) =>
+              msg.id !== refreshingMessageId && (
+                <div
+                  key={msg.id}
+                  style={{
+                    display: "flex",
+                    justifyContent:
+                      msg.type === "user"
+                        ? "flex-end"
+                        : msg.type === "error"
+                        ? "center"
+                        : "flex-start",
+                  }}
+                >
+                  <div
+                    style={{
+                      maxWidth: msg.type === "error" ? "100%" : "70%",
+                      backgroundColor:
+                        msg.type === "user"
+                          ? "#1890ff"
+                          : msg.type === "error"
+                          ? "#fff1f0"
+                          : "#ffffff",
+                      color:
+                        msg.type === "user"
+                          ? "white"
+                          : msg.type === "error"
+                          ? "#ff4d4f"
+                          : "black",
+                      borderRadius: "8px",
+                      padding: "16px",
+                      position: "relative",
+                      border:
+                        msg.type === "ai"
+                          ? "1px solid #d9d9d9"
+                          : msg.type === "error"
+                          ? "1px solid #ff4d4f"
+                          : "none",
+                      boxShadow:
+                        msg.type === "ai" || msg.type === "error"
+                          ? "0 1px 2px rgba(0, 0, 0, 0.03)"
+                          : "none",
+                      textAlign: msg.type === "error" ? "center" : "left",
+                    }}
+                  >
+                    <div style={{ display: "flex", alignItems: "flex-start" }}>
                       <Space>
-                        <Avatar
-                          size={32}
-                          icon={
-                            msg.type === "user" ? (
-                              <UserOutlined />
-                            ) : (
-                              <RobotOutlined />
-                            )
-                          }
-                          style={{
-                            backgroundColor:
-                              msg.type === "user"
-                                ? "#rgb(0 132 253 / 50%)"
-                                : "#595959",
-                          }}
-                        />
-                        <div
-                          style={{
-                            fontSize: "14px",
-                            fontWeight: 500,
-                            marginBottom: "4px",
-                          }}
-                        >
-                          {msg.type === "user"
-                            ? "你"
-                            : msg.model
-                            ? models?.find((m) => m.uid === msg.model)?.name
-                            : "AI助手"}
+                        {msg.type !== "error" && (
+                          <Avatar
+                            size={32}
+                            icon={
+                              msg.type === "user" ? (
+                                <UserOutlined />
+                              ) : (
+                                <RobotOutlined />
+                              )
+                            }
+                            style={{
+                              backgroundColor:
+                                msg.type === "user"
+                                  ? "rgb(0 132 253 / 50%)"
+                                  : "#595959",
+                            }}
+                          />
+                        )}
+                        <div>
+                          {msg.type !== "error" && (
+                            <div
+                              style={{
+                                fontSize: "14px",
+                                fontWeight: 500,
+                                marginBottom: "4px",
+                              }}
+                            >
+                              {msg.type === "user"
+                                ? "你"
+                                : msg.model
+                                ? models?.find((m) => m.uid === msg.model)?.name
+                                : "AI助手"}
+                            </div>
+                          )}
+                          {editingMessageId === msg.id ? (
+                            <div>
+                              <TextArea
+                                value={editingContent}
+                                onChange={(e) =>
+                                  setEditingContent(e.target.value)
+                                }
+                                onKeyPress={handleEditKeyPress}
+                                autoSize={{ minRows: 2, maxRows: 8 }}
+                                style={{ marginBottom: "8px" }}
+                              />
+                              <Space size="small">
+                                <Button
+                                  size="small"
+                                  type="primary"
+                                  icon={<CheckOutlined />}
+                                  onClick={confirmEdit}
+                                >
+                                  确认
+                                </Button>
+                                <Button
+                                  size="small"
+                                  icon={<CloseOutlined />}
+                                  onClick={cancelEdit}
+                                >
+                                  取消
+                                </Button>
+                              </Space>
+                            </div>
+                          ) : (
+                            <div>
+                              <div
+                                style={{
+                                  fontSize: "14px",
+                                  lineHeight: "1.5",
+                                  whiteSpace: "pre-wrap",
+                                  wordBreak: "break-word",
+                                }}
+                              >
+                                <Marked text={msg.content} />
+                              </div>
+                              <div
+                                style={{
+                                  fontSize: "12px",
+                                  opacity: 0.6,
+                                  marginTop: "8px",
+                                }}
+                              >
+                                {msg.timestamp}
+                              </div>
+                            </div>
+                          )}
                         </div>
                       </Space>
-                      {/* 显示版本导航 */}
+                    </div>
+                    <Space>
                       {msg.versions && msg.versions.length > 1 && (
                         <div style={{ marginBottom: "8px" }}>
                           <Space size="small">
@@ -706,100 +774,56 @@ const AIChatComponent = ({
                           </Space>
                         </div>
                       )}
-
-                      {/* 编辑模式 */}
-                      {editingMessageId === msg.id ? (
+                      {editingMessageId !== msg.id && (
                         <div>
-                          <TextArea
-                            value={editingContent}
-                            onChange={(e) => setEditingContent(e.target.value)}
-                            onKeyPress={handleEditKeyPress}
-                            autoSize={{ minRows: 2, maxRows: 8 }}
-                            style={{ marginBottom: "8px" }}
-                          />
                           <Space size="small">
-                            <Button
-                              size="small"
-                              type="primary"
-                              icon={<CheckOutlined />}
-                              onClick={confirmEdit}
-                            >
-                              确认
-                            </Button>
-                            <Button
-                              size="small"
-                              icon={<CloseOutlined />}
-                              onClick={cancelEdit}
-                            >
-                              取消
-                            </Button>
+                            <Tooltip title="复制">
+                              <Button
+                                size="small"
+                                type="text"
+                                icon={<CopyOutlined />}
+                                onClick={() => copyMessage(msg.content)}
+                              />
+                            </Tooltip>
+                            {msg.type === "user" ? (
+                              <Tooltip title="编辑">
+                                <Button
+                                  size="small"
+                                  type="text"
+                                  icon={<EditOutlined />}
+                                  onClick={() => startEditingMessage(index)}
+                                />
+                              </Tooltip>
+                            ) : msg.type === "error" ? (
+                              <Tooltip title="重试">
+                                <Button
+                                  size="small"
+                                  type="text"
+                                  icon={<ReloadOutlined />}
+                                  onClick={() => refreshAIResponse(index)}
+                                />
+                              </Tooltip>
+                            ) : (
+                              <Dropdown
+                                menu={refreshMenu(index)}
+                                trigger={["hover"]}
+                              >
+                                <Button
+                                  size="small"
+                                  type="text"
+                                  icon={<ReloadOutlined />}
+                                />
+                              </Dropdown>
+                            )}
                           </Space>
                         </div>
-                      ) : (
-                        <div>
-                          <div
-                            style={{
-                              fontSize: "14px",
-                              lineHeight: "1.5",
-                              whiteSpace: "pre-wrap",
-                              wordBreak: "break-word",
-                            }}
-                          >
-                            <Marked text={msg.content} />
-                          </div>
-                          <div
-                            style={{
-                              fontSize: "12px",
-                              opacity: 0.6,
-                              marginTop: "8px",
-                            }}
-                          >
-                            {msg.timestamp}
-                          </div>
-                        </div>
-                      )}
-                    </div>
-                  </Space>
-                </div>
-
-                {/* 悬浮工具按钮 */}
-                {editingMessageId !== msg.id && (
-                  <div>
-                    <Space size="small">
-                      <Tooltip title="复制">
-                        <Button
-                          size="small"
-                          type="text"
-                          icon={<CopyOutlined />}
-                          onClick={() => copyMessage(msg.content)}
-                        />
-                      </Tooltip>
-                      {msg.type === "user" ? (
-                        <Tooltip title="编辑">
-                          <Button
-                            size="small"
-                            type="text"
-                            icon={<EditOutlined />}
-                            onClick={() => startEditingMessage(index)}
-                          />
-                        </Tooltip>
-                      ) : (
-                        <Dropdown menu={refreshMenu(index)} trigger={["hover"]}>
-                          <Button
-                            size="small"
-                            type="text"
-                            icon={<ReloadOutlined />}
-                          />
-                        </Dropdown>
                       )}
                     </Space>
                   </div>
-                )}
-              </div>
-            </div>
-          ))}
+                </div>
+              )
+          )}
 
-          {/* 显示AI正在输入的消息 */}
           {isTyping && (
             <div style={{ display: "flex", justifyContent: "flex-start" }}>
               <div
@@ -870,11 +894,9 @@ const AIChatComponent = ({
         <div ref={messagesEndRef} />
       </div>
 
-      {/* 用户输入区域 */}
       <Affix offsetBottom={10}>
         <Card style={{ borderRadius: "10px", borderColor: "#d9d9d9" }}>
           <div style={{ maxWidth: "1200px", margin: "0 auto" }}>
-            {/* 输入框 */}
             <div style={{ display: "flex", marginBottom: "8px" }}>
               <TextArea
                 value={inputValue}
@@ -886,7 +908,6 @@ const AIChatComponent = ({
               />
             </div>
 
-            {/* 功能按钮和模型选择 */}
             <div
               style={{
                 display: "flex",

+ 164 - 0
dashboard-v4/dashboard/src/components/chat/MsgAssistant.tsx

@@ -0,0 +1,164 @@
+import { Button, Dropdown, message, Space, Tooltip, Typography } from "antd";
+import { Message } from "./AiChat";
+
+import {
+  CopyOutlined,
+  ReloadOutlined,
+  LeftOutlined,
+  RightOutlined,
+} from "@ant-design/icons";
+import { IAiModel } from "../api/ai";
+import { useState } from "react";
+import { MenuProps } from "antd/es/menu";
+import Marked from "../general/Marked";
+
+const { Text } = Typography;
+
+interface IWidget {
+  msg?: Message;
+  models?: IAiModel[];
+  onRefresh?: (modelIndex: number) => void;
+}
+
+const MsgAssistant = ({ msg, models, onRefresh }: IWidget) => {
+  const [currentVersion, setCurrentVersion] = useState(0);
+
+  const switchMessageVersion = (direction: "prev" | "next"): void => {
+    if (msg && msg.versions) {
+      const maxIndex = msg.versions.length - 1;
+
+      let newIndex = currentVersion;
+      if (direction === "prev" && currentVersion > 0) {
+        newIndex = currentVersion - 1;
+      } else if (direction === "next" && currentVersion < maxIndex) {
+        newIndex = currentVersion + 1;
+      }
+      setCurrentVersion(newIndex);
+    }
+  };
+
+  const refreshMenu: MenuProps = {
+    onClick: ({ key }) => {
+      if (key === "refresh") {
+        onRefresh && onRefresh(0);
+      }
+    },
+    items: [
+      {
+        key: "refresh",
+        label: "重新生成",
+      },
+      {
+        type: "divider",
+      },
+      {
+        key: "model-submenu",
+        label: "选择模型重新生成",
+        children: models?.map((model, id) => ({
+          key: model.uid,
+          label: model.name,
+          onClick: () => {
+            onRefresh && onRefresh(id);
+          },
+        })),
+      },
+    ],
+  };
+  return (
+    <div
+      style={{
+        display: "flex",
+        justifyContent: "flex-start",
+      }}
+    >
+      <div
+        style={{
+          maxWidth: "90%",
+          backgroundColor: "#ffffff",
+          color: "black",
+          borderRadius: "8px",
+          padding: "16px",
+          border: "none",
+          boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
+          textAlign: "left",
+        }}
+      >
+        <div
+          style={{
+            fontSize: "14px",
+            fontWeight: 500,
+            marginBottom: "4px",
+          }}
+        >
+          {msg?.model
+            ? models?.find((m) => m.uid === msg.model)?.name
+            : "AI助手"}
+        </div>
+        <div>
+          <Marked text={msg?.content} />
+        </div>
+        <div>
+          <Space>
+            {msg?.versions && msg.versions.length > 1 && (
+              <div style={{ marginBottom: "8px" }}>
+                <Space size="small">
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<LeftOutlined />}
+                    disabled={msg.currentVersionIndex === 0}
+                    onClick={() => switchMessageVersion("prev")}
+                  />
+                  <Text
+                    style={{
+                      fontSize: "12px",
+                      color:
+                        msg.type === "user" ? "rgba(255,255,255,0.7)" : "#666",
+                    }}
+                  >
+                    {(msg.currentVersionIndex || 0) + 1}/{msg.versions.length}
+                  </Text>
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<RightOutlined />}
+                    disabled={
+                      msg.currentVersionIndex === msg.versions.length - 1
+                    }
+                    onClick={() => switchMessageVersion("next")}
+                  />
+                </Space>
+              </div>
+            )}
+            <div>
+              <Space size="small">
+                <Tooltip title="复制">
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<CopyOutlined />}
+                    onClick={() => {
+                      msg &&
+                        navigator.clipboard
+                          .writeText(msg.content)
+                          .then((value) => message.success("已复制到剪贴板"))
+                          .catch((reason: any) => {
+                            console.error("复制失败:", reason);
+                            message.error("复制失败");
+                          });
+                    }}
+                  />
+                </Tooltip>
+                <Dropdown menu={refreshMenu} trigger={["hover"]}>
+                  <Button size="small" type="text" icon={<ReloadOutlined />} />
+                </Dropdown>
+              </Space>
+            </div>
+          </Space>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default MsgAssistant;

+ 87 - 0
dashboard-v4/dashboard/src/components/chat/MsgUser.tsx

@@ -0,0 +1,87 @@
+import { useCallback, useState } from "react";
+import { Message } from "./AiChat";
+import Marked from "../general/Marked";
+import TextArea from "antd/lib/input/TextArea";
+import { Button, Space } from "antd";
+
+import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
+
+interface IWidget {
+  msg?: Message;
+  onChange?: (value: string) => void;
+}
+
+const MsgUser = ({ msg, onChange }: IWidget) => {
+  const [editing, setEditing] = useState(false);
+  const [content, setContent] = useState("");
+
+  const confirmEdit = useCallback((): void => {
+    onChange && onChange(content);
+    setContent("");
+  }, [content, onChange]);
+
+  const cancelEdit = useCallback((): void => {
+    setEditing(false);
+  }, []);
+
+  const handleEditKeyPress = useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
+      if (e.key === "Enter" && e.ctrlKey) {
+        e.preventDefault();
+        confirmEdit();
+      } else if (e.key === "Escape") {
+        cancelEdit();
+      }
+    },
+    [cancelEdit, confirmEdit]
+  );
+
+  return editing ? (
+    <div>
+      <TextArea
+        value={content}
+        onChange={(e) => setContent(e.target.value)}
+        onKeyPress={handleEditKeyPress}
+        autoSize={{ minRows: 2, maxRows: 8 }}
+        style={{ marginBottom: "8px" }}
+      />
+      <Space size="small">
+        <Button
+          size="small"
+          type="primary"
+          icon={<CheckOutlined />}
+          onClick={confirmEdit}
+        >
+          确认
+        </Button>
+        <Button size="small" icon={<CloseOutlined />} onClick={cancelEdit}>
+          取消
+        </Button>
+      </Space>
+    </div>
+  ) : (
+    <div>
+      <div
+        style={{
+          fontSize: "14px",
+          lineHeight: "1.5",
+          whiteSpace: "pre-wrap",
+          wordBreak: "break-word",
+        }}
+      >
+        <Marked text={msg?.content} />
+      </div>
+      <div
+        style={{
+          fontSize: "12px",
+          opacity: 0.6,
+          marginTop: "8px",
+        }}
+      >
+        {msg?.timestamp}
+      </div>
+    </div>
+  );
+};
+
+export default MsgUser;