This is an automated email from the ASF dual-hosted git repository.
shuai pushed a commit to branch test
in repository https://gitbox.apache.org/repos/asf/answer.git
The following commit(s) were added to refs/heads/test by this push:
new dc7f7521 Support AI Assistant and MCP functions (#1477)
dc7f7521 is described below
commit dc7f75212845dc851e95fe6cb7f2e19b9faf8550
Author: dashuai <[email protected]>
AuthorDate: Fri Jan 23 17:12:26 2026 +0800
Support AI Assistant and MCP functions (#1477)
1. Add AI assistant-related business.
2. Add Mcp related configuration in the management background
---
i18n/en_US.yaml | 79 ++++
i18n/zh_CN.yaml | 79 ++++
ui/package.json | 1 +
ui/pnpm-lock.yaml | 13 +
ui/src/common/constants.ts | 10 +
ui/src/common/interface.ts | 71 +++
ui/src/components/BubbleAi/index.tsx | 259 +++++++++++
.../index.ts => components/BubbleUser/index.scss} | 15 +-
.../index.ts => components/BubbleUser/index.tsx} | 24 +-
ui/src/components/Modal/Modal.tsx | 6 +-
.../index.ts => components/Sender/index.scss} | 25 +-
ui/src/components/Sender/index.tsx | 172 ++++++++
ui/src/components/SideNav/index.tsx | 14 +-
ui/src/components/index.ts | 6 +
.../Admin/AiAssistant/components/Action/index.tsx | 71 +++
.../AiAssistant/components/DetailModal/index.tsx | 86 ++++
ui/src/pages/Admin/AiAssistant/index.tsx | 130 ++++++
ui/src/pages/Admin/AiSettings/index.tsx | 486 +++++++++++++++++++++
.../Admin/Apikeys/components/Action/index.tsx | 77 ++++
.../Apikeys/components/AddOrEditModal/index.tsx | 184 ++++++++
.../Apikeys/components/CreatedModal/index.tsx | 55 +++
.../Admin/Apikeys/components}/index.ts | 14 +-
ui/src/pages/Admin/Apikeys/index.tsx | 138 ++++++
ui/src/pages/Admin/Mcp/index.tsx | 113 +++++
ui/src/pages/Admin/QaSettings/index.tsx | 19 +
ui/src/pages/Admin/Security/index.tsx | 19 +
ui/src/pages/Admin/TagsSettings/index.tsx | 19 +
ui/src/pages/Admin/UsersSettings/index.tsx | 19 +
.../components/ConversationList/index.tsx | 69 +++
ui/src/pages/AiAssistant/index.tsx | 380 ++++++++++++++++
ui/src/pages/Questions/Ask/index.tsx | 6 +-
ui/src/pages/Questions/EditAnswer/index.tsx | 8 +-
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/pages/Tags/Create/index.tsx | 8 +-
ui/src/pages/Tags/Edit/index.tsx | 8 +-
ui/src/router/routes.ts | 36 ++
ui/src/services/admin/ai.ts | 87 ++++
.../index.ts => services/admin/apikeys.ts} | 38 +-
ui/src/services/admin/index.ts | 3 +
.../components/index.ts => services/admin/mcp.ts} | 22 +-
.../components/index.ts => services/client/ai.ts} | 27 +-
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 +++++++++++++
49 files changed, 3355 insertions(+), 85 deletions(-)
diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index ea607366..9a0d198b 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -857,6 +857,16 @@ ui:
http_403: HTTP Error 403
logout: Log Out
posts: Posts
+ ai_assistant: AI Assistant
+ ai_assistant:
+ description: Got a question? Ask it and get answers, perspectives, and
recommendations.
+ recent_conversations: Recent Conversations
+ show_more: Show more
+ new: New chat
+ ai_generate: AI-generated from posts and may not be accurate.
+ copy: Copy
+ ask_a_follow_up: Ask a follow-up
+ ask_placeholder: Ask a question
notifications:
title: Notifications
inbox: Inbox
@@ -1823,6 +1833,11 @@ ui:
policies: Policies
security: Security
files: Files
+ apikeys: API Keys
+ intelligence: Intelligence
+ ai_assistant: AI Assistant
+ ai_settings: AI Settings
+ mcp: MCP
website_welcome: Welcome to {{site_name}}
user_center:
login: Login
@@ -2298,6 +2313,70 @@ ui:
show_logs: Show logs
status: Status
title: Badges
+ apikeys:
+ title: API Keys
+ add_api_key: Add API Key
+ desc: Description
+ scope: Scope
+ key: Key
+ created: Created
+ last_used: Last used
+ add_or_edit_modal:
+ add_title: Add API Key
+ edit_title: Edit API Key
+ description: Description
+ description_required: Description is required.
+ scope: Scope
+ global: Global
+ read-only: Read-only
+ created_modal:
+ title: API key created
+ api_key: API key
+ description: This key will not be displayed again. Make sure you take
a copy before continuing.
+ delete_modal:
+ title: Delete API Key
+ content: Any applications or scripts using this key will no longer be
able to access the API. This is permanent!
+ ai_settings:
+ enabled:
+ label: AI enabled
+ check: Enable AI features
+ text: The AI model must be configured correctly before it can be used.
+ provider:
+ label: Provider
+ api_host:
+ label: API host
+ msg: API host is required
+ api_key:
+ label: API key
+ check: Check
+ check_success: "Connection successful."
+ msg: API key is required
+ model:
+ label: Model
+ msg: Model is required
+ add_success: AI settings updated successfully.
+ conversations:
+ topic: Topic
+ helpful: Helpful
+ unhelpful: Unhelpful
+ created: Created
+ action: Action
+ empty: No conversations found.
+ delete_modal:
+ title: Delete conversation
+ content: Are you sure you want to delete this conversation? This is
permanent!
+ delete_success: Conversation deleted successfully.
+ mcp:
+ mcp_server:
+ label: MCP server
+ switch: Enabled
+ type:
+ label: Type
+ url:
+ label: URL
+ http_header:
+ label: HTTP header
+ text: Please replace {key} with the API Key.
form:
optional: (optional)
empty: cannot be empty
diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml
index 59ef41be..07589872 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -846,6 +846,16 @@ ui:
http_403: HTTP 错误 403
logout: 退出
posts: 帖子
+ ai_assistant: AI 助手
+ ai_assistant:
+ description: 有问题吗?尽管提问,即可获得解答、不同观点及实用建议。
+ recent_conversations: 最近的对话
+ show_more: 展示更多
+ new: 新建对话
+ ai_generate: 由帖子生成的 AI 内容,可能并不准确。
+ copy: 复制
+ ask_a_follow_up: 继续提问
+ ask_placeholder: 提出问题
notifications:
title: 通知
inbox: 收件箱
@@ -1785,6 +1795,11 @@ ui:
policies: 政策
security: 安全
files: 文件
+ apikeys: API Keys
+ intelligence: 人工智能
+ ai_assistant: AI 助手
+ ai_settings: AI 设置
+ mcp: MCP
website_welcome: 欢迎来到 {{site_name}}
user_center:
login: 登录
@@ -2259,6 +2274,70 @@ ui:
show_logs: 显示日志
status: 状态
title: 徽章
+ apikeys:
+ title: API Keys
+ add_api_key: 添加 API Key
+ desc: 描述
+ scope: 范围
+ key: Key
+ created: 创建时间
+ last_used: 最后使用时间
+ add_or_edit_modal:
+ add_title: 添加 API Key
+ edit_title: 编辑 API Key
+ description: 描述
+ description_required: 描述必填.
+ scope: 范围
+ global: 全局
+ read-only: 只读
+ created_modal:
+ title: API key 创建成功
+ api_key: API key
+ description: 此密钥将不会再显示。请确保在继续之前复制一份。
+ delete_modal:
+ title: 删除 API Key
+ content: 任何使用此密钥的应用程序或脚本将无法再访问 API。这是永久性的!
+ ai_settings:
+ enabled:
+ label: AI 启用
+ check: 启用 AI 功能
+ text: AI 模型必须正确配置才能使用。
+ provider:
+ label: 提供者
+ api_host:
+ label: API 主机
+ msg: API 主机必填
+ api_key:
+ label: API Key
+ check: 校验
+ check_success: "连接成功."
+ msg: API key 必填
+ model:
+ label: 模型
+ msg: 模型必填
+ add_success: AI 设置已成功更新.
+ conversations:
+ topic: 话题
+ helpful: 有用的
+ unhelpful: 无用的
+ created: 创建于
+ action: 操作
+ empty: 没有会话记录。
+ delete_modal:
+ title: 删除对话
+ content: 你确定要删除此对话吗?这是永久的!
+ delete_success: 对话删除成功.
+ mcp:
+ mcp_server:
+ label: MCP 服务
+ switch: 启用
+ type:
+ label: 类型
+ url:
+ label: URL
+ http_header:
+ label: HTTP header
+ text: 请将 {key} 替换为 API Key。
form:
optional: (选填)
empty: 不能为空
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 285da474..2e10f149 100644
--- a/ui/src/common/constants.ts
+++ b/ui/src/common/constants.ts
@@ -102,6 +102,14 @@ export const ADMIN_NAV_MENUS = [
{ name: 'tags', path: 'tags/settings', pathPrefix: 'tags/' },
],
},
+ {
+ name: 'intelligence',
+ icon: 'robot',
+ children: [
+ { name: 'ai_settings', path: 'ai-settings' },
+ { name: 'ai_assistant', path: 'ai-assistant' },
+ ],
+ },
{
name: 'community',
icon: 'people-fill',
@@ -135,6 +143,8 @@ export const ADMIN_NAV_MENUS = [
{ name: 'login' },
{ name: 'seo' },
{ name: 'smtp' },
+ { name: 'apikeys' },
+ { name: 'mcp' },
],
},
{
diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts
index aac89cce..308726e8 100644
--- a/ui/src/common/interface.ts
+++ b/ui/src/common/interface.ts
@@ -427,6 +427,7 @@ export interface SiteSettings {
version: string;
revision: string;
site_security: AdminSettingsSecurity;
+ ai_enabled: boolean;
}
export interface AdminSettingBranding {
@@ -809,6 +810,76 @@ export interface BadgeDetailListRes {
list: BadgeDetailListItem[];
}
+export interface AdminApiKeysItem {
+ access_key: string;
+ created_at: number;
+ description: string;
+ id: number;
+ last_used_at: number;
+ scope: string;
+}
+
+export interface AddOrEditApiKeyParams {
+ description: string;
+ scope?: string;
+ id?: number;
+}
+
+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';
+}
+
export interface AdminQuestionSetting {
min_tags: number;
min_content: number;
diff --git a/ui/src/components/BubbleAi/index.tsx
b/ui/src/components/BubbleAi/index.tsx
new file mode 100644
index 00000000..453a61c5
--- /dev/null
+++ b/ui/src/components/BubbleAi/index.tsx
@@ -0,0 +1,259 @@
+/*
+ * 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 { 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 '@/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);
+ // add ref for 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/services/admin/index.ts
b/ui/src/components/BubbleUser/index.scss
similarity index 77%
copy from ui/src/services/admin/index.ts
copy to ui/src/components/BubbleUser/index.scss
index 3fa211ee..6606ac5e 100644
--- a/ui/src/services/admin/index.ts
+++ b/ui/src/components/BubbleUser/index.scss
@@ -17,12 +17,9 @@
* under the License.
*/
-export * from './answer';
-export * from './flag';
-export * from './question';
-export * from './settings';
-export * from './users';
-export * from './dashboard';
-export * from './plugins';
-export * from './badges';
-export * from './tags';
+.bubble-user-wrap {
+ scroll-margin-top: 88px;
+}
+.bubble-user {
+ background-color: var(--bs-gray-200);
+}
diff --git a/ui/src/pages/Search/components/index.ts
b/ui/src/components/BubbleUser/index.tsx
similarity index 68%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/components/BubbleUser/index.tsx
index 1ea0e991..b6c52a74 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/components/BubbleUser/index.tsx
@@ -17,11 +17,21 @@
* 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 } from 'react';
+import './index.scss';
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+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/Modal/Modal.tsx
b/ui/src/components/Modal/Modal.tsx
index cbf98c24..580d51da 100644
--- a/ui/src/components/Modal/Modal.tsx
+++ b/ui/src/components/Modal/Modal.tsx
@@ -21,6 +21,8 @@ import React, { FC } from 'react';
import { Button, Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import classNames from 'classnames';
+
export interface Props {
id?: string;
/** header title */
@@ -77,7 +79,9 @@ const Index: FC<Props> = ({
{title || t('title', { keyPrefix: 'modal_confirm' })}
</Modal.Title>
</Modal.Header>
- <Modal.Body className={bodyClass}>{children}</Modal.Body>
+ <Modal.Body className={classNames('text-break', bodyClass)}>
+ {children}
+ </Modal.Body>
{(showCancel || showConfirm) && (
<Modal.Footer>
{showCancel && (
diff --git a/ui/src/pages/Search/components/index.ts
b/ui/src/components/Sender/index.scss
similarity index 66%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/components/Sender/index.scss
index 1ea0e991..32c12faa 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/components/Sender/index.scss
@@ -17,11 +17,22 @@
* 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';
+.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;
+ }
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+ .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..e79cf9b2
--- /dev/null
+++ b/ui/src/components/Sender/index.tsx
@@ -0,0 +1,172 @@
+/*
+ * 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 { 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; // minimum height
+ const maxHeight = 96; // maximum height
+
+ // calculate the height needed
+ const { scrollHeight } = textarea;
+ const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
+
+ // set the new height
+ textarea.style.height = `${newHeight}px`;
+
+ // control the scrollbar display
+ 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 416db241..5c81739b 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';
import TabNav from './TabNav';
export {
@@ -116,6 +119,9 @@ export {
PinList,
MobileSideNav,
AdminSideNav,
+ BubbleAi,
+ BubbleUser,
+ Sender,
TabNav,
};
export type { EditorRef, JSONSchema, UISchema };
diff --git a/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx
b/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx
new file mode 100644
index 00000000..c9702ea6
--- /dev/null
+++ b/ui/src/pages/Admin/AiAssistant/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 '@/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/AiAssistant/components/DetailModal/index.tsx
b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx
new file mode 100644
index 00000000..ec4bd7d7
--- /dev/null
+++ b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 { FC, memo } from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { BubbleAi, BubbleUser } from '@/components';
+import { useQueryAdminConversationDetail } from '@/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/AiAssistant/index.tsx
b/ui/src/pages/Admin/AiAssistant/index.tsx
new file mode 100644
index 00000000..7fc3e9d9
--- /dev/null
+++ b/ui/src/pages/Admin/AiAssistant/index.tsx
@@ -0,0 +1,130 @@
+/*
+ * 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 { 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('ai_assistant', { keyPrefix: 'nav_menus' })}</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/Admin/AiSettings/index.tsx
b/ui/src/pages/Admin/AiSettings/index.tsx
new file mode 100644
index 00000000..2270aa5c
--- /dev/null
+++ b/ui/src/pages/Admin/AiSettings/index.tsx
@@ -0,0 +1,486 @@
+/*
+ * 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 { 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('ai_settings', { keyPrefix: 'nav_menus' })}</h3>
+ <div className="max-w-748">
+ <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.Text className="text-muted">{t('enabled.text')}</Form.Text>
+ <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>
+
+ <div className="mb-3">
+ <label htmlFor="model" className="form-label">
+ {t('model.label')}
+ </label>
+ {/* <Form.Select
+ list="datalistOptions"
+ 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> */}
+ <input
+ className="form-control"
+ list="datalistOptions"
+ id="model"
+ value={formData.model.value}
+ onChange={(e) =>
+ handleValueChange({
+ model: {
+ value: e.target.value,
+ errorMsg: '',
+ isInvalid: false,
+ },
+ })
+ }
+ />
+ <datalist id="datalistOptions">
+ {modelsData?.map((model) => {
+ return (
+ <option key={model.id} value={model.id}>
+ {model.id}
+ </option>
+ );
+ })}
+ </datalist>
+
+ <div className="invalid-feedback">{formData.model.errorMsg}</div>
+ </div>
+
+ <Button type="submit">{t('save', { keyPrefix: 'btns' })}</Button>
+ </Form>
+ </div>
+ </div>
+ );
+};
+export default Index;
diff --git a/ui/src/pages/Admin/Apikeys/components/Action/index.tsx
b/ui/src/pages/Admin/Apikeys/components/Action/index.tsx
new file mode 100644
index 00000000..f3a7cbcc
--- /dev/null
+++ b/ui/src/pages/Admin/Apikeys/components/Action/index.tsx
@@ -0,0 +1,77 @@
+/*
+ * 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 { Icon, Modal } from '@/components';
+import { deleteApiKey } from '@/services';
+import { toastStore } from '@/stores';
+
+const ApiActions = ({ itemData, refreshList, showModal }) => {
+ const { t } = useTranslation('translation', {
+ keyPrefix: 'admin.apikeys.delete_modal',
+ });
+
+ const handleAction = (type) => {
+ if (type === 'delete') {
+ Modal.confirm({
+ title: t('title'),
+ content: t('content'),
+ cancelBtnVariant: 'link',
+ confirmBtnVariant: 'danger',
+ confirmText: t('delete', { keyPrefix: 'btns' }),
+ onConfirm: () => {
+ deleteApiKey(itemData.id).then(() => {
+ toastStore.getState().show({
+ msg: t('api_key_deleted', { keyPrefix: 'messages' }),
+ variant: 'success',
+ });
+ refreshList();
+ });
+ },
+ });
+ }
+
+ if (type === 'edit') {
+ showModal(true);
+ }
+ };
+
+ return (
+ <Dropdown>
+ <Dropdown.Toggle variant="link" className="no-toggle p-0">
+ <Icon
+ name="three-dots-vertical"
+ title={t('action', { keyPrefix: 'admin.answers' })}
+ />
+ </Dropdown.Toggle>
+ <Dropdown.Menu align="end">
+ <Dropdown.Item onClick={() => handleAction('edit')}>
+ {t('edit', { keyPrefix: 'btns' })}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={() => handleAction('delete')}>
+ {t('delete', { keyPrefix: 'btns' })}
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ );
+};
+
+export default ApiActions;
diff --git a/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx
b/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx
new file mode 100644
index 00000000..044a3017
--- /dev/null
+++ b/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx
@@ -0,0 +1,184 @@
+/*
+ * 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 { useState } from 'react';
+import { Modal, Form, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { handleFormError } from '@/utils';
+import { addApiKey, updateApiKey } from '@/services';
+
+const initFormData = {
+ description: {
+ value: '',
+ isInvalid: false,
+ errorMsg: '',
+ },
+ scope: {
+ value: 'read-only',
+ isInvalid: false,
+ errorMsg: '',
+ },
+};
+
+const Index = ({ data, visible = false, onClose, callback }) => {
+ const { t } = useTranslation('translation', {
+ keyPrefix: 'admin.apikeys.add_or_edit_modal',
+ });
+ const [formData, setFormData] = useState<any>(initFormData);
+
+ const handleValueChange = (value) => {
+ setFormData({
+ ...formData,
+ ...value,
+ });
+ };
+
+ const handleAdd = () => {
+ const { description, scope } = formData;
+ if (!description.value) {
+ setFormData({
+ ...formData,
+ description: {
+ ...description,
+ isInvalid: true,
+ errorMsg: t('description_required'),
+ },
+ });
+ return;
+ }
+ addApiKey({
+ description: description.value,
+ scope: scope.value,
+ })
+ .then((res) => {
+ callback('add', res.access_key);
+ setFormData(initFormData);
+ })
+ .catch((error) => {
+ const obj = handleFormError(error, formData);
+ setFormData({ ...obj });
+ });
+ };
+
+ const handleEdit = () => {
+ const { description } = formData;
+ if (!description.value) {
+ setFormData({
+ ...formData,
+ description: {
+ ...description,
+ isInvalid: true,
+ errorMsg: t('description_required'),
+ },
+ });
+ return;
+ }
+ updateApiKey({
+ description: description.value,
+ id: data?.id,
+ })
+ .then(() => {
+ callback('edit', null);
+ setFormData(initFormData);
+ })
+ .catch((error) => {
+ const obj = handleFormError(error, formData);
+ setFormData({ ...obj });
+ });
+ };
+
+ const handleSubmit = () => {
+ if (data?.id) {
+ handleEdit();
+ return;
+ }
+ handleAdd();
+ };
+
+ const closeModal = () => {
+ setFormData(initFormData);
+ onClose(false, null);
+ };
+ return (
+ <Modal show={visible} onHide={closeModal}>
+ <Modal.Header closeButton>
+ {data?.id ? t('edit_title') : t('add_title')}
+ </Modal.Header>
+ <Modal.Body>
+ <Form>
+ <Form.Group controlId="description" className="mb-3">
+ <Form.Label>{t('description')}</Form.Label>
+ <Form.Control
+ type="text"
+ isInvalid={formData.description.isInvalid}
+ value={formData.description.value}
+ onChange={(e) => {
+ handleValueChange({
+ description: {
+ value: e.target.value,
+ errorMsg: '',
+ isInvalid: false,
+ },
+ });
+ }}
+ />
+ <Form.Control.Feedback type="invalid">
+ {formData.description.errorMsg}
+ </Form.Control.Feedback>
+ </Form.Group>
+
+ {!data?.id && visible && (
+ <Form.Group controlId="scope" className="mb-3">
+ <Form.Label>{t('scope')}</Form.Label>
+ <Form.Select
+ isInvalid={formData.scope.isInvalid}
+ value={formData.scope.value}
+ onChange={(e) => {
+ handleValueChange({
+ scope: {
+ value: e.target.value,
+ errorMsg: '',
+ isInvalid: false,
+ },
+ });
+ }}>
+ <option value="read-only">{t('read-only')}</option>
+ <option value="global">{t('global')}</option>
+ </Form.Select>
+ <Form.Control.Feedback type="invalid">
+ {formData.scope.errorMsg}
+ </Form.Control.Feedback>
+ </Form.Group>
+ )}
+ </Form>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="link" onClick={closeModal}>
+ {t('cancel', { keyPrefix: 'btns' })}
+ </Button>
+ <Button type="button" variant="primary" onClick={handleSubmit}>
+ {t('submit', { keyPrefix: 'btns' })}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx
b/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx
new file mode 100644
index 00000000..83f782a1
--- /dev/null
+++ b/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx
@@ -0,0 +1,55 @@
+/*
+ * 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 { Modal, Form, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const Index = ({ visible, api_key = '', onClose }) => {
+ const { t } = useTranslation('translation', {
+ keyPrefix: 'admin.apikeys.created_modal',
+ });
+
+ return (
+ <Modal show={visible} onHide={onClose}>
+ <Modal.Header closeButton>{t('title')}</Modal.Header>
+ <Modal.Body>
+ <Form>
+ <Form.Group controlId="api_key" className="mb-3">
+ <Form.Label>{t('api_key')}</Form.Label>
+ <Form.Control
+ type="text"
+ defaultValue={api_key}
+ readOnly
+ disabled
+ />
+ </Form.Group>
+
+ <div className="mb-3">{t('description')}</div>
+ </Form>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="link" onClick={onClose}>
+ {t('close', { keyPrefix: 'btns' })}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+export default Index;
diff --git a/ui/src/services/admin/index.ts
b/ui/src/pages/Admin/Apikeys/components/index.ts
similarity index 77%
copy from ui/src/services/admin/index.ts
copy to ui/src/pages/Admin/Apikeys/components/index.ts
index 3fa211ee..46bda3f5 100644
--- a/ui/src/services/admin/index.ts
+++ b/ui/src/pages/Admin/Apikeys/components/index.ts
@@ -17,12 +17,8 @@
* under the License.
*/
-export * from './answer';
-export * from './flag';
-export * from './question';
-export * from './settings';
-export * from './users';
-export * from './dashboard';
-export * from './plugins';
-export * from './badges';
-export * from './tags';
+import Action from './Action';
+import AddOrEditModal from './AddOrEditModal';
+import CreatedModal from './CreatedModal';
+
+export { Action, AddOrEditModal, CreatedModal };
diff --git a/ui/src/pages/Admin/Apikeys/index.tsx
b/ui/src/pages/Admin/Apikeys/index.tsx
new file mode 100644
index 00000000..a143cfa8
--- /dev/null
+++ b/ui/src/pages/Admin/Apikeys/index.tsx
@@ -0,0 +1,138 @@
+/*
+ * 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 { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Table } from 'react-bootstrap';
+
+import dayjs from 'dayjs';
+
+import { useQueryApiKeys } from '@/services';
+
+import { Action, AddOrEditModal, CreatedModal } from './components';
+
+const Index = () => {
+ const { t } = useTranslation('translation', {
+ keyPrefix: 'admin.apikeys',
+ });
+ const [showModal, setShowModal] = useState({
+ visible: false,
+ item: null,
+ });
+ const [showCreatedModal, setShowCreatedModal] = useState({
+ visible: false,
+ api_key: '',
+ });
+ const { data: apiKeysList, mutate: refreshList } = useQueryApiKeys();
+
+ const handleAddModalState = (bol, item) => {
+ setShowModal({
+ visible: bol,
+ item,
+ });
+ };
+
+ const handleCreatedModalState = (visible, api_key) => {
+ setShowCreatedModal({
+ visible,
+ api_key,
+ });
+ };
+
+ const addOrEditCallback = (type, key) => {
+ handleAddModalState(false, null);
+ refreshList();
+ if (type === 'add') {
+ handleCreatedModalState(true, key);
+ }
+ };
+
+ return (
+ <div>
+ <h3 className="mb-4">{t('title')}</h3>
+ <Button
+ variant="outline-primary mb-3"
+ size="sm"
+ onClick={() => handleAddModalState(true, null)}>
+ {t('add_api_key')}
+ </Button>
+ <Table responsive="md">
+ <thead className="c-table">
+ <tr>
+ <th style={{ width: '20%' }}>{t('desc')}</th>
+ <th style={{ width: '11%' }}>{t('scope')}</th>
+ <th style={{ minWidth: '200px' }}>{t('key')}</th>
+ <th style={{ width: '18%' }}>{t('created')}</th>
+ <th style={{ width: '18%' }}>{t('last_used')}</th>
+ <th className="text-end" style={{ width: '10%' }}>
+ {t('action', { keyPrefix: 'admin.questions' })}
+ </th>
+ </tr>
+ {apiKeysList?.map((item) => {
+ return (
+ <tr key={item.id}>
+ <td>{item.description}</td>
+ <td>
+ {t(item.scope, {
+ keyPrefix: 'admin.apikeys.add_or_edit_modal',
+ })}
+ </td>
+ <td>{item.access_key}</td>
+ <td>
+ {dayjs
+ .unix(item?.created_at)
+ .tz()
+ .format(t('long_date_with_time', { keyPrefix: 'dates' }))}
+ </td>
+ <td>
+ {item?.last_used_at &&
+ dayjs
+ .unix(item?.last_used_at)
+ .tz()
+ .format(t('long_date_with_time', { keyPrefix: 'dates'
}))}
+ </td>
+ <td className="text-end">
+ <Action
+ itemData={item}
+ showModal={() => handleAddModalState(true, item)}
+ refreshList={refreshList}
+ />
+ </td>
+ </tr>
+ );
+ })}
+ </thead>
+ </Table>
+
+ <AddOrEditModal
+ data={showModal.item}
+ visible={showModal.visible}
+ onClose={handleAddModalState}
+ callback={addOrEditCallback}
+ />
+ <CreatedModal
+ visible={showCreatedModal.visible}
+ api_key={showCreatedModal.api_key}
+ onClose={() => handleCreatedModalState(false, '')}
+ />
+ </div>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Admin/Mcp/index.tsx b/ui/src/pages/Admin/Mcp/index.tsx
new file mode 100644
index 00000000..39d5375d
--- /dev/null
+++ b/ui/src/pages/Admin/Mcp/index.tsx
@@ -0,0 +1,113 @@
+/*
+ * 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 { FormEvent, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form, Button } from 'react-bootstrap';
+
+import { useToast } from '@/hooks';
+import { getMcpConfig, saveMcpConfig } from '@/services';
+
+const Mcp = () => {
+ const toast = useToast();
+ const { t } = useTranslation('translation', {
+ keyPrefix: 'admin.mcp',
+ });
+ const [formData, setFormData] = useState({
+ enabled: true,
+ type: '',
+ url: '',
+ http_header: '',
+ });
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleOnChange = (form) => {
+ setFormData({ ...formData, ...form });
+ };
+ const onSubmit = (evt: FormEvent) => {
+ evt.preventDefault();
+ evt.stopPropagation();
+ saveMcpConfig({ enabled: formData.enabled }).then(() => {
+ toast.onShow({
+ msg: t('update', { keyPrefix: 'toast' }),
+ variant: 'success',
+ });
+ });
+ };
+
+ useEffect(() => {
+ getMcpConfig()
+ .then((resp) => {
+ setIsLoading(false);
+ setFormData(resp);
+ })
+ .catch(() => {
+ setIsLoading(false);
+ });
+ }, []);
+ if (isLoading) {
+ return null;
+ }
+ return (
+ <>
+ <h3 className="mb-4">{t('mcp', { keyPrefix: 'nav_menus' })}</h3>
+ <div className="max-w-748">
+ <Form onSubmit={onSubmit}>
+ <Form.Group className="mb-3" controlId="mcp_server">
+ <Form.Label>{t('mcp_server.label')}</Form.Label>
+ <Form.Check
+ type="switch"
+ label={t('mcp_server.switch')}
+ checked={formData.enabled}
+ onChange={(e) => handleOnChange({ enabled: e.target.checked })}
+ />
+ </Form.Group>
+ {formData.enabled && (
+ <>
+ <Form.Group className="mb-3" controlId="type">
+ <Form.Label>{t('type.label')}</Form.Label>
+ <Form.Control type="text" disabled value={formData.type} />
+ </Form.Group>
+ <Form.Group className="mb-3" controlId="url">
+ <Form.Label>{t('url.label')}</Form.Label>
+ <Form.Control type="text" disabled value={formData.url} />
+ </Form.Group>
+ <Form.Group className="mb-3">
+ <Form.Label>{t('http_header.label')}</Form.Label>
+ <Form.Control
+ type="text"
+ disabled
+ value={formData.http_header}
+ />
+ <Form.Text className="text-muted">
+ {t('http_header.text')}
+ </Form.Text>
+ </Form.Group>
+ </>
+ )}
+ <Button variant="primary" type="submit">
+ {t('save', { keyPrefix: 'btns' })}
+ </Button>
+ </Form>
+ </div>
+ </>
+ );
+};
+
+export default Mcp;
diff --git a/ui/src/pages/Admin/QaSettings/index.tsx
b/ui/src/pages/Admin/QaSettings/index.tsx
index 0c2636ea..17834ab0 100644
--- a/ui/src/pages/Admin/QaSettings/index.tsx
+++ b/ui/src/pages/Admin/QaSettings/index.tsx
@@ -1,3 +1,22 @@
+/*
+ * 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 { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/ui/src/pages/Admin/Security/index.tsx
b/ui/src/pages/Admin/Security/index.tsx
index 07eaeb5b..35d7f2f6 100644
--- a/ui/src/pages/Admin/Security/index.tsx
+++ b/ui/src/pages/Admin/Security/index.tsx
@@ -1,3 +1,22 @@
+/*
+ * 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 { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/ui/src/pages/Admin/TagsSettings/index.tsx
b/ui/src/pages/Admin/TagsSettings/index.tsx
index f2ab2f5b..b857d989 100644
--- a/ui/src/pages/Admin/TagsSettings/index.tsx
+++ b/ui/src/pages/Admin/TagsSettings/index.tsx
@@ -1,3 +1,22 @@
+/*
+ * 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 { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/ui/src/pages/Admin/UsersSettings/index.tsx
b/ui/src/pages/Admin/UsersSettings/index.tsx
index 769c9f0a..55a3e784 100644
--- a/ui/src/pages/Admin/UsersSettings/index.tsx
+++ b/ui/src/pages/Admin/UsersSettings/index.tsx
@@ -1,3 +1,22 @@
+/*
+ * 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 { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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..4dbc6129
--- /dev/null
+++ b/ui/src/pages/AiAssistant/components/ConversationList/index.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 { 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..e2329f85
--- /dev/null
+++ b/ui/src/pages/AiAssistant/index.tsx
@@ -0,0 +1,380 @@
+/*
+ * 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 { 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,
+ },
+ ],
+ }));
+
+ // scroll to user message after the page height is stable
+ 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/Questions/Ask/index.tsx
b/ui/src/pages/Questions/Ask/index.tsx
index 5fb4d57f..ab680c49 100644
--- a/ui/src/pages/Questions/Ask/index.tsx
+++ b/ui/src/pages/Questions/Ask/index.tsx
@@ -279,10 +279,10 @@ const Ask = () => {
});
const handleAnswerChange = (value: string) =>
- setFormData({
- ...formData,
+ setFormData((prev) => ({
+ ...prev,
answer_content: { value, errorMsg: '', isInvalid: false },
- });
+ }));
const handleSummaryChange = (evt: React.ChangeEvent<HTMLInputElement>) =>
setFormData({
diff --git a/ui/src/pages/Questions/EditAnswer/index.tsx
b/ui/src/pages/Questions/EditAnswer/index.tsx
index 3be7befc..11b0411a 100644
--- a/ui/src/pages/Questions/EditAnswer/index.tsx
+++ b/ui/src/pages/Questions/EditAnswer/index.tsx
@@ -116,10 +116,10 @@ const Index = () => {
}, [formData.content.value, formData.description.value]);
const handleAnswerChange = (value: string) =>
- setFormData({
- ...formData,
- content: { ...formData.content, value },
- });
+ setFormData((prev) => ({
+ ...prev,
+ content: { ...prev.content, value },
+ }));
const handleSummaryChange = (evt) => {
const v = evt.currentTarget.value;
setFormData({
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/pages/Tags/Create/index.tsx
b/ui/src/pages/Tags/Create/index.tsx
index b0564f19..428ba941 100644
--- a/ui/src/pages/Tags/Create/index.tsx
+++ b/ui/src/pages/Tags/Create/index.tsx
@@ -100,10 +100,10 @@ const Index = () => {
]);
const handleDescriptionChange = (value: string) =>
- setFormData({
- ...formData,
- description: { ...formData.description, value, isInvalid: false },
- });
+ setFormData((prev) => ({
+ ...prev,
+ description: { value, isInvalid: false, errorMsg: '' },
+ }));
const checkValidated = (): boolean => {
let bol = true;
diff --git a/ui/src/pages/Tags/Edit/index.tsx b/ui/src/pages/Tags/Edit/index.tsx
index 0670b946..021ce1ce 100644
--- a/ui/src/pages/Tags/Edit/index.tsx
+++ b/ui/src/pages/Tags/Edit/index.tsx
@@ -117,10 +117,10 @@ const Index = () => {
]);
const handleDescriptionChange = (value: string) =>
- setFormData({
- ...formData,
- description: { ...formData.description, value },
- });
+ setFormData((prev) => ({
+ ...prev,
+ description: { value, isInvalid: false, errorMsg: '' },
+ }));
const checkValidated = (): boolean => {
let bol = true;
diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts
index 7d7003a1..8423fb7a 100644
--- a/ui/src/router/routes.ts
+++ b/ui/src/router/routes.ts
@@ -445,6 +445,22 @@ const routes: RouteNode[] = [
path: 'badges',
page: 'pages/Admin/Badges',
},
+ {
+ path: 'ai-assistant',
+ page: 'pages/Admin/AiAssistant',
+ },
+ {
+ path: 'ai-settings',
+ page: 'pages/Admin/AiSettings',
+ },
+ {
+ path: 'apikeys',
+ page: 'pages/Admin/Apikeys',
+ },
+ {
+ path: 'mcp',
+ page: 'pages/Admin/Mcp',
+ },
],
},
{
@@ -467,6 +483,26 @@ const routes: RouteNode[] = [
path: '50x',
page: 'pages/50X',
},
+ // ai
+ {
+ page: 'pages/SideNavLayoutWithoutFooter',
+ children: [
+ {
+ path: '/ai-assistant',
+ page: 'pages/AiAssistant',
+ guard: () => {
+ return guard.logged();
+ },
+ },
+ {
+ path: '/ai-assistant/:id',
+ page: '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..e20da7ac
--- /dev/null
+++ b/ui/src/services/admin/ai.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 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/pages/Search/components/index.ts
b/ui/src/services/admin/apikeys.ts
similarity index 50%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/services/admin/apikeys.ts
index 1ea0e991..8271e024 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/services/admin/apikeys.ts
@@ -17,11 +17,35 @@
* 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 useSWR from 'swr';
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+import request from '@/utils/request';
+import type * as Type from '@/common/interface';
+
+export const useQueryApiKeys = () => {
+ const apiUrl = `/answer/admin/api/api-key/all`;
+ const { data, error, mutate } = useSWR<Type.AdminApiKeysItem[], Error>(
+ apiUrl,
+ request.instance.get,
+ );
+ return {
+ data,
+ isLoading: !data && !error,
+ error,
+ mutate,
+ };
+};
+
+export const addApiKey = (params: Type.AddOrEditApiKeyParams) => {
+ return request.post('/answer/admin/api/api-key', params);
+};
+
+export const updateApiKey = (params: Type.AddOrEditApiKeyParams) => {
+ return request.put('/answer/admin/api/api-key', params);
+};
+
+export const deleteApiKey = (id: string) => {
+ return request.delete('/answer/admin/api/api-key', {
+ id,
+ });
+};
diff --git a/ui/src/services/admin/index.ts b/ui/src/services/admin/index.ts
index 3fa211ee..76d6ef90 100644
--- a/ui/src/services/admin/index.ts
+++ b/ui/src/services/admin/index.ts
@@ -25,4 +25,7 @@ export * from './users';
export * from './dashboard';
export * from './plugins';
export * from './badges';
+export * from './ai';
export * from './tags';
+export * from './apikeys';
+export * from './mcp';
diff --git a/ui/src/pages/Search/components/index.ts
b/ui/src/services/admin/mcp.ts
similarity index 68%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/services/admin/mcp.ts
index 1ea0e991..6fbbd735 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/services/admin/mcp.ts
@@ -17,11 +17,19 @@
* 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 request from '@/utils/request';
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+type McpConfig = {
+ enabled: boolean;
+ type: string;
+ url: string;
+ http_header: string;
+};
+
+export const getMcpConfig = () => {
+ return request.get<McpConfig>(`/answer/admin/api/mcp-config`);
+};
+
+export const saveMcpConfig = (params: { enabled: boolean }) => {
+ return request.put(`/answer/admin/api/mcp-config`, params);
+};
diff --git a/ui/src/pages/Search/components/index.ts
b/ui/src/services/client/ai.ts
similarity index 54%
copy from ui/src/pages/Search/components/index.ts
copy to ui/src/services/client/ai.ts
index 1ea0e991..28cc6398 100644
--- a/ui/src/pages/Search/components/index.ts
+++ b/ui/src/services/client/ai.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 qs from 'qs';
-export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };
+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 1bd2fece..41af8d55 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 siteSecurityStore from './siteSecurity';
+import aiControlStore from './aiControl';
export {
toastStore,
@@ -53,4 +54,5 @@ export {
commentReplyStore,
writeSettingStore,
siteSecurityStore,
+ aiControlStore,
};
diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts
index 6e6a6d80..fc78fa12 100644
--- a/ui/src/utils/guard.ts
+++ b/ui/src/utils/guard.ts
@@ -31,6 +31,7 @@ import {
pageTagStore,
writeSettingStore,
siteSecurityStore,
+ aiControlStore,
} from '@/stores';
import { RouteAlias } from '@/router/alias';
import {
@@ -386,6 +387,9 @@ export const initAppSettingsStore = async () => {
...appSettings.site_questions,
...appSettings.site_tags,
});
+ aiControlStore.getState().update({
+ ai_enabled: appSettings.ai_enabled,
+ });
siteSecurityStore.getState().update(appSettings.site_security);
}
};
diff --git a/ui/src/utils/requestAi.ts b/ui/src/utils/requestAi.ts
new file mode 100644
index 00000000..444bbe6d
--- /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;
+}
+
+// create a object to track the current request state
+const requestState = {
+ currentReader: null as ReadableStreamDefaultReader<Uint8Array> | null,
+ abortController: null as AbortController | null,
+ isProcessing: false,
+};
+
+// HTTP error handling function (based on request.ts logic)
+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 there is a previous request being processed, cancel it
+ if (requestState.isProcessing && requestState.abortController) {
+ requestState.abortController.abort();
+ }
+
+ // create a new AbortController
+ const abortController = new AbortController();
+ requestState.abortController = abortController;
+
+ // merge the incoming signal with the new created signal
+ const combinedSignal = options.signal || abortController.signal;
+
+ // mark as being processed
+ requestState.isProcessing = true;
+
+ // get the authentication information and language settings (consistent
with 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,
+ },
+ });
+
+ // unified error handling (based on request.ts logic)
+ if (!response.ok) {
+ await handleHttpError(response, options);
+ return;
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error('ReadableStream not supported');
+ }
+
+ // store the current reader so it can be cancelled later
+ 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 {
+ // handle the special [DONE] signal
+ const cleanedLine = line.replace(/^data: /, '').trim();
+ if (cleanedLine === '[DONE]') {
+ return; // skip the [DONE] signal processing
+ }
+
+ if (cleanedLine) {
+ const parsedLine = JSON.parse(cleanedLine);
+ options.onMessage?.(parsedLine);
+ }
+ } catch (error) {
+ console.debug('Error parsing line:', line);
+ }
+ }
+ });
+
+ // check if it has been cancelled
+ 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; // rethrow other errors
+ }
+ }
+ };
+
+ await processStream();
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+
+ // if the error is caused by cancellation, do not treat it as an 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?.(); // cancellation is also considered complete
+ }
+ } finally {
+ requestState.isProcessing = false;
+ requestState.currentReader = null;
+ }
+};
+
+// add a function to cancel the current request
+const cancelCurrentRequest = () => {
+ if (requestState.abortController) {
+ requestState.abortController.abort();
+ console.log('AI request cancelled by user');
+ return true;
+ }
+ return false;
+};
+
+export { cancelCurrentRequest };
+export default requestAi;