visuddhinanda пре 9 месеци
родитељ
комит
23fcf3a4f9
1 измењених фајлова са 640 додато и 0 уклоњено
  1. 640 0
      dashboard-v4/dashboard/src/components/chat/AiChat.tsx

+ 640 - 0
dashboard-v4/dashboard/src/components/chat/AiChat.tsx

@@ -0,0 +1,640 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import {
+  Input,
+  Button,
+  Avatar,
+  Dropdown,
+  message,
+  Tooltip,
+  Space,
+  Spin,
+  MenuProps,
+  Card,
+  Affix,
+} from "antd";
+import {
+  SendOutlined,
+  CopyOutlined,
+  EditOutlined,
+  ReloadOutlined,
+  DownOutlined,
+  UserOutlined,
+  RobotOutlined,
+  PaperClipOutlined,
+} from "@ant-design/icons";
+import Marked from "../general/Marked";
+import { IAiModel, IAiModelListResponse } from "../api/ai";
+import { get } from "../../request";
+
+const { TextArea } = Input;
+
+// 类型定义
+interface Message {
+  id: number;
+  type: "user" | "ai";
+  content: string;
+  timestamp: string;
+  model?: string;
+}
+
+interface OpenAIMessage {
+  role: "system" | "user" | "assistant";
+  content: string;
+}
+
+interface AIModel {
+  key: string;
+  label: string;
+}
+
+interface StreamTypeController {
+  addToken: (token: string) => void;
+  complete: () => void;
+}
+
+interface OpenAIStreamResponse {
+  choices?: Array<{
+    delta?: {
+      content?: string;
+    };
+  }>;
+}
+
+interface IWidget {
+  initMessage?: string;
+  systemPrompt?: string;
+  onChat?: () => void;
+}
+const AIChatComponent = ({
+  initMessage,
+  systemPrompt = "你是一个巴利语专家",
+  onChat,
+}: IWidget) => {
+  const [messages, setMessages] = useState<Message[]>([]);
+  const [inputValue, setInputValue] = useState<string>("");
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+  const [selectedModel, setSelectedModel] = useState<string>("");
+
+  const messagesEndRef = useRef<HTMLDivElement>(null);
+  const [isTyping, setIsTyping] = useState<boolean>(false);
+  const [currentTypingMessage, setCurrentTypingMessage] = useState<string>("");
+  const [models, setModels] = useState<IAiModel[]>(); // 可用的AI模型
+
+  const scrollToBottom = useCallback(() => {
+    messagesEndRef.current?.scrollIntoView({
+      behavior: "smooth",
+      block: "center",
+    });
+  }, []);
+
+  useEffect(() => {
+    const url = `/v2/ai-model?view=chat`;
+    console.info("api request", url);
+    get<IAiModelListResponse>(url).then((json) => {
+      if (json.ok) {
+        setModels(json.data.rows);
+        if (json.data.rows.length > 0) {
+          setSelectedModel(json.data.rows[0].uid);
+        }
+      }
+    });
+  }, []);
+
+  useEffect(() => {
+    scrollToBottom();
+  }, [messages, currentTypingMessage, scrollToBottom]);
+
+  useEffect(() => {
+    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,
+      onComplete?: (finalContent: string) => void
+    ): StreamTypeController => {
+      setIsTyping(true);
+      setCurrentTypingMessage("");
+
+      return {
+        addToken: (token: string) => {
+          setCurrentTypingMessage((prev) => {
+            const newContent = prev + token;
+            onToken && onToken(newContent);
+            return newContent;
+          });
+        },
+        complete: () => {
+          setIsTyping(false);
+          setCurrentTypingMessage((prev) => {
+            const finalContent = prev;
+            setCurrentTypingMessage("");
+            onComplete && onComplete(finalContent);
+            return "";
+          });
+        },
+      };
+    },
+    []
+  );
+
+  // 调用OpenAI API - 支持流式输出
+  const callOpenAI = useCallback(
+    async (messages: OpenAIMessage[]): Promise<void> => {
+      setIsLoading(false); // 开始流式输出时取消loading状态
+      if (typeof process.env.REACT_APP_OPENAI_PROXY === "undefined") {
+        console.error("no REACT_APP_OPENAI_PROXY");
+        return;
+      }
+      try {
+        const payload = {
+          model: selectedModel,
+          messages: messages,
+          stream: true,
+          temperature: 0.7,
+          max_tokens: 2000,
+        };
+        const response = await fetch(process.env.REACT_APP_OPENAI_PROXY, {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json",
+            Authorization: `Bearer AIzaSyCzr8KqEdaQ3cRCxsFwSHh8c7kF3RZTZWw`, // 或你的API密钥
+          },
+          body: JSON.stringify({
+            model_id: selectedModel,
+            payload: payload,
+          }),
+        });
+
+        if (!response.ok) {
+          throw new Error(`HTTP error! status: ${response.status}`);
+        }
+
+        // 处理流式响应
+        const reader = response.body?.getReader();
+        if (!reader) {
+          throw new Error("无法获取响应流");
+        }
+
+        const decoder = new TextDecoder();
+        let buffer = "";
+
+        // 创建流式打字机效果
+        const typeController = streamTypeWriter(
+          (content: string) => {
+            // 每次添加token时的回调
+          },
+          (finalContent: string) => {
+            // 完成时的回调
+            const aiMessage: Message = {
+              id: Date.now(),
+              type: "ai",
+              content: finalContent,
+              timestamp: new Date().toLocaleTimeString(),
+              model: selectedModel,
+            };
+            setMessages((prev) => [...prev, aiMessage]);
+          }
+        );
+
+        try {
+          while (true) {
+            const { done, value } = await reader.read();
+
+            if (done) {
+              typeController.complete();
+              break;
+            }
+
+            buffer += decoder.decode(value, { stream: true });
+            const lines = buffer.split("\n");
+            buffer = lines.pop() || "";
+
+            for (const line of lines) {
+              if (line.trim() === "") continue;
+              if (line.startsWith("data: ")) {
+                const data = line.slice(6);
+
+                if (data === "[DONE]") {
+                  typeController.complete();
+                  return;
+                }
+
+                try {
+                  const parsed: OpenAIStreamResponse = JSON.parse(data);
+                  const delta = parsed.choices?.[0]?.delta;
+
+                  if (delta?.content) {
+                    typeController.addToken(delta.content);
+                  }
+                } catch (e) {
+                  console.warn("解析SSE数据失败:", e);
+                }
+              }
+            }
+          }
+        } catch (error) {
+          console.error("读取流数据失败:", error);
+          typeController.complete();
+          throw error;
+        }
+      } catch (error) {
+        console.error("API调用失败:", error);
+
+        // 如果真实API失败,回退到模拟响应
+        const mockResponse = await simulateAIResponse(messages);
+        typeWriter(mockResponse, () => {
+          const aiMessage: Message = {
+            id: Date.now(),
+            type: "ai",
+            content: mockResponse,
+            timestamp: new Date().toLocaleTimeString(),
+            model: selectedModel,
+          };
+          setMessages((prev) => [...prev, aiMessage]);
+        });
+      }
+    },
+    [selectedModel, streamTypeWriter, typeWriter]
+  );
+
+  // 模拟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;
+
+      const userMessage: Message = {
+        id: Date.now(),
+        type: "user",
+        content: messageText,
+        timestamp: new Date().toLocaleTimeString(),
+      };
+
+      setMessages((prev) => [...prev, userMessage]);
+      setInputValue("");
+      setIsLoading(true);
+      onChat && onChat();
+      try {
+        // 构建对话历史
+        const conversationHistory: OpenAIMessage[] = [
+          { role: "system", content: systemPrompt },
+          ...messages.map((msg) => {
+            const newMsg: OpenAIMessage = {
+              role: msg.type === "user" ? "user" : "assistant",
+              content: msg.content,
+            };
+            return newMsg;
+          }),
+          { role: "user", content: messageText },
+        ];
+
+        // 调用OpenAI API
+        await callOpenAI(conversationHistory);
+      } catch (error) {
+        console.error("发送消息失败:", error);
+        message.error("发送消息失败,请重试");
+        setIsLoading(false);
+        setIsTyping(false);
+      }
+    },
+    [inputValue, messages, systemPrompt, callOpenAI]
+  );
+
+  // 复制消息内容
+  const copyMessage = useCallback(async (content: string): Promise<void> => {
+    try {
+      await navigator.clipboard.writeText(content);
+      message.success("已复制到剪贴板");
+    } catch (error) {
+      console.error("复制失败:", error);
+      message.error("复制失败");
+    }
+  }, []);
+
+  // 刷新AI回答
+  const refreshAIResponse = useCallback(
+    async (messageIndex: number): Promise<void> => {
+      const userMessage = messages[messageIndex - 1];
+      if (userMessage && userMessage.type === "user") {
+        // 重新构建到该消息为止的对话历史
+        const conversationHistory: OpenAIMessage[] = [
+          { role: "system", content: systemPrompt },
+          ...messages.slice(0, messageIndex - 1).map((msg) => {
+            const newMsg: OpenAIMessage = {
+              role: msg.type === "user" ? "user" : "assistant",
+              content: msg.content,
+            };
+            return newMsg;
+          }),
+          { role: "user", content: userMessage.content },
+        ];
+
+        // 移除旧的AI回答
+        setMessages((prev) => prev.slice(0, messageIndex));
+
+        try {
+          await callOpenAI(conversationHistory);
+        } catch (error) {
+          console.error("刷新回答失败:", error);
+          message.error("刷新回答失败,请重试");
+        }
+      }
+    },
+    [messages, systemPrompt, callOpenAI]
+  );
+
+  // 编辑用户消息
+  const editUserMessage = useCallback(
+    (messageIndex: number, newContent: string): void => {
+      const updatedMessages = [...messages];
+      updatedMessages[messageIndex].content = newContent;
+      setMessages(updatedMessages);
+    },
+    [messages]
+  );
+
+  // 处理键盘事件
+  const handleKeyPress = useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
+      if (e.key === "Enter" && !e.shiftKey) {
+        e.preventDefault();
+        sendMessage();
+      }
+    },
+    [sendMessage]
+  );
+
+  // 模型选择菜单
+  const modelMenu: MenuProps = {
+    selectedKeys: [selectedModel],
+    onClick: ({ key }) => setSelectedModel(key),
+    items: models?.map((model) => ({
+      key: model.uid,
+      label: model.name,
+    })),
+  };
+
+  // 刷新按钮的下拉菜单
+  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 className="flex flex-col h-screen bg-gray-50">
+      {/* 聊天显示窗口 */}
+      <div className="flex-1 overflow-y-auto p-4 space-y-4">
+        {messages.map((msg, index) => (
+          <div
+            key={msg.id}
+            className={`flex ${
+              msg.type === "user" ? "justify-end" : "justify-start"
+            }`}
+          >
+            <div
+              className={`group max-w-[70%] ${
+                msg.type === "user"
+                  ? "bg-blue-500 text-white rounded-l-lg rounded-tr-lg"
+                  : "bg-white border rounded-r-lg rounded-tl-lg shadow-sm"
+              } p-4 relative`}
+            >
+              <div className="flex items-start space-x-3">
+                <Avatar
+                  size={32}
+                  icon={
+                    msg.type === "user" ? <UserOutlined /> : <RobotOutlined />
+                  }
+                  className={
+                    msg.type === "user" ? "bg-blue-600" : "bg-gray-500"
+                  }
+                />
+                <div className="flex-1">
+                  <div className="text-sm font-medium mb-1">
+                    {msg.type === "user"
+                      ? "你"
+                      : msg.model
+                      ? models?.find((m) => m.uid === msg.model)?.name
+                      : "AI助手"}
+                  </div>
+                  <div className="text-sm leading-relaxed whitespace-pre-wrap">
+                    <Marked text={msg.content} />
+                  </div>
+                  <div className="text-xs opacity-60 mt-2">{msg.timestamp}</div>
+                </div>
+              </div>
+
+              {/* 悬浮工具按钮 */}
+              <div
+                className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
+                style={{ textAlign: "right" }}
+              >
+                <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={() => {
+                          const newContent = prompt("编辑消息:", msg.content);
+                          if (newContent !== null) {
+                            editUserMessage(index, newContent);
+                          }
+                        }}
+                      />
+                    </Tooltip>
+                  ) : (
+                    <Dropdown menu={refreshMenu(index)} trigger={["hover"]}>
+                      <Button
+                        size="small"
+                        type="text"
+                        icon={<ReloadOutlined />}
+                      />
+                    </Dropdown>
+                  )}
+                </Space>
+              </div>
+            </div>
+          </div>
+        ))}
+
+        {/* 显示AI正在输入的消息 */}
+        {isTyping && (
+          <div className="flex justify-start">
+            <div className="max-w-[70%] bg-white border rounded-r-lg rounded-tl-lg shadow-sm p-4">
+              <div className="flex items-start space-x-3">
+                <Avatar
+                  size={32}
+                  icon={<RobotOutlined />}
+                  className="bg-gray-500"
+                />
+                <div className="flex-1">
+                  <div className="text-sm font-medium mb-1">
+                    {models?.find((m) => m.uid === selectedModel)?.name ||
+                      "AI助手"}
+                  </div>
+                  <Marked text={currentTypingMessage} />
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {isLoading && !isTyping && (
+          <div className="flex justify-start">
+            <div className="max-w-[70%] bg-white border rounded-r-lg rounded-tl-lg shadow-sm p-4">
+              <div className="flex items-center space-x-3">
+                <Avatar
+                  size={32}
+                  icon={<RobotOutlined />}
+                  className="bg-gray-500"
+                />
+                <Spin size="small" />
+                <span className="text-sm text-gray-500">正在思考...</span>
+              </div>
+            </div>
+          </div>
+        )}
+
+        <div ref={messagesEndRef} />
+      </div>
+
+      {/* 用户输入区域 */}
+      <Affix offsetBottom={10}>
+        <Card bordered={true} style={{ borderRadius: 10, borderColor: "gray" }}>
+          <div className="max-w-4xl mx-auto">
+            {/* 输入框 */}
+            <div style={{ display: "flex" }}>
+              <TextArea
+                value={inputValue}
+                onChange={(e) => setInputValue(e.target.value)}
+                onKeyPress={handleKeyPress}
+                placeholder="提出你的问题,如:总结下面的内容..."
+                autoSize={{ minRows: 1, maxRows: 6 }}
+                className="resize-none pr-12"
+              />
+            </div>
+
+            {/* 功能按钮和模型选择 */}
+            <div style={{ display: "flex", justifyContent: "space-between" }}>
+              <Space>
+                <Tooltip title="附加文件">
+                  <Button
+                    size="small"
+                    type="text"
+                    icon={<PaperClipOutlined />}
+                  />
+                </Tooltip>
+              </Space>
+              <div>
+                <Dropdown menu={modelMenu} trigger={["click"]}>
+                  <Button size="small" type="text">
+                    {models?.find((m) => m.uid === selectedModel)?.name}
+                    <DownOutlined />
+                  </Button>
+                </Dropdown>
+                <Button
+                  type="primary"
+                  icon={<SendOutlined />}
+                  onClick={() => sendMessage()}
+                  disabled={!inputValue.trim() || isLoading}
+                  className="absolute right-2 bottom-2"
+                />
+              </div>
+            </div>
+          </div>
+        </Card>
+      </Affix>
+    </div>
+  );
+};
+
+export default AIChatComponent;