This is an automated email from the ASF dual-hosted git repository.

shuai pushed a commit to branch ai-ui
in repository https://gitbox.apache.org/repos/asf/answer.git

commit 611a7cf52caadb4cf6057fef6a995cfa9ffbb03c
Author: shuai <[email protected]>
AuthorDate: Fri Jan 16 16:30:47 2026 +0800

    feat: ai feature
---
 ui/package.json                                    |   1 +
 ui/pnpm-lock.yaml                                  |  13 +
 ui/src/common/constants.ts                         |   9 +
 ui/src/common/interface.ts                         |  56 +++
 ui/src/components/BubbleAi/index.tsx               | 240 +++++++++++
 ui/src/components/BubbleUser/index.scss            |   6 +
 ui/src/components/BubbleUser/index.tsx             |  18 +
 ui/src/components/Sender/index.scss                |  19 +
 ui/src/components/Sender/index.tsx                 | 153 ++++++++
 ui/src/components/SideNav/index.tsx                |  14 +-
 ui/src/components/index.ts                         |   6 +
 ui/src/pages/Admin/AiSettings/index.tsx            | 437 +++++++++++++++++++++
 .../Conversations/components/Action/index.tsx      |  71 ++++
 .../Conversations/components/DetailModal/index.tsx |  67 ++++
 ui/src/pages/Admin/Conversations/index.tsx         | 111 ++++++
 .../components/ConversationList/index.tsx          |  51 +++
 ui/src/pages/AiAssistant/index.tsx                 | 361 +++++++++++++++++
 ui/src/pages/Search/components/AiCard/index.tsx    | 170 ++++++++
 ui/src/pages/Search/components/index.ts            |   3 +-
 ui/src/pages/Search/index.tsx                      |   4 +
 .../index.tsx}                                     |  33 +-
 ui/src/router/routes.ts                            |  28 ++
 ui/src/services/admin/ai.ts                        |  68 ++++
 ui/src/services/admin/index.ts                     |   1 +
 ui/src/services/client/ai.ts                       |  21 +
 ui/src/services/client/index.ts                    |   1 +
 .../components/index.ts => stores/aiControl.ts}    |  27 +-
 ui/src/stores/index.ts                             |   2 +
 ui/src/utils/guard.ts                              |   4 +
 ui/src/utils/requestAi.ts                          | 300 ++++++++++++++
 30 files changed, 2279 insertions(+), 16 deletions(-)

diff --git a/ui/package.json b/ui/package.json
index b88fc12d..5abd241a 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -46,6 +46,7 @@
     "react-router-dom": "^7.0.2",
     "semver": "^7.3.8",
     "swr": "^1.3.0",
+    "uuid": "13.0.0",
     "zustand": "^5.0.2"
   },
   "devDependencies": {
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 89a37ab1..2ae03d03 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
       swr:
         specifier: ^1.3.0
         version: 1.3.0([email protected])
+      uuid:
+        specifier: 13.0.0
+        version: 13.0.0
       zustand:
         specifier: ^5.0.2
         version: 
5.0.2(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))
@@ -1608,24 +1611,28 @@ packages:
     engines: {node: '>=10'}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@swc/[email protected]':
     resolution: {integrity: 
sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==}
     engines: {node: '>=10'}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@swc/[email protected]':
     resolution: {integrity: 
sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==}
     engines: {node: '>=10'}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@swc/[email protected]':
     resolution: {integrity: 
sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==}
     engines: {node: '>=10'}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@swc/[email protected]':
     resolution: {integrity: 
sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==}
@@ -6690,6 +6697,10 @@ packages:
     resolution: {integrity: 
sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
     engines: {node: '>= 0.4.0'}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
+    hasBin: true
+
   [email protected]:
     resolution: {integrity: 
sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
     hasBin: true
@@ -14894,6 +14905,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts
index 18f25114..c32b26e3 100644
--- a/ui/src/common/constants.ts
+++ b/ui/src/common/constants.ts
@@ -94,6 +94,14 @@ export const ADMIN_NAV_MENUS = [
     icon: 'file-earmark-text-fill',
     children: [{ name: 'questions' }, { name: 'answers' }],
   },
+  {
+    name: 'AI',
+    icon: 'robot',
+    children: [
+      { name: 'conversations' },
+      { name: 'ai_settings', path: 'ai-settings' },
+    ],
+  },
   {
     name: 'users',
     icon: 'people-fill',
@@ -127,6 +135,7 @@ export const ADMIN_NAV_MENUS = [
       { name: 'seo' },
       { name: 'login' },
       { name: 'privileges' },
+      { name: 'mcp' },
     ],
   },
   {
diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts
index 3a77047e..920942d7 100644
--- a/ui/src/common/interface.ts
+++ b/ui/src/common/interface.ts
@@ -420,6 +420,7 @@ export interface SiteSettings {
   version: string;
   revision: string;
   site_legal: AdminSettingsLegal;
+  ai_enabled: boolean;
 }
 
 export interface AdminSettingBranding {
@@ -809,3 +810,58 @@ export interface BadgeDetailListRes {
   count: number;
   list: BadgeDetailListItem[];
 }
+
+export interface AiConfig {
+  enabled: boolean;
+  chosen_provider: string;
+  ai_providers: Array<{
+    provider: string;
+    api_host: string;
+    api_key: string;
+    model: string;
+  }>;
+}
+
+export interface AiProviderItem {
+  name: string;
+  display_name: string;
+  default_api_host: string;
+}
+
+export interface ConversationListItem {
+  conversation_id: string;
+  created_at: number;
+  topic: string;
+}
+
+export interface AdminConversationListItem {
+  id: string;
+  topic: string;
+  helpful_count: number;
+  unhelpful_count: number;
+  created_at: number;
+  user_info: UserInfoBase;
+}
+
+export interface ConversationDetailItem {
+  chat_completion_id: string;
+  content: string;
+  role: string;
+  helpful: number;
+  unhelpful: number;
+  created_at: number;
+}
+
+export interface ConversationDetail {
+  conversation_id: string;
+  created_at: number;
+  records: ConversationDetailItem[];
+  topic: string;
+  updated_at: number;
+}
+
+export interface VoteConversationParams {
+  cancel: boolean;
+  chat_completion_id: string;
+  vote_type: 'helpful' | 'unhelpful';
+}
diff --git a/ui/src/components/BubbleAi/index.tsx 
b/ui/src/components/BubbleAi/index.tsx
new file mode 100644
index 00000000..6c03580c
--- /dev/null
+++ b/ui/src/components/BubbleAi/index.tsx
@@ -0,0 +1,240 @@
+import { FC, useEffect, useState, useRef } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { marked } from 'marked';
+import copy from 'copy-to-clipboard';
+
+import { voteConversation } from '@/enterprise/services';
+import { Icon, htmlRender } from '@/components';
+
+interface IProps {
+  canType?: boolean;
+  chatId: string;
+  isLast: boolean;
+  isCompleted: boolean;
+  content: string;
+  minHeight?: number;
+  actionData: {
+    helpful: number;
+    unhelpful: number;
+  };
+}
+
+const BubbleAi: FC<IProps> = ({
+  canType = false,
+  isLast,
+  isCompleted,
+  content,
+  chatId = '',
+  actionData,
+  minHeight = 0,
+}) => {
+  const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
+  const [displayContent, setDisplayContent] = useState('');
+  const [copyText, setCopyText] = useState<string>(t('copy'));
+  const [isHelpful, setIsHelpful] = useState(false);
+  const [isUnhelpful, setIsUnhelpful] = useState(false);
+  const [canShowAction, setCanShowAction] = useState(false);
+  const typewriterRef = useRef<{
+    timer: NodeJS.Timeout | null;
+    index: number;
+    isTyping: boolean;
+  }>({
+    timer: null,
+    index: 0,
+    isTyping: false,
+  });
+  const fmtContainer = useRef<HTMLDivElement>(null);
+  // 添加ref用于ScrollIntoView
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const handleCopy = () => {
+    const res = copy(displayContent);
+    if (res) {
+      setCopyText(t('copied', { keyPrefix: 'messages' }));
+      setTimeout(() => {
+        setCopyText(t('copy'));
+      }, 1200);
+    }
+  };
+
+  const handleVote = (voteType: 'helpful' | 'unhelpful') => {
+    const isCancel =
+      (voteType === 'helpful' && isHelpful) ||
+      (voteType === 'unhelpful' && isUnhelpful);
+    voteConversation({
+      chat_completion_id: chatId,
+      cancel: isCancel,
+      vote_type: voteType,
+    }).then(() => {
+      setIsHelpful(voteType === 'helpful' && !isCancel);
+      setIsUnhelpful(voteType === 'unhelpful' && !isCancel);
+    });
+  };
+
+  useEffect(() => {
+    if ((!canType || !isLast) && content) {
+      // 如果不是最后一个消息,直接返回,不进行打字效果
+      if (typewriterRef.current.timer) {
+        clearInterval(typewriterRef.current.timer);
+        typewriterRef.current.timer = null;
+      }
+      setDisplayContent(content);
+      setCanShowAction(true);
+      typewriterRef.current.timer = null;
+      typewriterRef.current.isTyping = false;
+      return;
+    }
+    // 当内容变化时,清理之前的计时器
+    if (typewriterRef.current.timer) {
+      clearInterval(typewriterRef.current.timer);
+      typewriterRef.current.timer = null;
+    }
+
+    // 如果内容为空,则直接返回
+    if (!content) {
+      setDisplayContent('');
+      return;
+    }
+
+    // 如果内容比当前显示的短,则重置
+    if (content.length < displayContent.length) {
+      setDisplayContent('');
+      typewriterRef.current.index = 0;
+    }
+
+    // 如果内容与显示内容相同,不需要做任何事
+    if (content === displayContent) {
+      return;
+    }
+
+    typewriterRef.current.isTyping = true;
+
+    // start typing animation
+    typewriterRef.current.timer = setInterval(() => {
+      const currentIndex = typewriterRef.current.index;
+      if (currentIndex < content.length) {
+        const remainingLength = content.length - currentIndex;
+        const baseRandomNum = Math.floor(Math.random() * 3) + 2;
+        let randomNum = Math.min(baseRandomNum, remainingLength);
+
+        // 简单的单词边界检查(可选)
+        const nextChar = content[currentIndex + randomNum];
+        const prevChar = content[currentIndex + randomNum - 1];
+
+        // 如果下一个字符是字母,当前字符也是字母,尝试调整到空格处
+        if (
+          nextChar &&
+          /[a-zA-Z]/.test(nextChar) &&
+          /[a-zA-Z]/.test(prevChar)
+        ) {
+          // 向前找1-2个字符,看看有没有空格
+          for (
+            let i = 1;
+            i <= 2 && currentIndex + randomNum - i > currentIndex;
+            i += 1
+          ) {
+            if (content[currentIndex + randomNum - i] === ' ') {
+              randomNum = randomNum - i + 1;
+              break;
+            }
+          }
+          // 向后找1-2个字符,看看有没有空格
+          for (
+            let i = 1;
+            i <= 2 && currentIndex + randomNum + i < content.length;
+            i += 1
+          ) {
+            if (content[currentIndex + randomNum + i] === ' ') {
+              randomNum = randomNum + i + 1;
+              break;
+            }
+          }
+        }
+
+        const nextIndex = currentIndex + randomNum;
+        const newContent = content.substring(0, nextIndex);
+        setDisplayContent(newContent);
+        typewriterRef.current.index = nextIndex;
+        setCanShowAction(false);
+      } else {
+        clearInterval(typewriterRef.current.timer as NodeJS.Timeout);
+        typewriterRef.current.timer = null;
+        typewriterRef.current.isTyping = false;
+        setCanShowAction(false);
+      }
+    }, 30);
+
+    // eslint-disable-next-line consistent-return
+    return () => {
+      if (typewriterRef.current.timer) {
+        clearInterval(typewriterRef.current.timer);
+        typewriterRef.current.timer = null;
+      }
+    };
+  }, [content, isCompleted]);
+
+  useEffect(() => {
+    setIsHelpful(actionData.helpful > 0);
+    setIsUnhelpful(actionData.unhelpful > 0);
+  }, [actionData]);
+
+  useEffect(() => {
+    if (fmtContainer.current && isCompleted) {
+      htmlRender(fmtContainer.current, {
+        copySuccessText: t('copied', { keyPrefix: 'messages' }),
+        copyText: t('copy', { keyPrefix: 'messages' }),
+      });
+      const links = fmtContainer.current.querySelectorAll('a');
+      links.forEach((link) => {
+        link.setAttribute('target', '_blank');
+      });
+      setCanShowAction(true);
+    }
+  }, [isCompleted, fmtContainer.current]);
+
+  return (
+    <div
+      className="rounded bubble-ai"
+      ref={containerRef}
+      style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}>
+      <div id={chatId}>
+        <div
+          className="fmt text-break text-wrap"
+          ref={fmtContainer}
+          style={{ transition: 'all 0.2s ease' }}
+          dangerouslySetInnerHTML={{ __html: marked.parse(displayContent) }}
+        />
+
+        {canShowAction && (
+          <div className="action">
+            <Button
+              variant="link"
+              className="p-0 link-secondary small me-3"
+              onClick={handleCopy}>
+              <Icon name="copy" />
+              <span className="ms-1">{copyText}</span>
+            </Button>
+            <Button
+              variant="link"
+              className={`p-0 small me-3 ${isHelpful ? 'link-primary active' : 
'link-secondary'}`}
+              onClick={() => handleVote('helpful')}>
+              <Icon name="hand-thumbs-up-fill" />
+              <span className="ms-1">Helpful</span>
+            </Button>
+            <Button
+              variant="link"
+              className={`p-0 small me-3 ${isUnhelpful ? 'link-primary active' 
: 'link-secondary'}`}
+              onClick={() => handleVote('unhelpful')}>
+              <Icon name="hand-thumbs-down-fill" />
+              <span className="ms-1">Unhelpful</span>
+            </Button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default BubbleAi;
diff --git a/ui/src/components/BubbleUser/index.scss 
b/ui/src/components/BubbleUser/index.scss
new file mode 100644
index 00000000..c944c413
--- /dev/null
+++ b/ui/src/components/BubbleUser/index.scss
@@ -0,0 +1,6 @@
+.bubble-user-wrap {
+  scroll-margin-top: 88px;
+}
+.bubble-user {
+  background-color: var(--bs-gray-200);
+}
diff --git a/ui/src/components/BubbleUser/index.tsx 
b/ui/src/components/BubbleUser/index.tsx
new file mode 100644
index 00000000..dd68c084
--- /dev/null
+++ b/ui/src/components/BubbleUser/index.tsx
@@ -0,0 +1,18 @@
+import { FC } from 'react';
+import './index.scss';
+
+interface BubbleUserProps {
+  content?: string;
+}
+
+const BubbleUser: FC<BubbleUserProps> = ({ content }) => {
+  return (
+    <div className="text-end bubble-user-wrap">
+      <div className="d-inline-block text-start bubble-user p-3 rounded 
pre-line">
+        {content}
+      </div>
+    </div>
+  );
+};
+
+export default BubbleUser;
diff --git a/ui/src/components/Sender/index.scss 
b/ui/src/components/Sender/index.scss
new file mode 100644
index 00000000..64788993
--- /dev/null
+++ b/ui/src/components/Sender/index.scss
@@ -0,0 +1,19 @@
+.sender-wrap {
+  z-index: 10;
+  margin-top: auto;
+  background-color: var(--bs-body-bg);
+  .input {
+    resize: none;
+    overflow-y: auto;
+    scrollbar-width: thin;
+  }
+  .input:focus {
+    box-shadow: none !important;
+    border-width: 0 !important;
+  }
+
+  .form-control-focus {
+    box-shadow: 0 0 0 0.25rem rgba(0, 51, 255, 0.25) !important;
+    border-color: rgb(128, 153, 255) !important;
+  }
+}
diff --git a/ui/src/components/Sender/index.tsx 
b/ui/src/components/Sender/index.tsx
new file mode 100644
index 00000000..9ce342a2
--- /dev/null
+++ b/ui/src/components/Sender/index.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useState, useRef, FC } from 'react';
+import { Form, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import classnames from 'classnames';
+
+import { Icon } from '@/components';
+
+import './index.scss';
+
+interface IProps {
+  onSubmit?: (value: string) => void;
+  onCancel?: () => void;
+  isGenerate: boolean;
+  hasConversation: boolean;
+}
+
+const Sender: FC<IProps> = ({
+  onSubmit,
+  onCancel,
+  isGenerate,
+  hasConversation,
+}) => {
+  const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
+  const containerRef = useRef<HTMLDivElement>(null);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+  const [initialized, setInitialized] = useState(false);
+  const [inputValue, setInputValue] = useState('');
+  const [isFocus, setIsFocus] = useState(false);
+
+  const handleFocus = () => {
+    setIsFocus(true);
+    textareaRef?.current?.focus();
+  };
+
+  const handleBlur = () => {
+    setIsFocus(false);
+  };
+
+  const autoResize = () => {
+    const textarea = textareaRef.current;
+    if (!textarea) return;
+
+    textarea.style.height = '32px';
+
+    const minHeight = 32; // 最小高度
+    const maxHeight = 96; // 最大高度
+
+    // 计算需要的高度
+    const { scrollHeight } = textarea;
+    const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
+
+    // 设置新高度
+    textarea.style.height = `${newHeight}px`;
+
+    // 控制滚动条显示
+    if (scrollHeight > maxHeight) {
+      textarea.style.overflowY = 'auto';
+    } else {
+      textarea.style.overflowY = 'hidden';
+    }
+  };
+
+  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+    setInputValue(e.target.value);
+    setTimeout(autoResize, 0);
+  };
+
+  const handleSubmit = () => {
+    if (isGenerate || !inputValue.trim()) {
+      return;
+    }
+    onSubmit?.(inputValue);
+    setInputValue('');
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault(); // Prevent default behavior of Enter key
+      handleSubmit();
+    } else if (e.key === 'Escape') {
+      setInputValue((prev) => `${prev}\n`); // Add a new line on Escape key
+    }
+  };
+
+  useEffect(() => {
+    setInitialized(true);
+  }, []);
+
+  useEffect(() => {
+    const handleOutsideClick = (event) => {
+      if (
+        initialized &&
+        containerRef.current &&
+        !containerRef.current?.contains(event.target)
+      ) {
+        handleBlur();
+      }
+    };
+    document.addEventListener('click', handleOutsideClick);
+    return () => {
+      document.removeEventListener('click', handleOutsideClick);
+    };
+  }, [initialized]);
+  return (
+    <div
+      className={classnames(
+        'sender-wrap',
+        hasConversation ? 'sticky-bottom pb-4' : 'mt-0',
+      )}
+      ref={containerRef}>
+      <div
+        onClick={handleFocus}
+        className={classnames(
+          'position-relative form-control p-3',
+          isFocus ? 'form-control-focus' : '',
+        )}>
+        <Form.Control
+          as="textarea"
+          ref={textareaRef}
+          style={{ height: '32px' }}
+          className="input border-0 p-0"
+          placeholder={t('ask_placeholder')}
+          value={inputValue}
+          onFocus={handleFocus}
+          onChange={handleInputChange}
+          onKeyDown={handleKeyDown}
+        />
+        <div className="clearfix tools">
+          {isGenerate ? (
+            <Button
+              variant="link"
+              onClick={onCancel}
+              className="p-0 lh-1 link-dark float-end">
+              <Icon name="stop-circle-fill" size="24px" />
+            </Button>
+          ) : (
+            <Button
+              variant="link"
+              className="p-0 lh-1 link-dark float-end"
+              onClick={handleSubmit}>
+              <Icon name="arrow-up-circle-fill" size="24px" />
+            </Button>
+          )}
+        </div>
+      </div>
+
+      <Form.Text className="text-center d-block">{t('ai_generate')}</Form.Text>
+    </div>
+  );
+};
+
+export default Sender;
diff --git a/ui/src/components/SideNav/index.tsx 
b/ui/src/components/SideNav/index.tsx
index 294389a0..54d2f6a4 100644
--- a/ui/src/components/SideNav/index.tsx
+++ b/ui/src/components/SideNav/index.tsx
@@ -22,7 +22,7 @@ import { Nav } from 'react-bootstrap';
 import { NavLink, useLocation, useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 
-import { loggedUserInfoStore, sideNavStore } from '@/stores';
+import { loggedUserInfoStore, sideNavStore, aiControlStore } from '@/stores';
 import { Icon, PluginRender } from '@/components';
 import { PluginType } from '@/utils/pluginKit';
 import request from '@/utils/request';
@@ -34,6 +34,7 @@ const Index: FC = () => {
   const { pathname } = useLocation();
   const { user: userInfo } = loggedUserInfoStore();
   const { can_revision, revision } = sideNavStore();
+  const { ai_enabled } = aiControlStore();
   const navigate = useNavigate();
 
   return (
@@ -47,6 +48,17 @@ const Index: FC = () => {
         <span>{t('header.nav.question')}</span>
       </NavLink>
 
+      {ai_enabled && (
+        <NavLink
+          to="/ai-assistant"
+          className={() =>
+            pathname === '/ai-assistant' ? 'nav-link active' : 'nav-link'
+          }>
+          <Icon name="chat-square-text-fill" className="me-2" />
+          <span>{t('ai_assistant', { keyPrefix: 'page_title' })}</span>
+        </NavLink>
+      )}
+
       <NavLink
         to="/tags"
         className={() =>
diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts
index 68e863d2..e8afa157 100644
--- a/ui/src/components/index.ts
+++ b/ui/src/components/index.ts
@@ -64,6 +64,9 @@ import CardBadge from './CardBadge';
 import PinList from './PinList';
 import MobileSideNav from './MobileSideNav';
 import AdminSideNav from './AdminSideNav';
+import BubbleAi from './BubbleAi';
+import BubbleUser from './BubbleUser';
+import Sender from './Sender';
 
 export {
   Avatar,
@@ -115,5 +118,8 @@ export {
   PinList,
   MobileSideNav,
   AdminSideNav,
+  BubbleAi,
+  BubbleUser,
+  Sender,
 };
 export type { EditorRef, JSONSchema, UISchema };
diff --git a/ui/src/pages/Admin/AiSettings/index.tsx 
b/ui/src/pages/Admin/AiSettings/index.tsx
new file mode 100644
index 00000000..368b01a0
--- /dev/null
+++ b/ui/src/pages/Admin/AiSettings/index.tsx
@@ -0,0 +1,437 @@
+import { useEffect, useState, useRef } from 'react';
+import { Form, InputGroup, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import {
+  getAiConfig,
+  useQueryAiProvider,
+  checkAiConfig,
+  saveAiConfig,
+} from '@/services';
+import { aiControlStore } from '@/stores';
+import { handleFormError } from '@/utils';
+import { useToast } from '@/hooks';
+import * as Type from '@/common/interface';
+
+const Index = () => {
+  const { t } = useTranslation('translation', {
+    keyPrefix: 'admin.ai_settings',
+  });
+  const toast = useToast();
+  const historyConfigRef = useRef<Type.AiConfig>();
+  // const [historyConfig, setHistoryConfig] = useState<Type.AiConfig>();
+  const { data: aiProviders } = useQueryAiProvider();
+
+  const [formData, setFormData] = useState({
+    enabled: {
+      value: false,
+      isInvalid: false,
+      errorMsg: '',
+    },
+    provider: {
+      value: '',
+      isInvalid: false,
+      errorMsg: '',
+    },
+    api_host: {
+      value: '',
+      isInvalid: false,
+      errorMsg: '',
+    },
+    api_key: {
+      value: '',
+      isInvalid: false,
+      isValid: false,
+      errorMsg: '',
+    },
+    model: {
+      value: '',
+      isInvalid: false,
+      errorMsg: '',
+    },
+  });
+  const [apiHostPlaceholder, setApiHostPlaceholder] = useState('');
+  const [modelsData, setModels] = useState<{ id: string }[]>([]);
+  const [isChecking, setIsChecking] = useState(false);
+
+  const getCurrentProviderData = (provider) => {
+    const findHistoryProvider =
+      historyConfigRef.current?.ai_providers.find(
+        (v) => v.provider === provider,
+      ) || historyConfigRef.current?.ai_providers[0];
+
+    return findHistoryProvider;
+  };
+
+  const checkAiConfigData = (data) => {
+    const params = data || {
+      api_host: formData.api_host.value || apiHostPlaceholder,
+      api_key: formData.api_key.value,
+    };
+    setIsChecking(true);
+
+    checkAiConfig(params)
+      .then((res) => {
+        setModels(res);
+        const findHistoryProvider = getCurrentProviderData(
+          formData.provider.value,
+        );
+
+        setIsChecking(false);
+        if (!data) {
+          setFormData({
+            ...formData,
+            api_key: {
+              ...formData.api_key,
+              errorMsg: t('api_key.check_success'),
+              isInvalid: false,
+              isValid: true,
+            },
+            model: {
+              value: findHistoryProvider?.model || res[0].id,
+              errorMsg: '',
+              isInvalid: false,
+            },
+          });
+        }
+      })
+      .catch((err) => {
+        console.error('Checking AI config:', err);
+        setIsChecking(false);
+      });
+  };
+
+  const handleProviderChange = (value) => {
+    const findHistoryProvider = getCurrentProviderData(value);
+    setFormData({
+      ...formData,
+      provider: {
+        value,
+        isInvalid: false,
+        errorMsg: '',
+      },
+      api_host: {
+        value: findHistoryProvider?.api_host || '',
+        isInvalid: false,
+        errorMsg: '',
+      },
+      api_key: {
+        value: findHistoryProvider?.api_key || '',
+        isInvalid: false,
+        isValid: false,
+        errorMsg: '',
+      },
+      model: {
+        value: findHistoryProvider?.model || '',
+        isInvalid: false,
+        errorMsg: '',
+      },
+    });
+    const provider = aiProviders?.find((item) => item.name === value);
+    const host = findHistoryProvider?.api_host || provider?.default_api_host;
+    if (findHistoryProvider?.model) {
+      checkAiConfigData({
+        api_host: host,
+        api_key: findHistoryProvider.api_key,
+      });
+    } else {
+      setModels([]);
+    }
+  };
+
+  const handleValueChange = (value) => {
+    setFormData((prev) => ({
+      ...prev,
+      ...value,
+    }));
+  };
+
+  const checkValidate = () => {
+    let bol = true;
+
+    const { api_host, api_key, model } = formData;
+
+    if (!api_host.value) {
+      bol = false;
+      formData.api_host = {
+        value: '',
+        isInvalid: true,
+        errorMsg: t('api_host.msg'),
+      };
+    }
+
+    if (!api_key.value) {
+      bol = false;
+      formData.api_key = {
+        value: '',
+        isInvalid: true,
+        isValid: false,
+        errorMsg: t('api_key.msg'),
+      };
+    }
+
+    if (!model.value) {
+      bol = false;
+      formData.model = {
+        value: '',
+        isInvalid: true,
+        errorMsg: t('model.msg'),
+      };
+    }
+
+    setFormData({
+      ...formData,
+    });
+
+    return bol;
+  };
+
+  const handleSubmit = (e) => {
+    e.preventDefault();
+    if (!checkValidate()) {
+      return;
+    }
+    const newProviders = historyConfigRef.current?.ai_providers.map((v) => {
+      if (v.provider === formData.provider.value) {
+        return {
+          provider: formData.provider.value,
+          api_host: formData.api_host.value,
+          api_key: formData.api_key.value,
+          model: formData.model.value,
+        };
+      }
+      return v;
+    });
+
+    const params = {
+      enabled: formData.enabled.value,
+      chosen_provider: formData.provider.value,
+      ai_providers: newProviders,
+    };
+    saveAiConfig(params)
+      .then(() => {
+        aiControlStore.getState().update({
+          ai_enabled: formData.enabled.value,
+        });
+
+        historyConfigRef.current = {
+          ...params,
+          ai_providers: params.ai_providers || [],
+        };
+
+        toast.onShow({
+          msg: t('add_success'),
+          variant: 'success',
+        });
+      })
+      .catch((err) => {
+        const data = handleFormError(err, formData);
+        setFormData({ ...data });
+        const ele = document.getElementById(err.list[0].error_field);
+        ele?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      });
+  };
+
+  const getAiConfigData = async () => {
+    const aiConfig = await getAiConfig();
+    historyConfigRef.current = aiConfig;
+
+    const currentAiConfig = getCurrentProviderData(aiConfig.chosen_provider);
+    if (currentAiConfig?.model) {
+      const provider = aiProviders?.find(
+        (item) => item.name === formData.provider.value,
+      );
+      const host = currentAiConfig.api_host || provider?.default_api_host;
+      checkAiConfigData({
+        api_host: host,
+        api_key: currentAiConfig.api_key,
+      });
+    }
+
+    setFormData({
+      enabled: {
+        value: aiConfig.enabled || false,
+        isInvalid: false,
+        errorMsg: '',
+      },
+      provider: {
+        value: currentAiConfig?.provider || '',
+        isInvalid: false,
+        errorMsg: '',
+      },
+      api_host: {
+        value: currentAiConfig?.api_host || '',
+        isInvalid: false,
+        errorMsg: '',
+      },
+      api_key: {
+        value: currentAiConfig?.api_key || '',
+        isInvalid: false,
+        isValid: false,
+        errorMsg: '',
+      },
+      model: {
+        value: currentAiConfig?.model || '',
+        isInvalid: false,
+        errorMsg: '',
+      },
+    });
+  };
+
+  useEffect(() => {
+    getAiConfigData();
+  }, []);
+
+  useEffect(() => {
+    if (formData.provider.value) {
+      const provider = aiProviders?.find(
+        (item) => item.name === formData.provider.value,
+      );
+      if (provider) {
+        setApiHostPlaceholder(provider.default_api_host || '');
+      }
+    }
+    if (!formData.provider.value && aiProviders) {
+      setFormData((prev) => ({
+        ...prev,
+        provider: {
+          ...prev.provider,
+          value: aiProviders[0].name,
+        },
+      }));
+    }
+  }, [aiProviders, formData]);
+
+  return (
+    <div>
+      <h3 className="mb-4">{t('title')}</h3>
+      <Form noValidate onSubmit={handleSubmit}>
+        <Form.Group className="mb-3" controlId="enabled">
+          <Form.Label>{t('enabled.label')}</Form.Label>
+          <Form.Switch
+            type="switch"
+            id="enabled"
+            label={t('enabled.check')}
+            checked={formData.enabled.value}
+            onChange={(e) =>
+              handleValueChange({
+                enabled: {
+                  value: e.target.checked,
+                  errorMsg: '',
+                  isInvalid: false,
+                },
+              })
+            }
+          />
+          <Form.Control.Feedback type="invalid">
+            {formData.enabled.errorMsg}
+          </Form.Control.Feedback>
+        </Form.Group>
+
+        <Form.Group className="mb-3" controlId="provider">
+          <Form.Label>{t('provider.label')}</Form.Label>
+          <Form.Select
+            isInvalid={formData.provider.isInvalid}
+            value={formData.provider.value}
+            onChange={(e) => handleProviderChange(e.target.value)}>
+            {aiProviders?.map((provider) => (
+              <option key={provider.name} value={provider.name}>
+                {provider.display_name}
+              </option>
+            ))}
+          </Form.Select>
+          <Form.Control.Feedback type="invalid">
+            {formData.provider.errorMsg}
+          </Form.Control.Feedback>
+        </Form.Group>
+
+        <Form.Group className="mb-3" controlId="api_host">
+          <Form.Label>{t('api_host.label')}</Form.Label>
+          <Form.Control
+            type="text"
+            autoComplete="off"
+            placeholder={apiHostPlaceholder}
+            isInvalid={formData.api_host.isInvalid}
+            value={formData.api_host.value}
+            onChange={(e) =>
+              handleValueChange({
+                api_host: {
+                  value: e.target.value,
+                  errorMsg: '',
+                  isInvalid: false,
+                },
+              })
+            }
+          />
+          <Form.Control.Feedback type="invalid">
+            {formData.api_host.errorMsg}
+          </Form.Control.Feedback>
+        </Form.Group>
+
+        <Form.Group className="mb-3" controlId="api_key">
+          <Form.Label>{t('api_key.label')}</Form.Label>
+          <InputGroup>
+            <Form.Control
+              type="password"
+              autoComplete="new-password"
+              isInvalid={formData.api_key.isInvalid}
+              isValid={formData.api_key.isValid}
+              value={formData.api_key.value}
+              onChange={(e) =>
+                handleValueChange({
+                  api_key: {
+                    value: e.target.value,
+                    errorMsg: '',
+                    isInvalid: false,
+                    isValid: false,
+                  },
+                })
+              }
+            />
+            <Button
+              variant="outline-secondary"
+              className="rounded-end"
+              onClick={() => checkAiConfigData(null)}
+              disabled={isChecking}>
+              {t('api_key.check')}
+            </Button>
+            <Form.Control.Feedback
+              type={formData.api_key.isValid ? 'valid' : 'invalid'}>
+              {formData.api_key.errorMsg}
+            </Form.Control.Feedback>
+          </InputGroup>
+        </Form.Group>
+
+        <Form.Group className="mb-3" controlId="model">
+          <Form.Label>{t('model.label')}</Form.Label>
+          <Form.Select
+            isInvalid={formData.model.isInvalid}
+            value={formData.model.value}
+            onChange={(e) =>
+              handleValueChange({
+                model: {
+                  value: e.target.value,
+                  errorMsg: '',
+                  isInvalid: false,
+                },
+              })
+            }>
+            {modelsData?.map((model) => {
+              return (
+                <option key={model.id} value={model.id}>
+                  {model.id}
+                </option>
+              );
+            })}
+          </Form.Select>
+          <Form.Control.Feedback type="invalid">
+            {formData.model.errorMsg}
+          </Form.Control.Feedback>
+        </Form.Group>
+
+        <Button type="submit">{t('save', { keyPrefix: 'btns' })}</Button>
+      </Form>
+    </div>
+  );
+};
+export default Index;
diff --git a/ui/src/pages/Admin/Conversations/components/Action/index.tsx 
b/ui/src/pages/Admin/Conversations/components/Action/index.tsx
new file mode 100644
index 00000000..577d16c3
--- /dev/null
+++ b/ui/src/pages/Admin/Conversations/components/Action/index.tsx
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Dropdown } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { Modal, Icon } from '@/components';
+import { deleteAdminConversation } from '@/enterprise/services';
+import { useToast } from '@/hooks';
+
+interface Props {
+  id: string;
+  refreshList?: () => void;
+}
+const ConversationsOperation = ({ id, refreshList }: Props) => {
+  const { t } = useTranslation('translation', {
+    keyPrefix: 'admin.conversations',
+  });
+  const toast = useToast();
+
+  const handleAction = (eventKey: string | null) => {
+    if (eventKey === 'delete') {
+      Modal.confirm({
+        title: t('delete_modal.title'),
+        content: t('delete_modal.content'),
+        cancelBtnVariant: 'link',
+        confirmBtnVariant: 'danger',
+        confirmText: t('delete', { keyPrefix: 'btns' }),
+        onConfirm: () => {
+          deleteAdminConversation(id).then(() => {
+            refreshList?.();
+            toast.onShow({
+              variant: 'success',
+              msg: t('delete_modal.delete_success'),
+            });
+          });
+        },
+      });
+    }
+  };
+
+  return (
+    <Dropdown onSelect={handleAction}>
+      <Dropdown.Toggle variant="link" className="no-toggle p-0 lh-1">
+        <Icon name="three-dots-vertical" title={t('action')} />
+      </Dropdown.Toggle>
+      <Dropdown.Menu align="end">
+        <Dropdown.Item eventKey="delete">
+          {t('delete', { keyPrefix: 'btns' })}
+        </Dropdown.Item>
+      </Dropdown.Menu>
+    </Dropdown>
+  );
+};
+
+export default ConversationsOperation;
diff --git a/ui/src/pages/Admin/Conversations/components/DetailModal/index.tsx 
b/ui/src/pages/Admin/Conversations/components/DetailModal/index.tsx
new file mode 100644
index 00000000..2d408c36
--- /dev/null
+++ b/ui/src/pages/Admin/Conversations/components/DetailModal/index.tsx
@@ -0,0 +1,67 @@
+import { FC, memo } from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { BubbleAi, BubbleUser } from '@/enterprise/components';
+import { useQueryAdminConversationDetail } from '@/enterprise/services';
+
+interface IProps {
+  visible: boolean;
+  id: string;
+  onClose?: () => void;
+}
+
+const Index: FC<IProps> = ({ visible, id, onClose }) => {
+  const { t } = useTranslation('translation', {
+    keyPrefix: 'admin.conversations',
+  });
+
+  const { data: conversationDetail } = useQueryAdminConversationDetail(id);
+
+  const handleClose = () => {
+    onClose?.();
+  };
+  return (
+    <Modal show={visible} size="lg" centered onHide={handleClose}>
+      <Modal.Header closeButton>
+        <div style={{ maxWidth: '85%' }} className="text-truncate">
+          {conversationDetail?.topic}
+        </div>
+      </Modal.Header>
+      <Modal.Body className="overflow-y-auto" style={{ maxHeight: '70vh' }}>
+        {conversationDetail?.records.map((item, index) => {
+          const isLastMessage =
+            index === Number(conversationDetail?.records.length) - 1;
+          return (
+            <div
+              key={`${item.chat_completion_id}-${item.role}`}
+              className={`${isLastMessage ? '' : 'mb-4'}`}>
+              {item.role === 'user' ? (
+                <BubbleUser content={item.content} />
+              ) : (
+                <BubbleAi
+                  canType={false}
+                  chatId={item.chat_completion_id}
+                  isLast={false}
+                  isCompleted
+                  content={item.content}
+                  actionData={{
+                    helpful: item.helpful,
+                    unhelpful: item.unhelpful,
+                  }}
+                />
+              )}
+            </div>
+          );
+        })}
+      </Modal.Body>
+      <Modal.Footer>
+        <Button variant="link" onClick={handleClose}>
+          {t('close', { keyPrefix: 'btns' })}
+        </Button>
+      </Modal.Footer>
+    </Modal>
+  );
+};
+
+export default memo(Index);
diff --git a/ui/src/pages/Admin/Conversations/index.tsx 
b/ui/src/pages/Admin/Conversations/index.tsx
new file mode 100644
index 00000000..ed1ee333
--- /dev/null
+++ b/ui/src/pages/Admin/Conversations/index.tsx
@@ -0,0 +1,111 @@
+import { useState } from 'react';
+import { Table, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { useSearchParams } from 'react-router-dom';
+
+import { BaseUserCard, FormatTime, Pagination, Empty } from '@/components';
+import { useQueryAdminConversationList } from '@/services';
+
+import DetailModal from './components/DetailModal';
+import Action from './components/Action';
+
+const Index = () => {
+  const { t } = useTranslation('translation', {
+    keyPrefix: 'admin.conversations',
+  });
+  const [urlSearchParams] = useSearchParams();
+  const curPage = Number(urlSearchParams.get('page') || '1');
+  const PAGE_SIZE = 20;
+  const [detailModal, setDetailModal] = useState({
+    visible: false,
+    id: '',
+  });
+  const {
+    data: conversations,
+    isLoading,
+    mutate: refreshList,
+  } = useQueryAdminConversationList({
+    page: curPage,
+    page_size: PAGE_SIZE,
+  });
+
+  const handleShowDetailModal = (data) => {
+    setDetailModal({
+      visible: true,
+      id: data.id,
+    });
+  };
+
+  const handleHideDetailModal = () => {
+    setDetailModal({
+      visible: false,
+      id: '',
+    });
+  };
+
+  return (
+    <div className="d-flex flex-column flex-grow-1 position-relative">
+      <h3 className="mb-4">{t('title')}</h3>
+      <Table responsive="md">
+        <thead>
+          <tr>
+            <th className="min-w-15">{t('topic')}</th>
+            <th style={{ width: '10%' }}>{t('helpful')}</th>
+            <th style={{ width: '10%' }}>{t('unhelpful')}</th>
+            <th style={{ width: '20%' }}>{t('created')}</th>
+            <th style={{ width: '10%' }} className="text-end">
+              {t('action')}
+            </th>
+          </tr>
+        </thead>
+        <tbody className="align-middle">
+          {conversations?.list.map((item) => {
+            return (
+              <tr key={item.id}>
+                <td>
+                  <Button
+                    variant="link"
+                    className="p-0 text-decoration-none text-truncate max-w-30"
+                    onClick={() => handleShowDetailModal(item)}>
+                    {item.topic}
+                  </Button>
+                </td>
+                <td>{item.helpful_count}</td>
+                <td>{item.unhelpful_count}</td>
+                <td>
+                  <div className="vstack">
+                    <BaseUserCard data={item.user_info} avatarSize="20px" />
+                    <FormatTime
+                      className="small text-secondary"
+                      time={item.created_at}
+                    />
+                  </div>
+                </td>
+                <td className="text-end">
+                  <Action id={item.id} refreshList={refreshList} />
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </Table>
+      {!isLoading && Number(conversations?.count) <= 0 && (
+        <Empty>{t('empty')}</Empty>
+      )}
+
+      <div className="mt-4 mb-2 d-flex justify-content-center">
+        <Pagination
+          currentPage={curPage}
+          totalSize={conversations?.count || 0}
+          pageSize={PAGE_SIZE}
+        />
+      </div>
+      <DetailModal
+        visible={detailModal.visible}
+        id={detailModal.id}
+        onClose={handleHideDetailModal}
+      />
+    </div>
+  );
+};
+export default Index;
diff --git a/ui/src/pages/AiAssistant/components/ConversationList/index.tsx 
b/ui/src/pages/AiAssistant/components/ConversationList/index.tsx
new file mode 100644
index 00000000..29cf4cf3
--- /dev/null
+++ b/ui/src/pages/AiAssistant/components/ConversationList/index.tsx
@@ -0,0 +1,51 @@
+import { FC, memo } from 'react';
+import { Card, ListGroup } from 'react-bootstrap';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+interface ConversationListItem {
+  conversation_id: string;
+  topic: string;
+}
+
+interface IProps {
+  data: {
+    count: number;
+    list: ConversationListItem[];
+  };
+  loadMore: (e: React.MouseEvent<HTMLAnchorElement>) => void;
+}
+
+const Index: FC<IProps> = ({ data, loadMore }) => {
+  const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
+
+  if (Number(data?.list.length) <= 0) return null;
+  return (
+    <Card>
+      <Card.Header>
+        <span>{t('recent_conversations')}</span>
+      </Card.Header>
+      <ListGroup variant="flush">
+        {data?.list.map((item) => {
+          return (
+            <ListGroup.Item
+              as={Link}
+              action
+              key={item.conversation_id}
+              to={`/ai-assistant/${item.conversation_id}`}
+              className="text-truncate">
+              {item.topic}
+            </ListGroup.Item>
+          );
+        })}
+        {Number(data?.count) > data?.list.length && (
+          <ListGroup.Item action onClick={loadMore} className="link-primary">
+            {t('show_more')}
+          </ListGroup.Item>
+        )}
+      </ListGroup>
+    </Card>
+  );
+};
+
+export default memo(Index);
diff --git a/ui/src/pages/AiAssistant/index.tsx 
b/ui/src/pages/AiAssistant/index.tsx
new file mode 100644
index 00000000..cad6d478
--- /dev/null
+++ b/ui/src/pages/AiAssistant/index.tsx
@@ -0,0 +1,361 @@
+import { useEffect, useState } from 'react';
+import { Row, Col, Spinner, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { useParams, useNavigate } from 'react-router-dom';
+
+import classNames from 'classnames';
+import { v4 as uuidv4 } from 'uuid';
+
+import * as Type from '@/common/interface';
+import requestAi, { cancelCurrentRequest } from '@/utils/requestAi';
+import { Sender, BubbleUser, BubbleAi, Icon } from '@/components';
+import { getConversationDetail, getConversationList } from '@/services';
+import { usePageTags } from '@/hooks';
+import { Storage } from '@/utils';
+
+import ConversationsList from './components/ConversationList';
+
+interface ConversationListItem {
+  conversation_id: string;
+  topic: string;
+}
+
+const Index = () => {
+  const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
+  const [isShowConversationList, setIsShowConversationList] = useState(false);
+  const [isGenerate, setIsGenerate] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [recentNewItem, setRecentNewItem] = useState<any>(null);
+  const [conversions, setConversions] = useState<Type.ConversationDetail>({
+    records: [],
+    conversation_id: '',
+    created_at: 0,
+    topic: '',
+    updated_at: 0,
+  });
+  const navigate = useNavigate();
+  const { id = '' } = useParams<{ id: string }>();
+  const [temporaryBottomSpace, setTemporaryBottomSpace] = useState(0);
+  const [conversationsPage, setConversationsPage] = useState(1);
+
+  const [conversationsList, setConversationsList] = useState<{
+    count: number;
+    list: ConversationListItem[];
+  }>({
+    count: 0,
+    list: [],
+  });
+
+  const calculateTemporarySpace = () => {
+    const viewportHeight = window.innerHeight;
+    const navHeight = 64;
+    const senderHeight = (document.querySelector('.sender-wrap') as 
HTMLElement)
+      ?.offsetHeight;
+    const neededSpace = viewportHeight - senderHeight - navHeight - 120;
+    const height = neededSpace;
+    console.log('lasMsgHeight', height);
+
+    setTemporaryBottomSpace(height);
+  };
+
+  const resetPageState = () => {
+    setConversions({
+      records: [],
+      conversation_id: '',
+      created_at: 0,
+      topic: '',
+      updated_at: 0,
+    });
+    setIsGenerate(false);
+    setRecentNewItem(null);
+  };
+
+  const handleNewConversation = (e) => {
+    e.preventDefault();
+    navigate('/ai-assistant', { replace: true });
+  };
+
+  const fetchDetail = () => {
+    getConversationDetail(id).then((res) => {
+      setConversions(res);
+    });
+  };
+
+  const handleSubmit = async (userMsg) => {
+    setIsLoading(true);
+    if (conversions?.records.length === 0) {
+      setRecentNewItem({
+        conversation_id: id,
+        topic: userMsg,
+      });
+    }
+    const chatId = Date.now();
+    setConversions((prev) => ({
+      ...prev,
+      topic: userMsg,
+      conversation_id: id,
+      records: [
+        ...prev.records,
+        {
+          id: chatId,
+          role: 'user',
+          content: userMsg,
+          chat_completion_id: String(chatId), // Add required properties
+          helpful: 0,
+          unhelpful: 0,
+          created_at: chatId,
+        },
+      ],
+    }));
+
+    // 在页面高度稳定后再滚动到用户消息
+    requestAnimationFrame(() => {
+      const userBubbles = document.querySelectorAll('.bubble-user-wrap');
+      const lastUserBubble = userBubbles[userBubbles.length - 1];
+      if (lastUserBubble) {
+        lastUserBubble.scrollIntoView({
+          behavior: 'smooth',
+          block: 'start',
+        });
+      }
+    });
+
+    calculateTemporarySpace();
+
+    const params = {
+      conversation_id: id,
+      messages: [
+        {
+          role: 'user',
+          content: userMsg,
+        },
+      ],
+    };
+
+    await requestAi('/answer/api/v1/chat/completions', {
+      body: JSON.stringify(params),
+      onMessage: (res) => {
+        if (!res.choices[0].delta?.content) {
+          return;
+        }
+        setIsLoading(false);
+        setIsGenerate(true);
+        setConversions((prev) => {
+          const updatedRecords = [...prev.records];
+          const lastConversion = updatedRecords[updatedRecords.length - 1];
+          if (lastConversion?.chat_completion_id === res?.chat_completion_id) {
+            updatedRecords[updatedRecords.length - 1] = {
+              ...lastConversion,
+              content: lastConversion.content + res.choices[0].delta.content,
+            };
+          } else {
+            updatedRecords.push({
+              chat_completion_id: res.chat_completion_id,
+              role: res.choices[0].delta.role || 'assistant',
+              content: res.choices[0].delta.content,
+              helpful: 0,
+              unhelpful: 0,
+              created_at: Date.now(),
+            });
+          }
+          return {
+            ...prev,
+            conversation_id: params.conversation_id,
+            records: updatedRecords,
+          };
+        });
+      },
+      onError: (error) => {
+        setIsLoading(false);
+        setIsGenerate(false);
+        console.error('Error:', error);
+      },
+      onComplete: () => {
+        setIsGenerate(false);
+        setIsLoading(false);
+      },
+    });
+  };
+
+  const handleSender = (userMsg) => {
+    if (conversions?.records.length <= 0) {
+      const newConversationId = uuidv4();
+      navigate(`/ai-assistant/${newConversationId}`);
+      Storage.set('_a_once_msg', userMsg);
+    } else {
+      handleSubmit(userMsg);
+    }
+  };
+
+  const handleCancel = () => {
+    if (cancelCurrentRequest()) {
+      setIsGenerate(false);
+    }
+  };
+
+  usePageTags({
+    title: conversions?.topic || t('ai_assistant', { keyPrefix: 'page_title' 
}),
+  });
+
+  useEffect(() => {
+    if (id) {
+      const msg = Storage.get('_a_once_msg');
+      Storage.remove('_a_once_msg');
+      if (msg) {
+        if (msg) {
+          handleSubmit(msg);
+        }
+        return;
+      }
+      fetchDetail();
+    } else {
+      resetPageState();
+    }
+  }, [id]);
+
+  const getList = (p) => {
+    getConversationList({
+      page: p,
+      page_size: 10,
+    }).then((res) => {
+      setConversationsList({
+        count: res.count,
+        list: [...conversationsList.list, ...res.list],
+      });
+    });
+  };
+
+  const getMore = (e) => {
+    e.preventDefault();
+    setConversationsPage((prev) => prev + 1);
+    getList(conversationsPage + 1);
+  };
+
+  useEffect(() => {
+    getList(1);
+
+    return () => {
+      setConversationsList({
+        count: 0,
+        list: [],
+      });
+      setConversationsPage(1);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (recentNewItem && recentNewItem.conversation_id) {
+      setConversationsList((prev) => ({
+        ...prev,
+        list: [
+          recentNewItem,
+          ...prev.list.filter(
+            (item) => item.conversation_id !== recentNewItem.conversation_id,
+          ),
+        ],
+      }));
+    }
+  }, [recentNewItem]);
+
+  return (
+    <div className="pt-4 d-flex flex-column flex-grow-1 position-relative">
+      <div className="d-flex justify-content-between align-items-center mb-4">
+        <h3 className="mb-0">
+          {t('ai_assistant', { keyPrefix: 'page_title' })}
+        </h3>
+        <div>
+          <Button
+            variant="outline-primary"
+            href="/ai-assistant"
+            className="me-2"
+            size="sm"
+            onClick={handleNewConversation}>
+            {t('new')}
+          </Button>
+          <Button
+            variant={isShowConversationList ? 'secondary' : 
'outline-secondary'}
+            size="sm"
+            title={t('recent_conversations')}
+            onClick={() => setIsShowConversationList(!isShowConversationList)}>
+            <Icon name="clock-history" />
+          </Button>
+        </div>
+      </div>
+      <Row
+        className={classNames(
+          'flex-grow-1',
+          !isShowConversationList ? 'justify-content-center' : '',
+        )}>
+        <Col
+          className={classNames(
+            'page-main flex-auto d-flex flex-column flex-grow-1',
+            !conversions?.conversation_id ? 'justify-content-center' : '',
+          )}
+          style={{ maxWidth: '772px' }}>
+          {conversions?.records.length > 0 && (
+            <div className="flex-grow-1 pb-5">
+              {conversions?.records.map((item, index) => {
+                const isLastMessage =
+                  index === Number(conversions?.records.length) - 1;
+                return (
+                  <div
+                    key={`${item.chat_completion_id}-${item.role}`}
+                    className={`${isLastMessage ? '' : 'mb-4'}`}>
+                    {item.role === 'user' ? (
+                      <BubbleUser content={item.content} />
+                    ) : (
+                      <BubbleAi
+                        minHeight={isLastMessage ? temporaryBottomSpace : 0}
+                        canType={isGenerate && isLastMessage}
+                        chatId={item.chat_completion_id}
+                        isLast={isLastMessage}
+                        isCompleted={!isGenerate}
+                        content={item.content}
+                        actionData={{
+                          helpful: item.helpful,
+                          unhelpful: item.unhelpful,
+                        }}
+                      />
+                    )}
+                  </div>
+                );
+              })}
+
+              {temporaryBottomSpace > 0 && isLoading && (
+                <div
+                  style={{
+                    height: `${temporaryBottomSpace}px`,
+                  }}>
+                  {isLoading && (
+                    <Spinner
+                      animation="border"
+                      size="sm"
+                      variant="secondary"
+                      className="mt-4"
+                    />
+                  )}
+                </div>
+              )}
+            </div>
+          )}
+          {conversions?.conversation_id ? null : (
+            <h5 className="text-center mb-3">{t('description')}</h5>
+          )}
+          <Sender
+            onSubmit={handleSender}
+            onCancel={handleCancel}
+            isGenerate={isGenerate || isLoading}
+            hasConversation={!!conversions?.conversation_id}
+          />
+        </Col>
+        {isShowConversationList && (
+          <Col className="page-right-side mt-4 mt-xl-0">
+            <ConversationsList data={conversationsList} loadMore={getMore} />
+          </Col>
+        )}
+      </Row>
+    </div>
+  );
+};
+
+export default Index;
diff --git a/ui/src/pages/Search/components/AiCard/index.tsx 
b/ui/src/pages/Search/components/AiCard/index.tsx
new file mode 100644
index 00000000..9e7d8f94
--- /dev/null
+++ b/ui/src/pages/Search/components/AiCard/index.tsx
@@ -0,0 +1,170 @@
+import { useState, useEffect } from 'react';
+import { Card, Spinner } from 'react-bootstrap';
+import { useSearchParams, Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import { v4 as uuidv4 } from 'uuid';
+
+import { BubbleAi, BubbleUser } from '@/components';
+import { aiControlStore } from '@/stores';
+import * as Type from '@/common/interface';
+import requestAi from '@/utils/requestAi';
+
+const Index = () => {
+  const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
+  const { ai_enabled } = aiControlStore((state) => state);
+  const [searchParams] = useSearchParams();
+  const [isLoading, setIsLoading] = useState(false);
+  const [isGenerate, setIsGenerate] = useState(false);
+  const [isCompleted, setIsCompleted] = useState(false);
+  const [conversions, setConversions] = useState<Type.ConversationDetail>({
+    records: [],
+    conversation_id: '',
+    created_at: 0,
+    topic: '',
+    updated_at: 0,
+  });
+
+  const handleSubmit = async (userMsg) => {
+    setIsLoading(true);
+    setIsCompleted(false);
+    const newConversationId = uuidv4();
+    setConversions({
+      conversation_id: newConversationId,
+      created_at: 0,
+      topic: '',
+      updated_at: 0,
+      records: [
+        {
+          chat_completion_id: Date.now().toString(),
+          role: 'user',
+          content: userMsg,
+          helpful: 0,
+          unhelpful: 0,
+          created_at: Date.now(),
+        },
+      ],
+    });
+
+    const params = {
+      conversation_id: newConversationId,
+      messages: [
+        {
+          role: 'user',
+          content: userMsg,
+        },
+      ],
+    };
+
+    await requestAi('/answer/api/v1/chat/completions', {
+      body: JSON.stringify(params),
+      onMessage: (res) => {
+        if (!res.choices[0].delta?.content) {
+          return;
+        }
+        setIsLoading(false);
+        setIsGenerate(true);
+
+        setConversions((prev) => {
+          const updatedRecords = [...prev.records];
+          const lastConversion = updatedRecords[updatedRecords.length - 1];
+          if (lastConversion?.chat_completion_id === res?.chat_completion_id) {
+            updatedRecords[updatedRecords.length - 1] = {
+              ...lastConversion,
+              content: lastConversion.content + res.choices[0].delta.content,
+            };
+          } else {
+            updatedRecords.push({
+              chat_completion_id: res.chat_completion_id,
+              role: res.choices[0].delta.role || 'assistant',
+              content: res.choices[0].delta.content,
+              helpful: 0,
+              unhelpful: 0,
+              created_at: Date.now(),
+            });
+          }
+          return {
+            ...prev,
+            conversation_id: params.conversation_id,
+            records: updatedRecords,
+          };
+        });
+      },
+      onError: (error) => {
+        setIsGenerate(false);
+        setIsLoading(false);
+        setIsCompleted(true);
+        console.error('Error:', error);
+      },
+      onComplete: () => {
+        setIsCompleted(true);
+        setIsGenerate(false);
+      },
+    });
+  };
+
+  useEffect(() => {
+    const q = searchParams.get('q') || '';
+    if (ai_enabled && q) {
+      handleSubmit(q);
+    }
+  }, [searchParams]);
+
+  if (!ai_enabled) {
+    return null;
+  }
+  return (
+    <Card className="mb-5">
+      <Card.Header>
+        {t('ai_assistant', { keyPrefix: 'page_title' })}
+      </Card.Header>
+      <Card.Body>
+        {conversions?.records.map((item, index) => {
+          const isLastMessage =
+            index === Number(conversions?.records.length) - 1;
+          return (
+            <div
+              key={`${item.chat_completion_id}-${item.role}`}
+              className={`${isLastMessage ? '' : 'mb-4'}`}>
+              {item.role === 'user' ? (
+                <BubbleUser content={item.content} />
+              ) : (
+                <BubbleAi
+                  canType
+                  chatId={item.chat_completion_id}
+                  isLast
+                  isCompleted={!isGenerate}
+                  content={item.content}
+                  actionData={{
+                    helpful: item.helpful,
+                    unhelpful: item.unhelpful,
+                  }}
+                />
+              )}
+            </div>
+          );
+        })}
+        {isLoading && (
+          <Spinner
+            animation="border"
+            size="sm"
+            variant="secondary"
+            className="mt-4"
+          />
+        )}
+      </Card.Body>
+      {isCompleted && !isLoading && (
+        <Card.Footer className="py-3">
+          <Link
+            className="btn btn-outline-primary me-3"
+            to={`/ai-assistant/${conversions.conversation_id}`}>
+            {t('ask_a_follow_up')}
+          </Link>
+          <span className="small text-secondary">{t('ai_generate')}</span>
+        </Card.Footer>
+      )}
+    </Card>
+  );
+};
+
+export default Index;
diff --git a/ui/src/pages/Search/components/index.ts 
b/ui/src/pages/Search/components/index.ts
index 1ea0e991..c04ecba0 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/pages/Search/components/index.ts
@@ -23,5 +23,6 @@ import Tips from './Tips';
 import Empty from './Empty';
 import SearchHead from './SearchHead';
 import ListLoader from './ListLoader';
+import AiCard from './AiCard';
 
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader, AiCard };
diff --git a/ui/src/pages/Search/index.tsx b/ui/src/pages/Search/index.tsx
index 8b6cc1e2..bcc734b2 100644
--- a/ui/src/pages/Search/index.tsx
+++ b/ui/src/pages/Search/index.tsx
@@ -27,6 +27,7 @@ import { useCaptchaPlugin } from '@/utils/pluginKit';
 import { Pagination } from '@/components';
 import { getSearchResult } from '@/services';
 import type { SearchParams, SearchRes } from '@/common/interface';
+import { logged } from '@/utils/guard';
 
 import {
   Head,
@@ -35,10 +36,12 @@ import {
   Tips,
   Empty,
   ListLoader,
+  AiCard,
 } from './components';
 
 const Index = () => {
   const { t } = useTranslation('translation');
+  const isLogged = logged().ok;
   const [searchParams] = useSearchParams();
   const page = searchParams.get('page') || 1;
   const q = searchParams.get('q') || '';
@@ -106,6 +109,7 @@ const Index = () => {
     <Row className="pt-4 mb-5">
       <Col className="page-main flex-auto">
         <Head data={extra} />
+        {isLogged && <AiCard />}
         <SearchHead sort={order} count={isLoading ? -1 : count} />
         <ListGroup className="rounded-0 mb-5">
           {isSkeletonShow ? (
diff --git a/ui/src/pages/Search/components/index.ts 
b/ui/src/pages/SideNavLayoutWithoutFooter/index.tsx
similarity index 53%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/pages/SideNavLayoutWithoutFooter/index.tsx
index 1ea0e991..5f931f78 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/pages/SideNavLayoutWithoutFooter/index.tsx
@@ -17,11 +17,30 @@
  * under the License.
  */
 
-import Head from './Head';
-import SearchItem from './SearchItem';
-import Tips from './Tips';
-import Empty from './Empty';
-import SearchHead from './SearchHead';
-import ListLoader from './ListLoader';
+import { FC, memo } from 'react';
+import { Outlet } from 'react-router-dom';
 
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+import { SideNav } from '@/components';
+
+import '@/common/sideNavLayout.scss';
+
+const Index: FC = () => {
+  return (
+    <div className="d-flex flex-fill">
+      <div
+        className="position-sticky px-3 border-end py-4 d-none d-xl-block"
+        id="pcSideNav">
+        <SideNav />
+      </div>
+      <div className="flex-fill w-100 d-flex flex-column">
+        <div className="d-flex justify-content-center flex-grow-1 px-0 
px-md-4">
+          <div className="d-flex flex-column flex-1 main-mx-with">
+            <Outlet />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default memo(Index);
diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts
index 3ca8431f..2328d526 100644
--- a/ui/src/router/routes.ts
+++ b/ui/src/router/routes.ts
@@ -429,6 +429,14 @@ const routes: RouteNode[] = [
             path: 'badges',
             page: 'pages/Admin/Badges',
           },
+          {
+            path: 'conversations',
+            page: '@/enterprise/pages/Admin/Conversations',
+          },
+          {
+            path: 'ai-settings',
+            page: '@/enterprise/pages/Admin/AiSettings',
+          },
         ],
       },
       {
@@ -451,6 +459,26 @@ const routes: RouteNode[] = [
         path: '50x',
         page: 'pages/50X',
       },
+      // ai
+      {
+        page: 'pages/SideNavLayoutWithoutFooter',
+        children: [
+          {
+            path: '/ai-assistant',
+            page: '@/enterprise/pages/AiAssistant',
+            guard: () => {
+              return guard.logged();
+            },
+          },
+          {
+            path: '/ai-assistant/:id',
+            page: '@/enterprise/pages/AiAssistant',
+            guard: () => {
+              return guard.logged();
+            },
+          },
+        ],
+      },
     ],
   },
   {
diff --git a/ui/src/services/admin/ai.ts b/ui/src/services/admin/ai.ts
new file mode 100644
index 00000000..2d723a31
--- /dev/null
+++ b/ui/src/services/admin/ai.ts
@@ -0,0 +1,68 @@
+import useSWR from 'swr';
+import qs from 'qs';
+
+import request from '@/utils/request';
+import type * as Type from '@/common/interface';
+
+export const getAiConfig = () => {
+  return request.get<Type.AiConfig>('/answer/admin/api/ai-config');
+};
+
+export const useQueryAiProvider = () => {
+  const apiUrl = `/answer/admin/api/ai-provider`;
+  const { data, error, mutate } = useSWR<Type.AiProviderItem[], Error>(
+    apiUrl,
+    request.instance.get,
+  );
+  return {
+    data,
+    isLoading: !data && !error,
+    error,
+    mutate,
+  };
+};
+
+export const checkAiConfig = (params) => {
+  return request.post('/answer/admin/api/ai-models', params);
+};
+
+export const saveAiConfig = (params) => {
+  return request.put('/answer/admin/api/ai-config', params);
+};
+
+export const useQueryAdminConversationDetail = (id: string) => {
+  const apiUrl = !id
+    ? null
+    : `/answer/admin/api/ai/conversation?conversation_id=${id}`;
+
+  const { data, error, mutate } = useSWR<Type.ConversationDetail, Error>(
+    apiUrl,
+    request.instance.get,
+  );
+  return {
+    data,
+    isLoading: !data && !error,
+    error,
+    mutate,
+  };
+};
+
+export const useQueryAdminConversationList = (params: Type.Paging) => {
+  const apiUrl = 
`/answer/admin/api/ai/conversation/page?${qs.stringify(params)}`;
+  const { data, error, mutate } = useSWR<
+    { count: number; list: Type.AdminConversationListItem[] },
+    Error
+  >(apiUrl, request.instance.get);
+  return {
+    data,
+    isLoading: !data && !error,
+    error,
+    mutate,
+  };
+};
+
+export const deleteAdminConversation = (id: string) => {
+  return request.delete('/answer/admin/api/ai/conversation', {
+    conversation_id: id,
+  });
+};
diff --git a/ui/src/services/admin/index.ts b/ui/src/services/admin/index.ts
index af83d365..c2c630cf 100644
--- a/ui/src/services/admin/index.ts
+++ b/ui/src/services/admin/index.ts
@@ -25,3 +25,4 @@ export * from './users';
 export * from './dashboard';
 export * from './plugins';
 export * from './badges';
+export * from './ai';
diff --git a/ui/src/services/client/ai.ts b/ui/src/services/client/ai.ts
new file mode 100644
index 00000000..9b798b26
--- /dev/null
+++ b/ui/src/services/client/ai.ts
@@ -0,0 +1,21 @@
+import qs from 'qs';
+
+import request from '@/utils/request';
+import type * as Type from '@/common/interface';
+
+export const getConversationList = (params: Type.Paging) => {
+  return request.get<{ count: number; list: Type.ConversationListItem[] }>(
+    `/answer/api/v1/ai/conversation/page?${qs.stringify(params)}`,
+  );
+};
+
+export const getConversationDetail = (id: string) => {
+  return request.get<Type.ConversationDetail>(
+    `/answer/api/v1/ai/conversation?conversation_id=${id}`,
+  );
+};
+
+// /answer/api/v1/ai/conversation/vote
+export const voteConversation = (params: Type.VoteConversationParams) => {
+  return request.post('/answer/api/v1/ai/conversation/vote', params);
+};
diff --git a/ui/src/services/client/index.ts b/ui/src/services/client/index.ts
index 005bc26d..4d5c85c1 100644
--- a/ui/src/services/client/index.ts
+++ b/ui/src/services/client/index.ts
@@ -31,3 +31,4 @@ export * from './user';
 export * from './Oauth';
 export * from './review';
 export * from './badges';
+export * from './ai';
diff --git a/ui/src/pages/Search/components/index.ts 
b/ui/src/stores/aiControl.ts
similarity index 63%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/stores/aiControl.ts
index 1ea0e991..c9f0afbc 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/stores/aiControl.ts
@@ -17,11 +17,24 @@
  * under the License.
  */
 
-import Head from './Head';
-import SearchItem from './SearchItem';
-import Tips from './Tips';
-import Empty from './Empty';
-import SearchHead from './SearchHead';
-import ListLoader from './ListLoader';
+import { create } from 'zustand';
 
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+interface AiControlStore {
+  ai_enabled: boolean;
+  update: (params: { ai_enabled: boolean }) => void;
+  reset: () => void;
+}
+
+const aiControlStore = create<AiControlStore>((set) => ({
+  ai_enabled: false,
+  update: (params: { ai_enabled: boolean }) =>
+    set((state) => {
+      return {
+        ...state,
+        ...params,
+      };
+    }),
+  reset: () => set({ ai_enabled: false }),
+}));
+
+export default aiControlStore;
diff --git a/ui/src/stores/index.ts b/ui/src/stores/index.ts
index 66d59b32..1e3aa25d 100644
--- a/ui/src/stores/index.ts
+++ b/ui/src/stores/index.ts
@@ -34,6 +34,7 @@ import errorCodeStore from './errorCode';
 import sideNavStore from './sideNav';
 import commentReplyStore from './commentReply';
 import siteLealStore from './siteLegal';
+import aiControlStore from './aiControl';
 
 export {
   toastStore,
@@ -53,4 +54,5 @@ export {
   commentReplyStore,
   writeSettingStore,
   siteLealStore,
+  aiControlStore,
 };
diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts
index 24064669..e1d2ceb5 100644
--- a/ui/src/utils/guard.ts
+++ b/ui/src/utils/guard.ts
@@ -31,6 +31,7 @@ import {
   pageTagStore,
   writeSettingStore,
   siteLealStore,
+  aiControlStore,
 } from '@/stores';
 import { RouteAlias } from '@/router/alias';
 import {
@@ -388,6 +389,9 @@ export const initAppSettingsStore = async () => {
     siteLealStore.getState().update({
       external_content_display: 
appSettings.site_legal.external_content_display,
     });
+    aiControlStore.getState().update({
+      ai_enabled: appSettings.ai_enabled,
+    });
   }
 };
 
diff --git a/ui/src/utils/requestAi.ts b/ui/src/utils/requestAi.ts
new file mode 100644
index 00000000..ad7e6562
--- /dev/null
+++ b/ui/src/utils/requestAi.ts
@@ -0,0 +1,300 @@
+import { Modal } from '@/components';
+import { loggedUserInfoStore, toastStore, errorCodeStore } from '@/stores';
+import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
+import { RouteAlias } from '@/router/alias';
+import { getCurrentLang } from '@/utils/localize';
+import Storage from '@/utils/storage';
+import { floppyNavigation } from '@/utils/floppyNavigation';
+import { isIgnoredPath, IGNORE_PATH_LIST } from '@/utils/guard';
+
+interface RequestAiOptions extends RequestInit {
+  onMessage?: (text: any) => void;
+  onError?: (error: Error) => void;
+  onComplete?: () => void;
+  signal?: AbortSignal;
+  // 添加项目配置选项
+  allow404?: boolean;
+  ignoreError?: '403' | '50X';
+  passingError?: boolean;
+}
+
+// 创建一个跟踪当前请求的状态对象
+const requestState = {
+  currentReader: null as ReadableStreamDefaultReader<Uint8Array> | null,
+  abortController: null as AbortController | null,
+  isProcessing: false,
+};
+
+// HTTP 错误处理函数(基于 request.ts 的逻辑)
+const handleHttpError = async (
+  response: Response,
+  options: RequestAiOptions,
+): Promise<void> => {
+  const { status } = response;
+  let errBody: any = {};
+
+  try {
+    const text = await response.text();
+    errBody = text ? JSON.parse(text) : {};
+  } catch {
+    errBody = { msg: response.statusText };
+  }
+
+  const { data = {}, msg = '', config } = errBody || {};
+
+  const errorObject = {
+    code: status,
+    msg,
+    data,
+  };
+
+  if (status === 400) {
+    if (data?.err_type && options?.passingError) {
+      return Promise.reject(errorObject);
+    }
+
+    if (data?.err_type) {
+      if (data.err_type === 'toast') {
+        toastStore.getState().show({
+          msg,
+          variant: 'danger',
+        });
+      }
+
+      if (data.err_type === 'alert') {
+        return Promise.reject({ msg, ...data });
+      }
+
+      if (data.err_type === 'modal') {
+        Modal.confirm({
+          content: msg,
+        });
+      }
+      return Promise.reject(false);
+    }
+
+    if (Array.isArray(data) && data.length > 0) {
+      return Promise.reject({
+        ...errorObject,
+        isError: true,
+        list: data,
+      });
+    }
+
+    if (!data || Object.keys(data).length <= 0) {
+      Modal.confirm({
+        content: msg,
+        showConfirm: false,
+        cancelText: 'close',
+      });
+      return Promise.reject(false);
+    }
+  }
+
+  // 401: 重新登录
+  if (status === 401) {
+    errorCodeStore.getState().reset();
+    loggedUserInfoStore.getState().clear();
+    floppyNavigation.navigateToLogin();
+    return Promise.reject(false);
+  }
+
+  if (status === 403) {
+    // Permission interception
+    if (data?.type === 'url_expired') {
+      // url expired
+      floppyNavigation.navigate(RouteAlias.activationFailed, {
+        handler: 'replace',
+      });
+      return Promise.reject(false);
+    }
+    if (data?.type === 'inactive') {
+      // inactivated
+      floppyNavigation.navigate(RouteAlias.inactive);
+      return Promise.reject(false);
+    }
+
+    if (data?.type === 'suspended') {
+      loggedUserInfoStore.getState().clear();
+      floppyNavigation.navigate(RouteAlias.suspended, {
+        handler: 'replace',
+      });
+      return Promise.reject(false);
+    }
+
+    if (isIgnoredPath(IGNORE_PATH_LIST)) {
+      return Promise.reject(false);
+    }
+    if (config?.url.includes('/admin/api')) {
+      errorCodeStore.getState().update('403');
+      return Promise.reject(false);
+    }
+
+    if (msg) {
+      toastStore.getState().show({
+        msg,
+        variant: 'danger',
+      });
+    }
+    return Promise.reject(false);
+  }
+
+  if (status === 404 && config?.allow404) {
+    if (isIgnoredPath(IGNORE_PATH_LIST)) {
+      return Promise.reject(false);
+    }
+    errorCodeStore.getState().update('404');
+    return Promise.reject(false);
+  }
+
+  if (status >= 500) {
+    if (isIgnoredPath(IGNORE_PATH_LIST)) {
+      return Promise.reject(false);
+    }
+
+    if (config?.ignoreError !== '50X') {
+      errorCodeStore.getState().update('50X');
+    }
+
+    console.error(`Request failed with status code ${status}, ${msg || ''}`);
+  }
+  return Promise.reject(errorObject);
+};
+const requestAi = async (url: string, options: RequestAiOptions) => {
+  try {
+    // 如果有之前的请求正在处理,取消它
+    if (requestState.isProcessing && requestState.abortController) {
+      requestState.abortController.abort();
+    }
+
+    // 创建新的AbortController
+    const abortController = new AbortController();
+    requestState.abortController = abortController;
+
+    // 合并传入的signal与新创建的signal
+    const combinedSignal = options.signal || abortController.signal;
+
+    // 标记为正在处理
+    requestState.isProcessing = true;
+
+    // 获取认证信息和语言设置(与 request.ts 保持一致)
+    const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || '';
+    console.log(token);
+    const lang = getCurrentLang();
+
+    const response = await fetch(url, {
+      ...options,
+      method: 'POST',
+      signal: combinedSignal,
+      headers: {
+        Authorization: token,
+        'Accept-Language': lang,
+        'Content-Type': 'application/json',
+        ...options.headers,
+      },
+    });
+
+    // 统一错误处理(基于 request.ts 的逻辑)
+    if (!response.ok) {
+      await handleHttpError(response, options);
+      return;
+    }
+
+    const reader = response.body?.getReader();
+    if (!reader) {
+      throw new Error('ReadableStream not supported');
+    }
+
+    // 存储当前reader以便稍后可以取消
+    requestState.currentReader = reader;
+
+    const decoder = new TextDecoder();
+    let buffer = '';
+
+    const processStream = async (): Promise<void> => {
+      try {
+        const { value, done } = await reader.read();
+
+        if (done) {
+          options.onComplete?.();
+          requestState.isProcessing = false;
+          requestState.currentReader = null;
+          return;
+        }
+
+        const chunk = decoder.decode(value, { stream: true });
+        buffer += chunk;
+
+        const lines = buffer.split('\n');
+        buffer = lines.pop() || '';
+
+        lines.forEach((line) => {
+          if (line.trim()) {
+            try {
+              // 处理特殊的 [DONE] 信号
+              const cleanedLine = line.replace(/^data: /, '').trim();
+              if (cleanedLine === '[DONE]') {
+                return; // 跳过 [DONE] 信号的处理
+              }
+
+              if (cleanedLine) {
+                const parsedLine = JSON.parse(cleanedLine);
+                options.onMessage?.(parsedLine);
+              }
+            } catch (error) {
+              console.debug('Error parsing line:', line);
+            }
+          }
+        });
+
+        // 检查是否已取消
+        if (combinedSignal.aborted) {
+          requestState.isProcessing = false;
+          requestState.currentReader = null;
+          throw new Error('Request was aborted');
+        }
+
+        await processStream();
+      } catch (error) {
+        if ((error as Error).message === 'Request was aborted') {
+          options.onComplete?.(); // 取消也视为完成
+        } else {
+          throw error; // 重新抛出其他错误
+        }
+      }
+    };
+
+    await processStream();
+  } catch (error) {
+    const errorMessage =
+      error instanceof Error ? error.message : 'Unknown error';
+
+    // 如果是取消导致的错误,不作为错误处理
+    if (
+      errorMessage !== 'The user aborted a request' &&
+      errorMessage !== 'Request was aborted'
+    ) {
+      console.error('Request AI Error:', errorMessage);
+      options.onError?.(new Error(errorMessage));
+    } else {
+      console.log('Request was cancelled by user');
+      options.onComplete?.(); // 取消也视为完成
+    }
+  } finally {
+    requestState.isProcessing = false;
+    requestState.currentReader = null;
+  }
+};
+
+// 添加一个取消当前请求的函数
+const cancelCurrentRequest = () => {
+  if (requestState.abortController) {
+    requestState.abortController.abort();
+    console.log('AI request cancelled by user');
+    return true;
+  }
+  return false;
+};
+
+export { cancelCurrentRequest };
+export default requestAi;

Reply via email to