Procházet zdrojové kódy

Merge pull request #2338 from visuddhinanda/development

Development
visuddhinanda před 8 měsíci
rodič
revize
edfbd7c8ef

+ 137 - 527
dashboard-v4/dashboard/src/components/chat/AiChat.tsx

@@ -2,53 +2,42 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
 import {
   Input,
   Button,
-  Avatar,
   Dropdown,
-  message,
   Tooltip,
   Space,
-  Spin,
   MenuProps,
   Card,
   Affix,
-  Typography,
 } from "antd";
 import {
   SendOutlined,
-  CopyOutlined,
-  EditOutlined,
-  ReloadOutlined,
   DownOutlined,
-  UserOutlined,
-  RobotOutlined,
   PaperClipOutlined,
-  LeftOutlined,
-  RightOutlined,
-  CheckOutlined,
-  CloseOutlined,
 } from "@ant-design/icons";
-import Marked from "../general/Marked";
 import { IAiModel, IAiModelListResponse } from "../api/ai";
 import { get } from "../../request";
-import User from "../auth/User";
+import MsgUser from "./MsgUser";
+import MsgAssistant from "./MsgAssistant";
+import MsgTyping from "./MsgTyping";
+import MsgLoading from "./MsgLoading";
+import MsgSystem from "./MsgSystem";
+import MsgError from "./MsgError";
 
 const { TextArea } = Input;
-const { Text } = Typography;
 
 // 类型定义
 export interface MessageVersion {
+  id: number;
   content: string;
   model: string;
+  role: "system" | "user" | "assistant";
+  timestamp: string;
 }
 
 export interface Message {
   id: number;
   type: "user" | "ai" | "error";
-  content: string;
-  timestamp: string;
-  model?: string;
-  versions?: MessageVersion[];
-  currentVersionIndex?: number;
+  versions: MessageVersion[];
 }
 
 interface OpenAIMessage {
@@ -69,6 +58,10 @@ interface OpenAIStreamResponse {
   }>;
 }
 
+const endOfMsg = (msg: Message) => {
+  return msg.versions[msg.versions.length - 1];
+};
+
 interface IWidget {
   initMessage?: string;
   systemPrompt?: string;
@@ -84,8 +77,7 @@ const AIChatComponent = ({
   const [inputValue, setInputValue] = useState<string>("");
   const [isLoading, setIsLoading] = useState<boolean>(false);
   const [selectedModel, setSelectedModel] = useState<string>("");
-  const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
-  const [editingContent, setEditingContent] = useState<string>("");
+  const [fetchModel, setFetchModel] = useState<string>("");
   const [refreshingMessageId, setRefreshingMessageId] = useState<number | null>(
     null
   );
@@ -95,6 +87,8 @@ const AIChatComponent = ({
   const [currentTypingMessage, setCurrentTypingMessage] = useState<string>("");
   const [models, setModels] = useState<IAiModel[]>();
 
+  const [error, setError] = useState<string>();
+
   const scrollToBottom = useCallback(() => {
     messagesEndRef.current?.scrollIntoView({
       behavior: "smooth",
@@ -159,34 +153,39 @@ const AIChatComponent = ({
   const callOpenAI = useCallback(
     async (
       messages: OpenAIMessage[],
+      modelId: string,
       isRegenerate: boolean = false,
       messageIndex?: number
     ): Promise<{ success: boolean; content?: string; error?: string }> => {
-      setIsLoading(false);
+      setError(undefined);
       if (typeof process.env.REACT_APP_OPENAI_PROXY === "undefined") {
         console.error("no REACT_APP_OPENAI_PROXY");
         return { success: false, error: "API配置错误" };
       }
+
       try {
+        setFetchModel(modelId);
         const payload = {
-          model: models?.find((value) => value.uid === selectedModel)?.model,
+          model: models?.find((value) => value.uid === modelId)?.model,
           messages: messages,
           stream: true,
           temperature: 0.7,
-          max_tokens: 2000,
+          max_tokens: 3000, //本次回复”最大输出长度
         };
         const url = process.env.REACT_APP_OPENAI_PROXY;
-        console.info("api request", url, payload);
+        const data = {
+          model_id: modelId,
+          payload: payload,
+        };
+        console.info("api request", url, data);
+        setIsLoading(true);
         const response = await fetch(url, {
           method: "POST",
           headers: {
             "Content-Type": "application/json",
             Authorization: `Bearer AIzaSyCzr8KqEdaQ3cRCxsFwSHh8c7kF3RZTZWw`,
           },
-          body: JSON.stringify({
-            model_id: selectedModel,
-            payload: payload,
-          }),
+          body: JSON.stringify(data),
         });
 
         if (!response.ok) {
@@ -204,28 +203,23 @@ const AIChatComponent = ({
         const typeController = streamTypeWriter(
           (content: string) => {},
           (finalContent: string) => {
+            console.log("newData in callOpenAI", finalContent);
+            const newData: MessageVersion = {
+              id: Date.now(),
+              content: finalContent,
+              model: modelId,
+              role: "assistant",
+              timestamp: new Date().toLocaleTimeString(),
+            };
             if (isRegenerate && messageIndex !== undefined) {
               setMessages((prev) => {
                 const newMessages = [...prev];
                 const targetMessage = newMessages[messageIndex];
                 if (targetMessage) {
                   if (!targetMessage.versions) {
-                    targetMessage.versions = [
-                      {
-                        content: targetMessage.content,
-                        model: targetMessage.model || "",
-                      },
-                    ];
-                    targetMessage.currentVersionIndex = 0;
+                    targetMessage.versions = [];
                   }
-                  targetMessage.versions.push({
-                    content: finalContent,
-                    model: selectedModel,
-                  });
-                  targetMessage.currentVersionIndex =
-                    targetMessage.versions.length - 1;
-                  targetMessage.content = finalContent;
-                  targetMessage.model = selectedModel;
+                  targetMessage.versions.push(newData);
                 }
                 setRefreshingMessageId(null);
                 return newMessages;
@@ -234,11 +228,7 @@ const AIChatComponent = ({
               const aiMessage: Message = {
                 id: Date.now(),
                 type: "ai",
-                content: finalContent,
-                timestamp: new Date().toLocaleTimeString(),
-                model: selectedModel,
-                versions: [{ content: finalContent, model: selectedModel }],
-                currentVersionIndex: 0,
+                versions: [newData],
               };
               setMessages((prev) => [...prev, aiMessage]);
               setRefreshingMessageId(null);
@@ -292,20 +282,24 @@ const AIChatComponent = ({
         return { success: false, error: "API调用失败,请重试" };
       }
     },
-    [models, selectedModel, streamTypeWriter, currentTypingMessage]
+    [models, streamTypeWriter, fetchModel, currentTypingMessage]
   );
 
   const sendMessage = useCallback(
     async (messageText: string = inputValue): Promise<void> => {
       if (!messageText.trim()) return;
 
-      const userMessage: Message = {
+      const newData: MessageVersion = {
         id: Date.now(),
-        type: "user",
         content: messageText,
+        model: "",
+        role: "user",
         timestamp: new Date().toLocaleTimeString(),
-        versions: [{ content: messageText, model: "" }],
-        currentVersionIndex: 0,
+      };
+      const userMessage: Message = {
+        id: Date.now(),
+        type: "user",
+        versions: [newData],
       };
 
       setMessages((prev) => [...prev, userMessage]);
@@ -322,55 +316,30 @@ const AIChatComponent = ({
           ...messages.map((msg) => {
             const data: OpenAIMessage = {
               role: msg.type === "user" ? "user" : "assistant",
-              content: msg.content,
+              content: msg.versions[msg.versions.length - 1].content,
             };
             return data;
           }),
           { role: "user", content: messageText },
         ];
 
-        const result = await callOpenAI(conversationHistory);
+        const result = await callOpenAI(conversationHistory, selectedModel);
+        setIsLoading(false);
         if (!result.success) {
-          setMessages((prev) => [
-            ...prev,
-            {
-              id: Date.now(),
-              type: "error",
-              content: result.error || "请求失败,请重试",
-              timestamp: new Date().toLocaleTimeString(),
-            },
-          ]);
-          setIsLoading(false);
+          setError("请求失败,请重试");
         }
       } catch (error) {
         console.error("发送消息失败:", error);
-        setMessages((prev) => [
-          ...prev,
-          {
-            id: Date.now(),
-            type: "error",
-            content: "请求失败,请重试",
-            timestamp: new Date().toLocaleTimeString(),
-          },
-        ]);
+        setError("请求失败,请重试");
         setIsLoading(false);
       }
     },
     [inputValue, messages, systemPrompt, callOpenAI, scrollToBottom]
   );
 
-  const copyMessage = useCallback(async (content: string): Promise<void> => {
-    try {
-      await navigator.clipboard.writeText(content);
-      message.success("已复制到剪贴板");
-    } catch (error) {
-      console.error("复制失败:", error);
-      message.error("复制失败");
-    }
-  }, []);
-
   const refreshAIResponse = useCallback(
-    async (messageIndex: number): Promise<void> => {
+    async (messageIndex: number, modelId: string): Promise<void> => {
+      console.debug("refresh", messageIndex);
       const userMessage = messages[messageIndex - 1];
       if (userMessage && userMessage.type === "user") {
         setRefreshingMessageId(messages[messageIndex].id);
@@ -379,149 +348,80 @@ const AIChatComponent = ({
           ...messages.slice(0, messageIndex - 1).map((msg) => {
             const data: OpenAIMessage = {
               role: msg.type === "user" ? "user" : "assistant",
-              content: msg.content,
+              content: endOfMsg(msg).content,
             };
             return data;
           }),
-          { role: "user", content: userMessage.content },
+          { role: "user", content: endOfMsg(userMessage).content },
         ];
 
         try {
           const result = await callOpenAI(
             conversationHistory,
+            modelId,
             true,
             messageIndex
           );
+          setIsLoading(false);
           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;
-            });
+            setError("重新生成失败,请重试");
+            setRefreshingMessageId(null);
           } else {
-            // Ensure the message type is set to "ai" on successful refresh
+            /*
+            console.log("newData refreshAIResponse", result);
             setMessages((prev) => {
               const newMessages = [...prev];
               const targetMessage = newMessages[messageIndex];
               if (targetMessage) {
+                const newData: MessageVersion = {
+                  id: Date.now(),
+                  content: result.content || "",
+                  model: modelId,
+                  role: "assistant",
+                  timestamp: new Date().toLocaleTimeString(),
+                };
                 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 = [];
                 }
-                targetMessage.versions.push({
-                  content: result.content || "",
-                  model: selectedModel,
-                });
-                targetMessage.currentVersionIndex =
-                  targetMessage.versions.length - 1;
+                targetMessage.versions.push(newData);
               }
               setRefreshingMessageId(null);
               return newMessages;
             });
+            */
           }
         } catch (error) {
           console.error("刷新回答失败:", error);
-          setMessages((prev) => {
-            const newMessages = [...prev];
-            newMessages[messageIndex] = {
-              id: Date.now(),
-              type: "error",
-              content: "重新生成失败,请重试",
-              timestamp: new Date().toLocaleTimeString(),
-            };
-            setRefreshingMessageId(null);
-            return newMessages;
-          });
+          setIsLoading(false);
+          setError("请求失败,请重试");
+          setRefreshingMessageId(null);
         }
       }
     },
-    [messages, systemPrompt, callOpenAI, selectedModel]
+    [messages, systemPrompt, callOpenAI, fetchModel]
   );
 
-  const switchMessageVersion = useCallback(
-    (messageIndex: number, direction: "prev" | "next"): void => {
-      setMessages((prev) => {
-        const newMessages = [...prev];
+  const confirmEdit = useCallback((id: number, text: string): void => {
+    setMessages((prev) => {
+      const newMessages = [...prev];
+      const messageIndex = newMessages.findIndex((m) => m.id === id);
+      if (messageIndex !== -1) {
         const message = newMessages[messageIndex];
-        if (
-          message &&
-          message.versions &&
-          message.currentVersionIndex !== undefined
-        ) {
-          const currentIndex = message.currentVersionIndex;
-          const maxIndex = message.versions.length - 1;
-
-          let newIndex = currentIndex;
-          if (direction === "prev" && currentIndex > 0) {
-            newIndex = currentIndex - 1;
-          } else if (direction === "next" && currentIndex < maxIndex) {
-            newIndex = currentIndex + 1;
-          }
-
-          if (newIndex !== currentIndex) {
-            message.currentVersionIndex = newIndex;
-            message.content = message.versions[newIndex].content;
-            message.model = message.versions[newIndex].model;
-          }
+        if (!message.versions) {
+          message.versions = [];
         }
-        return newMessages;
-      });
-    },
-    []
-  );
-
-  const startEditingMessage = useCallback(
-    (messageIndex: number): void => {
-      const message = messages[messageIndex];
-      if (message && message.type === "user") {
-        setEditingMessageId(message.id);
-        setEditingContent(message.content);
+        const newData: MessageVersion = {
+          id: Date.now(),
+          content: text,
+          model: "",
+          role: "user",
+          timestamp: new Date().toLocaleTimeString(),
+        };
+        message.versions.push(newData);
       }
-    },
-    [messages]
-  );
-
-  const confirmEdit = useCallback((): void => {
-    if (editingMessageId !== null) {
-      setMessages((prev) => {
-        const newMessages = [...prev];
-        const messageIndex = newMessages.findIndex(
-          (m) => m.id === editingMessageId
-        );
-        if (messageIndex !== -1) {
-          const message = newMessages[messageIndex];
-          if (!message.versions) {
-            message.versions = [{ content: message.content, model: "" }];
-            message.currentVersionIndex = 0;
-          }
-          message.versions.push({ content: editingContent, model: "" });
-          message.currentVersionIndex = message.versions.length - 1;
-          message.content = editingContent;
-        }
-        return newMessages;
-      });
-      setEditingMessageId(null);
-      setEditingContent("");
-    }
-  }, [editingMessageId, editingContent]);
-
-  const cancelEdit = useCallback((): void => {
-    setEditingMessageId(null);
-    setEditingContent("");
+      return newMessages;
+    });
   }, []);
 
   const handleKeyPress = useCallback(
@@ -534,18 +434,6 @@ const AIChatComponent = ({
     [sendMessage]
   );
 
-  const handleEditKeyPress = useCallback(
-    (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
-      if (e.key === "Enter" && e.ctrlKey) {
-        e.preventDefault();
-        confirmEdit();
-      } else if (e.key === "Escape") {
-        cancelEdit();
-      }
-    },
-    [confirmEdit, cancelEdit]
-  );
-
   const modelMenu: MenuProps = {
     selectedKeys: [selectedModel],
     onClick: ({ key }) => setSelectedModel(key),
@@ -555,340 +443,62 @@ const AIChatComponent = ({
     })),
   };
 
-  const refreshMenu = useCallback(
-    (messageIndex: number): MenuProps => ({
-      onClick: ({ key }) => {
-        if (key === "refresh") {
-          refreshAIResponse(messageIndex);
-        }
-      },
-      items: [
-        {
-          key: "refresh",
-          label: "重新生成",
-        },
-        {
-          type: "divider",
-        },
-        {
-          key: "model-submenu",
-          label: "选择模型重新生成",
-          children: models?.map((model) => ({
-            key: model.uid,
-            label: model.name,
-            onClick: () => {
-              setSelectedModel(model.uid);
-              refreshAIResponse(messageIndex);
-            },
-          })),
-        },
-      ],
-    }),
-    [refreshAIResponse, models]
-  );
-
   return (
     <div
       style={{
         display: "flex",
         flexDirection: "column",
-        backgroundColor: "#f5f5f5",
+        width: "100%",
       }}
     >
       <div style={{ flex: 1, overflowY: "auto", padding: "16px" }}>
         <Space direction="vertical" size="middle" style={{ width: "100%" }}>
-          {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",
+          <MsgSystem value={systemPrompt} />
+          {messages.map((msg, index) => {
+            if (msg.id === refreshingMessageId) {
+              return <></>;
+            } else {
+              if (msg.type === "user") {
+                return (
+                  <MsgUser
+                    msg={msg}
+                    onChange={(value: string) => confirmEdit(index, value)}
+                  />
+                );
+              } else if (msg.type === "ai") {
+                return (
+                  <MsgAssistant
+                    msg={msg}
+                    models={models}
+                    onRefresh={(modelId: string) => {
+                      refreshAIResponse(index, modelId);
                     }}
-                  >
-                    <div style={{ display: "flex", alignItems: "flex-start" }}>
-                      <Space>
-                        {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">
-                            <Button
-                              size="small"
-                              type="text"
-                              icon={<LeftOutlined />}
-                              disabled={msg.currentVersionIndex === 0}
-                              onClick={() =>
-                                switchMessageVersion(index, "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(index, "next")
-                              }
-                            />
-                          </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>
-                            ) : 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>
-                      )}
-                    </Space>
-                  </div>
-                </div>
-              )
+                  />
+                );
+              } else {
+                return <>unknown</>;
+              }
+            }
+          })}
+          {error ? (
+            <MsgError
+              message={error}
+              onRefresh={() =>
+                refreshAIResponse(messages.length - 1, fetchModel)
+              }
+            />
+          ) : (
+            <></>
           )}
-
           {isTyping && (
-            <div style={{ display: "flex", justifyContent: "flex-start" }}>
-              <div
-                style={{
-                  maxWidth: "70%",
-                  backgroundColor: "#ffffff",
-                  border: "1px solid #d9d9d9",
-                  borderRadius: "8px",
-                  padding: "16px",
-                  boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
-                }}
-              >
-                <div style={{ display: "flex", alignItems: "flex-start" }}>
-                  <Space>
-                    <Avatar
-                      size={32}
-                      icon={<RobotOutlined />}
-                      style={{ backgroundColor: "#595959" }}
-                    />
-                    <div>
-                      <div
-                        style={{
-                          fontSize: "14px",
-                          fontWeight: 500,
-                          marginBottom: "4px",
-                        }}
-                      >
-                        {models?.find((m) => m.uid === selectedModel)?.name ||
-                          "AI助手"}
-                      </div>
-                      <Marked text={currentTypingMessage} />
-                    </div>
-                  </Space>
-                </div>
-              </div>
-            </div>
+            <MsgTyping
+              text={currentTypingMessage}
+              model={models?.find((m) => m.uid === fetchModel)}
+            />
           )}
 
           {isLoading && !isTyping && (
-            <div style={{ display: "flex", justifyContent: "flex-start" }}>
-              <div
-                style={{
-                  maxWidth: "70%",
-                  backgroundColor: "#ffffff",
-                  border: "1px solid #d9d9d9",
-                  borderRadius: "8px",
-                  padding: "16px",
-                  boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
-                }}
-              >
-                <div style={{ display: "flex", alignItems: "center" }}>
-                  <Space>
-                    <Avatar
-                      size={32}
-                      icon={<RobotOutlined />}
-                      style={{ backgroundColor: "#595959" }}
-                    />
-                    <Spin size="small" />
-                    <span style={{ fontSize: "14px", color: "#666" }}>
-                      正在思考...
-                    </span>
-                  </Space>
-                </div>
-              </div>
-            </div>
+            <MsgLoading model={models?.find((m) => m.uid === fetchModel)} />
           )}
         </Space>
         <div ref={messagesEndRef} />

+ 78 - 90
dashboard-v4/dashboard/src/components/chat/MsgAssistant.tsx

@@ -8,21 +8,28 @@ import {
   RightOutlined,
 } from "@ant-design/icons";
 import { IAiModel } from "../api/ai";
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { MenuProps } from "antd/es/menu";
 import Marked from "../general/Marked";
+import MsgContainer from "./MsgContainer";
 
 const { Text } = Typography;
 
 interface IWidget {
   msg?: Message;
   models?: IAiModel[];
-  onRefresh?: (modelIndex: number) => void;
+  onRefresh?: (modelId: string) => void;
 }
 
 const MsgAssistant = ({ msg, models, onRefresh }: IWidget) => {
   const [currentVersion, setCurrentVersion] = useState(0);
 
+  useEffect(() => {
+    if (msg) {
+      setCurrentVersion(msg?.versions.length - 1);
+    }
+  }, [msg]);
+
   const switchMessageVersion = (direction: "prev" | "next"): void => {
     if (msg && msg.versions) {
       const maxIndex = msg.versions.length - 1;
@@ -39,8 +46,8 @@ const MsgAssistant = ({ msg, models, onRefresh }: IWidget) => {
 
   const refreshMenu: MenuProps = {
     onClick: ({ key }) => {
-      if (key === "refresh") {
-        onRefresh && onRefresh(0);
+      if (key === "refresh" && msg) {
+        onRefresh && onRefresh(msg.versions[currentVersion].model);
       }
     },
     items: [
@@ -58,106 +65,87 @@ const MsgAssistant = ({ msg, models, onRefresh }: IWidget) => {
           key: model.uid,
           label: model.name,
           onClick: () => {
-            onRefresh && onRefresh(id);
+            onRefresh && onRefresh(model.uid);
           },
         })),
       },
     ],
   };
   return (
-    <div
-      style={{
-        display: "flex",
-        justifyContent: "flex-start",
-      }}
-    >
+    <MsgContainer>
       <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",
+          fontSize: "14px",
+          fontWeight: 500,
+          marginBottom: "4px",
         }}
       >
-        <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>
+        {msg?.versions[currentVersion].model
+          ? models?.find((m) => m.uid === msg.versions[currentVersion].model)
+              ?.name
+          : "AI助手"}
+      </div>
+      <div>
+        <Marked text={msg?.versions[currentVersion].content} />
+      </div>
+      <div>
+        <Space>
+          {msg?.versions && msg.versions.length > 1 && (
+            <div style={{ marginBottom: "8px" }}>
               <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>
+                <Button
+                  size="small"
+                  type="text"
+                  icon={<LeftOutlined />}
+                  disabled={currentVersion === 0}
+                  onClick={() => switchMessageVersion("prev")}
+                />
+                <Text
+                  style={{
+                    fontSize: "12px",
+                    color:
+                      msg.type === "user" ? "rgba(255,255,255,0.7)" : "#666",
+                  }}
+                >
+                  {(currentVersion || 0) + 1}/{msg.versions.length}
+                </Text>
+                <Button
+                  size="small"
+                  type="text"
+                  icon={<RightOutlined />}
+                  disabled={currentVersion === msg.versions.length - 1}
+                  onClick={() => switchMessageVersion("next")}
+                />
               </Space>
             </div>
-          </Space>
-        </div>
+          )}
+          <div>
+            <Space size="small">
+              <Tooltip title="复制">
+                <Button
+                  size="small"
+                  type="text"
+                  icon={<CopyOutlined />}
+                  onClick={() => {
+                    msg &&
+                      navigator.clipboard
+                        .writeText(msg.versions[currentVersion].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>
+    </MsgContainer>
   );
 };
 

+ 29 - 0
dashboard-v4/dashboard/src/components/chat/MsgContainer.tsx

@@ -0,0 +1,29 @@
+interface IWidget {
+  children?: React.ReactNode;
+}
+const MsgContainer = ({ children }: IWidget) => {
+  return (
+    <div
+      style={{
+        display: "flex",
+        justifyContent: "flex-start",
+      }}
+    >
+      <div
+        style={{
+          maxWidth: "95%",
+          color: "black",
+          borderRadius: "8px",
+          padding: "16px",
+          border: "none",
+          boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
+          textAlign: "left",
+        }}
+      >
+        {children}
+      </div>
+    </div>
+  );
+};
+
+export default MsgContainer;

+ 23 - 0
dashboard-v4/dashboard/src/components/chat/MsgError.tsx

@@ -0,0 +1,23 @@
+import { Alert, Button } from "antd";
+import { ReloadOutlined } from "@ant-design/icons";
+interface IWidget {
+  message?: string;
+  onRefresh?: () => void;
+}
+const MsgError = ({ message, onRefresh }: IWidget) => {
+  return (
+    <Alert
+      type="error"
+      closable={false}
+      showIcon
+      message={message}
+      action={
+        <Button type="text" icon={<ReloadOutlined />} onClick={onRefresh}>
+          刷新
+        </Button>
+      }
+    />
+  );
+};
+
+export default MsgError;

+ 20 - 0
dashboard-v4/dashboard/src/components/chat/MsgLoading.tsx

@@ -0,0 +1,20 @@
+import MsgContainer from "./MsgContainer";
+import { IAiModel } from "../api/ai";
+import User from "../auth/User";
+import { Space } from "antd";
+
+interface IWidget {
+  model?: IAiModel;
+}
+const MsgLoading = ({ model }: IWidget) => {
+  return (
+    <MsgContainer>
+      <Space>
+        <User {...model?.user} />
+        正在思考……
+      </Space>
+    </MsgContainer>
+  );
+};
+
+export default MsgLoading;

+ 42 - 0
dashboard-v4/dashboard/src/components/chat/MsgSystem.tsx

@@ -0,0 +1,42 @@
+import { useState } from "react";
+
+interface IWidget {
+  value?: string;
+}
+const MsgSystem = ({ value }: IWidget) => {
+  const [display, setDisplay] = useState(false);
+  return (
+    <div
+      style={{
+        backgroundColor: "#fafafa",
+        border: "1px dashed #d9d9d9",
+        borderRadius: 4,
+        marginTop: 8,
+        fontSize: 12,
+        color: "#888",
+        padding: 8,
+      }}
+    >
+      <div
+        style={{ cursor: "pointer", userSelect: "none" }}
+        onClick={() => {
+          setDisplay(!display);
+        }}
+      >
+        {display ? "▼ 收起资料" : "▶ 展开资料"}
+      </div>
+      <div style={{ display: display ? "block" : "none" }}>
+        <pre
+          style={{
+            whiteSpace: "pre-wrap",
+            wordBreak: "break-word",
+            marginTop: 4,
+          }}
+        >
+          {value}
+        </pre>
+      </div>
+    </div>
+  );
+};
+export default MsgSystem;

+ 23 - 0
dashboard-v4/dashboard/src/components/chat/MsgTyping.tsx

@@ -0,0 +1,23 @@
+import MsgContainer from "./MsgContainer";
+import { IAiModel } from "../api/ai";
+import Marked from "../general/Marked";
+import User from "../auth/User";
+
+interface IWidget {
+  model?: IAiModel;
+  text?: string;
+}
+const MsgTyping = ({ model, text }: IWidget) => {
+  return (
+    <MsgContainer>
+      <div>
+        <User {...model?.user} />
+      </div>
+      <div>
+        <Marked text={text} />
+      </div>
+    </MsgContainer>
+  );
+};
+
+export default MsgTyping;

+ 106 - 44
dashboard-v4/dashboard/src/components/chat/MsgUser.tsx

@@ -1,10 +1,15 @@
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, 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 { Button, message, Space, Tooltip } from "antd";
 
-import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
+import {
+  CheckOutlined,
+  CloseOutlined,
+  CopyOutlined,
+  EditOutlined,
+} from "@ant-design/icons";
 
 interface IWidget {
   msg?: Message;
@@ -13,11 +18,17 @@ interface IWidget {
 
 const MsgUser = ({ msg, onChange }: IWidget) => {
   const [editing, setEditing] = useState(false);
-  const [content, setContent] = useState("");
+  const [current, setCurrent] = useState(0);
+  const [content, setContent] = useState<string>("");
+
+  useEffect(() => {
+    if (msg?.versions && msg?.versions.length > 0) {
+      setContent(msg.versions[current].content);
+    }
+  }, [current, msg]);
 
   const confirmEdit = useCallback((): void => {
     onChange && onChange(content);
-    setContent("");
   }, [content, onChange]);
 
   const cancelEdit = useCallback((): void => {
@@ -36,49 +47,100 @@ const MsgUser = ({ msg, onChange }: IWidget) => {
     [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>
+  return (
+    <div
+      style={{
+        display: "flex",
+        justifyContent: "flex-end",
+      }}
+    >
       <div
         style={{
-          fontSize: "12px",
-          opacity: 0.6,
-          marginTop: "8px",
+          maxWidth: "70%",
+          minWidth: 400,
+          backgroundColor: "rgba(255, 255, 255, 0.8)",
+          color: "black",
+          borderRadius: "8px",
+          padding: "16px",
+          border: "none",
+          boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
+          textAlign: "left",
         }}
       >
-        {msg?.timestamp}
+        {editing ? (
+          <div style={{ width: "100%" }}>
+            <TextArea
+              value={content}
+              onChange={(e) => setContent(e.target.value)}
+              onKeyPress={handleEditKeyPress}
+              autoSize={{ minRows: 2, maxRows: 8 }}
+              style={{ marginBottom: "8px", width: "100%" }}
+            />
+            <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>
+              <Marked text={msg?.versions[current].content} />
+            </div>
+            <div
+              style={{
+                fontSize: "12px",
+                opacity: 0.6,
+                marginTop: "8px",
+              }}
+            >
+              {msg?.versions[current].timestamp}
+            </div>
+            <div>
+              <Space size="small">
+                <Tooltip title="复制">
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<CopyOutlined />}
+                    onClick={() => {
+                      msg &&
+                        navigator.clipboard
+                          .writeText(msg.versions[current].content)
+                          .then((value) => message.success("已复制到剪贴板"))
+                          .catch((reason: any) => {
+                            console.error("复制失败:", reason);
+                            message.error("复制失败");
+                          });
+                    }}
+                  />
+                </Tooltip>
+                <Tooltip title="复制">
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<EditOutlined />}
+                    onClick={() => {
+                      msg && setEditing(true);
+                    }}
+                  />
+                </Tooltip>
+              </Space>
+            </div>
+          </div>
+        )}
       </div>
     </div>
   );