Преглед на файлове

feat: 添加md渲染,代码复制,新建对话功能

liaojiaxing преди 2 месеца
родител
ревизия
2843201300

+ 1 - 0
apps/ai-server/package.json

@@ -11,6 +11,7 @@
   "license": "ISC",
   "dependencies": {
     "@ai-sdk/openai": "^1.1.9",
+    "cors": "^2.8.5",
     "dotenv": "^16.4.7",
     "express": "^4.21.2"
   }

+ 2 - 0
apps/ai-server/src/index.js

@@ -1,8 +1,10 @@
 const app = require('express')();
 const express = require('express');
+const cros = require('cors');
 
 app.use(express.json());
 app.use(express.urlencoded({ extended: true }));
+app.use(cros());
 
 app.use('/', require('./controller'));
 

+ 2 - 14
apps/ai-server/src/service/index.js

@@ -15,22 +15,10 @@ class AIService {
         "model": deepseek.chat("deepseek-chat"),
         messages,
         temperature: 0.5,
-        maxTokens: 1024
+        maxTokens: 1024,
       });
 
-      console.log(result.textStream);
-
-      // 设置流式响应头
-      res.setHeader('Content-Type', 'text/plain');
-      res.setHeader('Transfer-Encoding', 'chunked');
-
-      // 流式返回数据
-      for await (const chunk of result.textStream) {
-        console.log(chunk);
-        res.write(chunk);
-      }
-
-      res.end(); // 结束响应
+      result.pipeDataStreamToResponse(res);
 
     } catch (error) {
       console.error(error);

+ 2 - 2
apps/designer/.umirc.ts

@@ -8,7 +8,7 @@ export default defineConfig({
     '/favicon.ico'
   ],
   styles: [
-    '//at.alicdn.com/t/c/font_4676747_eaief6vm62t.css'
+    '//at.alicdn.com/t/c/font_4676747_cuvm3rg4xsa.css'
   ],
   metas: [
     { name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' }
@@ -16,7 +16,7 @@ export default defineConfig({
   scripts: [
     // 字体加载
     // '//ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js'
-    '//at.alicdn.com/t/c/font_4676747_xihmn5nmv9h.js'
+    '//at.alicdn.com/t/c/font_4676747_dwa7i924a0c.js'
   ],
   plugins: [
     require.resolve('@umijs/plugins/dist/unocss'),

+ 221 - 30
apps/designer/src/components/ai/Chat.tsx

@@ -6,14 +6,134 @@ import {
   UserOutlined,
   SendOutlined,
   LoadingOutlined,
-  PauseOutlined,
 } from "@ant-design/icons";
-import { Button, Tooltip, Input } from "antd";
-import { useModel } from "umi";
+import { Button, Tooltip, Input, Avatar, Form } from "antd";
+import { useChat, Message } from "ai/react";
+import MarkdownViewer from "./MarkdownViewer";
+import { uuid } from "@repo/utils";
 
-export default function Chat() {
-  const { setActiveAIChat } = useModel("appModel");
+function UserMessage({ message }: { message: Message }) {
+  return (
+    <>
+      <div className="rounded-8px bg-#eff6ff p-8px leading-1.5em">
+        {message.content ?? ""}
+      </div>
+
+      <Avatar size={32} icon={<UserOutlined />}></Avatar>
+    </>
+  );
+}
+
+function AssistantMessage({ message }: { message: Message }) {
+  return (
+    <>
+      <Avatar
+        size={32}
+        className="flex-shrink-0"
+        icon={
+          <svg className="icon h-32px w-32px" aria-hidden="true">
+            <use xlinkHref="#icon-AI1"></use>
+          </svg>
+        }
+      ></Avatar>
+
+      <div
+        className="rounded-8px bg-#fff p-8px leading-1.5em overflow-x-auto"
+        style={{ overflowX: "auto" }}
+      >
+        <MarkdownViewer content={message.content ?? ""} />
+      </div>
+    </>
+  );
+}
+
+function MessageInfo({ message }: { message: Message }) {
+  return (
+    <div
+      key={message.id}
+      className={`flex items-start space-x-2 mb-4 ${message.role === "user" ? "justify-end" : "justify-start"}`}
+    >
+      {message.role === "assistant" ? (
+        <AssistantMessage message={message}></AssistantMessage>
+      ) : (
+        <UserMessage message={message}></UserMessage>
+      )}
+    </div>
+  );
+}
+
+function MessageList({ messages }: { messages: Message[] }) {
+  return messages.map((message) => (
+    <MessageInfo key={message.id} message={message}></MessageInfo>
+  ));
+}
+
+export default function Chat(props: { onClose?: () => void }) {
   const [focused, setFocused] = React.useState(false);
+  const [chatStarted, setChatStarted] = React.useState(false);
+  const [scrollHeight, setScrollHeight] = React.useState(0);
+  const scrollAreaRef = React.useRef<HTMLDivElement>(null);
+
+  const [chatId, setChatId] = React.useState(uuid());
+
+  const {
+    messages,
+    input,
+    handleInputChange,
+    handleSubmit,
+    isLoading,
+    stop,
+    error,
+    reload,
+    setMessages,
+  } = useChat({
+    api: "http://localhost:3000/ai/chat",
+    keepLastMessageOnError: true,
+    id: chatId,
+  });
+
+  // 处理提交
+  const onSubmit = () => {
+    if (input.trim()) {
+      handleSubmit();
+      if (!chatStarted) setChatStarted(true);
+    }
+  };
+
+  // 开启新的对话
+  const onNewChat = () => {
+    setChatId(uuid());
+    setMessages([]);
+    setChatStarted(false);
+  };
+
+  React.useEffect(() => {
+    return () => {
+      // 取消所有进行中的请求
+      const controller = new AbortController();
+      controller.abort();
+    };
+  }, []);
+
+  React.useEffect(() => {
+    const scrollElement = scrollAreaRef.current;
+    if (scrollElement) {
+      const observer = new ResizeObserver((entries) => {
+        for (let entry of entries) {
+          if (entry.target === scrollElement) {
+            setScrollHeight(entry.target.scrollHeight);
+            entry.target.scrollTop = entry.target.scrollHeight;
+          }
+        }
+      });
+
+      observer.observe(scrollElement);
+
+      return () => {
+        observer.disconnect();
+      };
+    }
+  }, [messages]);
 
   return (
     <div className="flex-1 h-full flex flex-col">
@@ -21,47 +141,118 @@ export default function Chat() {
         <i className="iconfont icon-duihua"></i>
         <span>
           <Tooltip title="新建会话">
-            <Button type="text" size="small" icon={<PlusOutlined />}></Button>
-          </Tooltip>
-          <Tooltip title="历史记录">
             <Button
               type="text"
               size="small"
-              icon={<FieldTimeOutlined />}
+              icon={<PlusOutlined />}
+              onClick={onNewChat}
             ></Button>
           </Tooltip>
-          <Tooltip title="关闭">
+          <Tooltip title="历史记录">
             <Button
               type="text"
               size="small"
-              icon={<CloseOutlined />}
-              onClick={() => setActiveAIChat(false)}
+              icon={<FieldTimeOutlined />}
             ></Button>
           </Tooltip>
+          <Button
+            type="text"
+            size="small"
+            icon={<CloseOutlined />}
+            onClick={() => props.onClose?.()}
+          ></Button>
         </span>
       </div>
 
-      <div className="chat-content flex-1 bg-#f5f5f5 px-10px">
-        <div className="text-center pt-40px"><i className="iconfont icon-robot-2-line text-#333 text-32px"></i></div>
-        <h2 className="text-center">询问AI助手</h2>
-        <p>您好,我是AI助手,有什么可以帮您的吗?</p>
-      </div>
+      <div
+        className="chat-content flex-1 bg-#f5f5f5 px-10px overflow-y-auto mt-12px"
+        ref={scrollAreaRef}
+      >
+        <div style={{ minHeight: `${scrollHeight}px` }}>
+          {!chatStarted && (
+            <>
+              <div className="text-center pt-200px">
+                <svg className="icon h-40px! w-40px!" aria-hidden="true">
+                  <use xlinkHref="#icon-AI1"></use>
+                </svg>
+              </div>
+              <h2 className="text-center">询问AI助手</h2>
+              <p className="text-center">我是AI助手,有什么可以帮您的吗?</p>
+            </>
+          )}
 
-      <div style={{
-        borderColor: focused ? '#1890ff' : '#ddd'
-      }} className="chat-foot bg-#f3f4f6 rounded-10px border border-solid border-1px m-10px">
-        <Input.TextArea
-          rows={3}
-          autoSize={{ maxRows: 3, minRows: 3 }}
-          placeholder="输入询问内容..."
-          variant="borderless"
-          onFocus={() => setFocused(true)}
-          onBlur={() => setFocused(false)}
-        />
-        <div className="float-right p-10px">
-          <Button type="primary" icon={<SendOutlined />}>发送</Button>
+          {chatStarted && (
+            <div className="overflow-y-auto h-full">
+              <MessageList messages={messages}></MessageList>
+              <div className="flex justify-center items-center h-40px">
+                {isLoading && (
+                  <Button
+                    type="primary"
+                    icon={<LoadingOutlined />}
+                    loading={isLoading}
+                  >
+                    思考中...
+                  </Button>
+                )}
+              </div>
+              {error && (
+                <div className="flex justify-center items-center h-40px">
+                  <Button
+                    type="primary"
+                    shape="circle"
+                    onClick={() => reload()}
+                  >
+                    重试
+                  </Button>
+                </div>
+              )}
+            </div>
+          )}
         </div>
       </div>
+
+      <div
+        style={{
+          borderColor: focused ? "#1890ff" : "#ddd",
+        }}
+        className="chat-foot bg-#f3f4f6 rounded-10px border border-solid border-1px m-10px"
+      >
+        <Form onFinish={onSubmit}>
+          <Input.TextArea
+            rows={3}
+            autoSize={{ maxRows: 3, minRows: 3 }}
+            placeholder="输入询问内容..."
+            variant="borderless"
+            onFocus={() => setFocused(true)}
+            onBlur={() => setFocused(false)}
+            value={input}
+            onChange={handleInputChange}
+            disabled={isLoading}
+            onPressEnter={onSubmit}
+          />
+          <div className="float-right p-10px">
+            {isLoading ? (
+              <Tooltip title="停止生成">
+                <Button
+                  type="primary"
+                  shape="circle"
+                  icon={<i className="iconfont icon-stopcircle" />}
+                  onClick={stop}
+                ></Button>
+              </Tooltip>
+            ) : (
+              <Button
+                type="primary"
+                icon={<SendOutlined />}
+                disabled={!input.trim()}
+                htmlType="submit"
+              >
+                发送
+              </Button>
+            )}
+          </div>
+        </Form>
+      </div>
     </div>
   );
 }

+ 92 - 0
apps/designer/src/components/ai/MarkdownViewer.tsx

@@ -0,0 +1,92 @@
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeRaw from "rehype-raw";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { useState } from "react";
+
+interface MarkdownViewerProps {
+  content: string;
+}
+
+const CodeHeader: React.FC<{
+  language: string;
+  onCopy: () => void;
+  copied: boolean;
+}> = ({ language, onCopy, copied }) => (
+  <div
+    className="flex justify-between items-center text-white"
+    style={{ background: "#afadad", padding: "3px 4px" }}
+  >
+    <div className="flex items-center">
+      <span className="text-xs text-#fff">{language}</span>
+    </div>
+
+    <span className="cursor-pointer text-xs" onClick={onCopy}>
+      {copied ? "已复制!" : "复制代码"}
+    </span>
+  </div>
+);
+
+const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content }) => {
+  const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
+
+  const handleCopy = (code: string, index: number) => {
+    navigator.clipboard.writeText(code);
+    setCopiedIndex(index);
+    setTimeout(() => setCopiedIndex(null), 2000);
+  };
+
+  return (
+    <ReactMarkdown
+      className="markdown-body"
+      remarkPlugins={[remarkGfm]}
+      rehypePlugins={[rehypeRaw]}
+      components={{
+        code({ node, className, children, ...props }) {
+          const match = /language-(\w+)/.exec(className || "");
+          const code = String(children).replace(/\n$/, "");
+          const language = match ? match[1] : "";
+
+          if (match) {
+            return (
+              <div className="rounded-md overflow-hidden mb-4">
+                <CodeHeader
+                  language={language}
+                  onCopy={() =>
+                    handleCopy(code, node?.position?.start.line ?? 0)
+                  }
+                  copied={copiedIndex === node?.position?.start.line}
+                />
+                <div className="max-w-full overflow-x-auto">
+                  <SyntaxHighlighter
+                    style={vscDarkPlus}
+                    language={language}
+                    PreTag="div"
+                    {...props}
+                    customStyle={{
+                      margin: 0,
+                      borderTopLeftRadius: 0,
+                      borderTopRightRadius: 0,
+                    }}
+                  >
+                    {code}
+                  </SyntaxHighlighter>
+                </div>
+              </div>
+            );
+          }
+          return (
+            <code className={className} {...props}>
+              {children}
+            </code>
+          );
+        },
+      }}
+    >
+      {content}
+    </ReactMarkdown>
+  );
+};
+
+export default MarkdownViewer;

+ 3 - 1
apps/designer/src/index.d.ts

@@ -1,2 +1,4 @@
 declare module 'insert-css';
-declare module '@antv/hierarchy';
+declare module '@antv/hierarchy';
+declare module 'react-syntax-highlighter';
+declare module 'react-syntax-highlighter/dist/esm/styles/prism';

+ 5 - 1
apps/designer/src/models/appModel.ts

@@ -40,6 +40,8 @@ export default function appModel() {
   const [activeAIChat, setActiveAIChat] = useState(false);
   // 右侧面板tab activeKey
   const [rightPanelTabActiveKey, setRightPanelTabActiveKey] = useState("1");
+  // 右侧面板宽度
+  const [rightPanelWidth, setRightPanelWidth] = useState(280);
   const graphRef = useRef<Graph>();
   const [pageState, setPageState] = useState<PageSettings>({
     backgroundColor: "transparent",
@@ -181,6 +183,8 @@ export default function appModel() {
     showHistory,
     setShowHistory,
     activeAIChat,
-    setActiveAIChat
+    setActiveAIChat,
+    rightPanelWidth,
+    setRightPanelWidth,
   }
 }

+ 60 - 14
apps/designer/src/pages/flow/components/Config/index.tsx

@@ -1,11 +1,12 @@
 import { ConfigProvider, Tabs } from "antd";
-import React, { useEffect, useMemo } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import PageStyle from "./PageStyle";
 import GraphStyle from "./GraphStyle";
 import NodeAttrs from "@/components/NodeAttrs";
 import { useModel } from "umi";
 import InsetCss from "insert-css";
 import Chat from "@/components/ai/Chat";
+import { debounce, set } from "lodash-es";
 
 InsetCss(`
   .shalu-tabs {
@@ -20,8 +21,14 @@ InsetCss(`
   `);
 export default function Config() {
   const { selectedCell } = useModel("graphModel");
-  const { rightPanelTabActiveKey, setRightPanelTabActiveKey, activeAIChat } =
-    useModel("appModel");
+  const {
+    rightPanelTabActiveKey,
+    setRightPanelTabActiveKey,
+    activeAIChat,
+    setActiveAIChat,
+    rightPanelWidth,
+    setRightPanelWidth,
+  } = useModel("appModel");
 
   const firstNode = useMemo(() => {
     return selectedCell.find((cell) => cell.isNode());
@@ -95,22 +102,61 @@ export default function Config() {
   //   }
   // }, [selectedCell]);
 
+  const [dragging, setDragging] = useState(false);
+
+  // 拖拽改变右侧面板宽度
+  const handleMouseMove = (e: MouseEvent) => {
+    const offset = startRef.current - e.clientX;
+    requestAnimationFrame(() => {
+      setRightPanelWidth((w) => {
+        const newW = w + offset;
+        if (newW < 280) return 280;
+        if (newW > window.innerWidth * 0.5) return window.innerWidth * 0.5;
+        return newW;
+      });
+      startRef.current = e.clientX;
+    });
+  };
+
+  const handleMouseUp = () => {
+    setDragging(false);
+    document.body.style.cursor = "default";
+    document.removeEventListener("mousemove", handleMouseMove);
+    document.removeEventListener("mouseup", handleMouseUp);
+  };
+
+  const startRef = React.useRef<number>(0);
+  // 开启左右拖拽
+  const handleMouseDown = (e: React.MouseEvent) => {
+    setDragging(true);
+    // 设置鼠标样式
+    document.body.style.cursor = "ew-resize";
+    startRef.current = e.clientX;
+    document.addEventListener("mousemove", handleMouseMove);
+    document.addEventListener("mouseup", handleMouseUp);
+  };
+
   return (
     <div className="w-full h-full flex">
       <div
-        className="h-full w-4px cursor-e-resize hover:bg-blue"
+        className="h-full w-4px cursor-e-resize hover:bg-blue flex-shrink-0"
+        style={{ backgroundColor: dragging ? "#60a5fa" : "" }}
         ref={resizeRef}
+        onMouseDown={handleMouseDown}
       ></div>
-      {activeAIChat ? (
-        <Chat />
-      ) : (
-        <Tabs
-          centered
-          items={tabItems}
-          activeKey={rightPanelTabActiveKey}
-          onChange={(key) => setRightPanelTabActiveKey(key)}
-        />
-      )}
+      <div className="flex-1 overflow-hidden">
+        {activeAIChat ? (
+          <Chat onClose={() => setActiveAIChat(false)} />
+        ) : (
+          <Tabs
+            centered
+            items={tabItems}
+            activeKey={rightPanelTabActiveKey}
+            onChange={(key) => setRightPanelTabActiveKey(key)}
+            className="w-full"
+          />
+        )}
+      </div>
     </div>
   );
 }

+ 2 - 2
apps/designer/src/pages/flow/index.tsx

@@ -9,7 +9,7 @@ import { useModel, useParams, useRequest } from "umi";
 import { useEffect } from "react";
 import { FlowchartMindMapInfo } from "@/api/systemDesigner";
 export default function HomePage() {
-  const { showRightPanel, pageState, setPageState } = useModel("appModel");
+  const { showRightPanel, pageState, setPageState, rightPanelWidth } = useModel("appModel");
   const { setProjectInfo } = useModel("projectModel");
   const { initCells, updateKey } = useModel("graphModel");
 
@@ -118,7 +118,7 @@ export default function HomePage() {
               {/* 右侧配置表单 */}
               <Layout.Sider
                 className={styles.config}
-                width={showRightPanel ? 280 : 0}
+                width={showRightPanel ? rightPanelWidth : 0}
               >
                 <Config />
               </Layout.Sider>

+ 4 - 1
package.json

@@ -61,13 +61,16 @@
     "ai": "^4.1.26",
     "antd": "^5.23.0",
     "axios": "^1.7.7",
-    "bytemd": "^1.21.0",
     "dagre": "^0.8.5",
     "dayjs": "^1.11.13",
     "insert-css": "^2.0.0",
     "lodash-es": "^4.17.21",
     "mermaid": "^11.4.1",
     "react-draggable": "^4.4.6",
+    "react-markdown": "^9.0.3",
+    "react-syntax-highlighter": "^15.6.1",
+    "rehype-raw": "^7.0.0",
+    "remark-gfm": "^4.0.1",
     "thememirror": "^2.0.1",
     "umi": "^4.3.18",
     "unocss": "^0.62.3",

Файловите разлики са ограничени, защото са твърде много
+ 856 - 248
pnpm-lock.yaml