This is an automated email from the ASF dual-hosted git repository. shuai pushed a commit to branch ai-ui in repository https://gitbox.apache.org/repos/asf/answer.git
commit 8fa8a021cfa994d6bfa9fd7adabccefab142c4ed Merge: 10c674e0 c509723f Author: shuai <[email protected]> AuthorDate: Thu Jan 22 17:01:24 2026 +0800 feat: add apikeys and mcp pages cmd/wire_gen.go | 72 +- docs/docs.go | 1007 +++++++++++++------- docs/swagger.json | 990 ++++++++++++------- docs/swagger.yaml | 528 +++++++--- go.mod | 2 +- i18n/en_US.yaml | 53 +- i18n/zh_CN.yaml | 55 +- internal/base/constant/site_type.go | 21 +- internal/base/middleware/auth.go | 4 +- internal/base/middleware/visit_img_auth.go | 4 +- internal/controller/answer_controller.go | 2 +- internal/controller/siteinfo_controller.go | 22 +- internal/controller/template_controller.go | 2 +- internal/controller_admin/siteinfo_controller.go | 188 +++- internal/migrations/init.go | 85 +- internal/migrations/migrations.go | 1 + internal/migrations/v30.go | 396 ++++++++ internal/router/answer_api_router.go | 21 +- internal/schema/siteinfo_schema.go | 118 ++- internal/service/dashboard/dashboard_service.go | 17 +- internal/service/mock/siteinfo_repo_mock.go | 139 ++- internal/service/question_common/question.go | 2 +- internal/service/siteinfo/siteinfo_service.go | 106 ++- .../service/siteinfo_common/siteinfo_service.go | 72 +- internal/service/tag_common/tag_common.go | 8 +- internal/service/uploader/upload.go | 32 +- ui/src/common/constants.ts | 63 +- ui/src/common/interface.ts | 52 +- ui/src/components/AccordionNav/index.tsx | 65 +- ui/src/components/AdminSideNav/index.tsx | 13 +- ui/src/components/BubbleAi/index.tsx | 2 +- ui/src/components/SchemaForm/components/Switch.tsx | 3 +- ui/src/components/SchemaForm/index.tsx | 8 +- ui/src/components/SchemaForm/types.ts | 2 +- ui/src/components/TabNav/index.tsx | 26 + ui/src/components/index.ts | 2 + .../components/Action/index.tsx | 2 +- .../components/DetailModal/index.tsx | 4 +- .../Admin/{Conversations => AiAssistant}/index.tsx | 2 +- ui/src/pages/Admin/AiSettings/index.tsx | 226 +++-- ui/src/pages/Admin/Answers/index.tsx | 8 +- .../components/Action/index.tsx | 52 +- .../Apikeys/components/AddOrEditModal/index.tsx | 165 ++++ .../Apikeys/components/CreatedModal/index.tsx | 36 + ui/src/pages/Admin/Apikeys/components/index.ts | 5 + ui/src/pages/Admin/Apikeys/index.tsx | 119 +++ ui/src/pages/Admin/Branding/index.tsx | 16 +- ui/src/pages/Admin/CssAndHtml/index.tsx | 16 +- .../Dashboard/components/HealthStatus/index.tsx | 6 +- ui/src/pages/Admin/Files/index.tsx | 261 +++++ ui/src/pages/Admin/General/index.tsx | 29 +- ui/src/pages/Admin/Interface/index.tsx | 75 +- ui/src/pages/Admin/Login/index.tsx | 30 +- ui/src/pages/Admin/Mcp/index.tsx | 94 ++ ui/src/pages/Admin/Plugins/Config/index.tsx | 18 +- ui/src/pages/Admin/{Legal => Policies}/index.tsx | 63 +- ui/src/pages/Admin/Privileges/index.tsx | 32 +- ui/src/pages/Admin/QaSettings/index.tsx | 132 +++ ui/src/pages/Admin/Questions/index.tsx | 4 +- ui/src/pages/Admin/Security/index.tsx | 145 +++ ui/src/pages/Admin/Seo/index.tsx | 16 +- ui/src/pages/Admin/Smtp/index.tsx | 16 +- ui/src/pages/Admin/TagsSettings/index.tsx | 170 ++++ ui/src/pages/Admin/Themes/index.tsx | 16 +- ui/src/pages/Admin/Users/index.tsx | 3 + ui/src/pages/Admin/UsersSettings/index.tsx | 132 +++ ui/src/pages/Admin/Write/index.tsx | 473 --------- ui/src/pages/Admin/index.scss | 4 + ui/src/pages/Admin/index.tsx | 16 +- ui/src/pages/Layout/index.tsx | 5 +- ui/src/pages/Questions/Ask/index.tsx | 6 +- ui/src/pages/Users/Settings/Profile/index.tsx | 10 +- ui/src/router/routes.ts | 54 +- ui/src/services/admin/{question.ts => apikeys.ts} | 27 +- ui/src/services/admin/index.ts | 3 + ui/src/services/admin/mcp.ts | 16 + ui/src/services/admin/question.ts | 10 + ui/src/services/admin/settings.ts | 38 +- .../Admin/index.scss => services/admin/tags.ts} | 23 +- ui/src/services/admin/users.ts | 19 + ui/src/stores/index.ts | 4 +- ui/src/stores/interface.ts | 4 +- ui/src/stores/loginSetting.ts | 1 - ui/src/stores/siteInfo.ts | 3 +- ui/src/stores/{siteLegal.ts => siteSecurity.ts} | 16 +- ui/src/stores/writeSetting.ts | 12 +- ui/src/utils/guard.ts | 15 +- 87 files changed, 4856 insertions(+), 1979 deletions(-) diff --cc i18n/en_US.yaml index d8d31e44,ea607366..9a0d198b --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@@ -1826,10 -1816,13 +1826,18 @@@ ui plugins: Plugins installed_plugins: Installed Plugins apperance: Appearance - AI: AI - conversations: Conversations - ai_settings: Settings + community: Community + advanced: Advanced + tags: Tags + rules: Rules + 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 @@@ -2305,37 -2298,6 +2313,70 @@@ 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: - title: Model + 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: - title: 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 --cc i18n/zh_CN.yaml index 0779e3a5,59ef41be..07589872 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@@ -1788,10 -1778,13 +1788,18 @@@ ui plugins: 插件 installed_plugins: 已安装插件 apperance: 外观 - AI: AI - conversations: 对话 - ai_settings: 设置 + community: 社区 + advanced: 高级 + tags: 标签 + rules: 规则 + policies: 政策 + security: 安全 + files: 文件 ++ apikeys: API Keys ++ intelligence: 人工智能 ++ ai_assistant: AI 助手 ++ ai_settings: AI 设置 + mcp: MCP website_welcome: 欢迎来到 {{site_name}} user_center: login: 登录 @@@ -2266,37 -2259,6 +2274,70 @@@ 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: - title: 模型 + 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: - title: 对话 + 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 --cc ui/src/common/constants.ts index c32b26e3,285da474..2e10f149 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@@ -92,23 -97,19 +97,27 @@@ export const ADMIN_NAV_MENUS = { name: 'contents', icon: 'file-earmark-text-fill', - children: [{ name: 'questions' }, { name: 'answers' }], + children: [ + { name: 'questions', path: 'qa/questions', pathPrefix: 'qa/' }, + { name: 'tags', path: 'tags/settings', pathPrefix: 'tags/' }, + ], }, + { - name: 'AI', ++ name: 'intelligence', + icon: 'robot', + children: [ - { name: 'conversations' }, + { name: 'ai_settings', path: 'ai-settings' }, ++ { name: 'ai_assistant', path: 'ai-assistant' }, + ], + }, { - name: 'users', + name: 'community', icon: 'people-fill', - }, - { - name: 'badges', - icon: 'award-fill', + children: [ + { name: 'users', pathPrefix: 'users/' }, + { name: 'badges' }, + { name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' }, + ], }, { name: 'apperance', @@@ -128,14 -130,11 +138,13 @@@ icon: 'gear-fill', children: [ { name: 'general' }, - { name: 'interface' }, - { name: 'smtp' }, - { name: 'legal' }, - { name: 'write' }, - { name: 'seo' }, + { name: 'security' }, + { name: 'files' }, { name: 'login' }, - { name: 'privileges' }, + { name: 'seo' }, + { name: 'smtp' }, ++ { name: 'apikeys' }, + { name: 'mcp' }, ], }, { diff --cc ui/src/common/interface.ts index 920942d7,aac89cce..308726e8 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@@ -416,11 -421,12 +421,13 @@@ export interface SiteSettings theme: AdminSettingsTheme; site_seo: AdminSettingsSeo; site_users: AdminSettingsUsers; - site_write: AdminSettingsWrite; + site_advanced: AdminSettingsWrite; + site_questions: AdminQuestionSetting; + site_tags: AdminTagsSetting; version: string; revision: string; - site_legal: AdminSettingsLegal; + site_security: AdminSettingsSecurity; + ai_enabled: boolean; } export interface AdminSettingBranding { @@@ -811,57 -809,14 +810,84 @@@ 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; + restrict_answer: boolean; + } + + export interface AdminTagsSetting { + recommend_tags: Tag[]; + required_tag: boolean; + reserved_tags: Tag[]; + } diff --cc ui/src/components/BubbleAi/index.tsx index 6c03580c,00000000..52663686 mode 100644,000000..100644 --- a/ui/src/components/BubbleAi/index.tsx +++ b/ui/src/components/BubbleAi/index.tsx @@@ -1,240 -1,0 +1,240 @@@ +import { FC, useEffect, useState, useRef } from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { marked } from 'marked'; +import copy from 'copy-to-clipboard'; + - import { voteConversation } from '@/enterprise/services'; ++import { 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); + // 添加ref用于ScrollIntoView + const containerRef = useRef<HTMLDivElement>(null); + + const handleCopy = () => { + const res = copy(displayContent); + if (res) { + setCopyText(t('copied', { keyPrefix: 'messages' })); + setTimeout(() => { + setCopyText(t('copy')); + }, 1200); + } + }; + + const handleVote = (voteType: 'helpful' | 'unhelpful') => { + const isCancel = + (voteType === 'helpful' && isHelpful) || + (voteType === 'unhelpful' && isUnhelpful); + voteConversation({ + chat_completion_id: chatId, + cancel: isCancel, + vote_type: voteType, + }).then(() => { + setIsHelpful(voteType === 'helpful' && !isCancel); + setIsUnhelpful(voteType === 'unhelpful' && !isCancel); + }); + }; + + useEffect(() => { + if ((!canType || !isLast) && content) { + // 如果不是最后一个消息,直接返回,不进行打字效果 + if (typewriterRef.current.timer) { + clearInterval(typewriterRef.current.timer); + typewriterRef.current.timer = null; + } + setDisplayContent(content); + setCanShowAction(true); + typewriterRef.current.timer = null; + typewriterRef.current.isTyping = false; + return; + } + // 当内容变化时,清理之前的计时器 + if (typewriterRef.current.timer) { + clearInterval(typewriterRef.current.timer); + typewriterRef.current.timer = null; + } + + // 如果内容为空,则直接返回 + if (!content) { + setDisplayContent(''); + return; + } + + // 如果内容比当前显示的短,则重置 + if (content.length < displayContent.length) { + setDisplayContent(''); + typewriterRef.current.index = 0; + } + + // 如果内容与显示内容相同,不需要做任何事 + if (content === displayContent) { + return; + } + + typewriterRef.current.isTyping = true; + + // start typing animation + typewriterRef.current.timer = setInterval(() => { + const currentIndex = typewriterRef.current.index; + if (currentIndex < content.length) { + const remainingLength = content.length - currentIndex; + const baseRandomNum = Math.floor(Math.random() * 3) + 2; + let randomNum = Math.min(baseRandomNum, remainingLength); + + // 简单的单词边界检查(可选) + const nextChar = content[currentIndex + randomNum]; + const prevChar = content[currentIndex + randomNum - 1]; + + // 如果下一个字符是字母,当前字符也是字母,尝试调整到空格处 + if ( + nextChar && + /[a-zA-Z]/.test(nextChar) && + /[a-zA-Z]/.test(prevChar) + ) { + // 向前找1-2个字符,看看有没有空格 + for ( + let i = 1; + i <= 2 && currentIndex + randomNum - i > currentIndex; + i += 1 + ) { + if (content[currentIndex + randomNum - i] === ' ') { + randomNum = randomNum - i + 1; + break; + } + } + // 向后找1-2个字符,看看有没有空格 + for ( + let i = 1; + i <= 2 && currentIndex + randomNum + i < content.length; + i += 1 + ) { + if (content[currentIndex + randomNum + i] === ' ') { + randomNum = randomNum + i + 1; + break; + } + } + } + + const nextIndex = currentIndex + randomNum; + const newContent = content.substring(0, nextIndex); + setDisplayContent(newContent); + typewriterRef.current.index = nextIndex; + setCanShowAction(false); + } else { + clearInterval(typewriterRef.current.timer as NodeJS.Timeout); + typewriterRef.current.timer = null; + typewriterRef.current.isTyping = false; + setCanShowAction(false); + } + }, 30); + + // eslint-disable-next-line consistent-return + return () => { + if (typewriterRef.current.timer) { + clearInterval(typewriterRef.current.timer); + typewriterRef.current.timer = null; + } + }; + }, [content, isCompleted]); + + useEffect(() => { + setIsHelpful(actionData.helpful > 0); + setIsUnhelpful(actionData.unhelpful > 0); + }, [actionData]); + + useEffect(() => { + if (fmtContainer.current && isCompleted) { + htmlRender(fmtContainer.current, { + copySuccessText: t('copied', { keyPrefix: 'messages' }), + copyText: t('copy', { keyPrefix: 'messages' }), + }); + const links = fmtContainer.current.querySelectorAll('a'); + links.forEach((link) => { + link.setAttribute('target', '_blank'); + }); + setCanShowAction(true); + } + }, [isCompleted, fmtContainer.current]); + + return ( + <div + className="rounded bubble-ai" + ref={containerRef} + style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}> + <div id={chatId}> + <div + className="fmt text-break text-wrap" + ref={fmtContainer} + style={{ transition: 'all 0.2s ease' }} + dangerouslySetInnerHTML={{ __html: marked.parse(displayContent) }} + /> + + {canShowAction && ( + <div className="action"> + <Button + variant="link" + className="p-0 link-secondary small me-3" + onClick={handleCopy}> + <Icon name="copy" /> + <span className="ms-1">{copyText}</span> + </Button> + <Button + variant="link" + className={`p-0 small me-3 ${isHelpful ? 'link-primary active' : 'link-secondary'}`} + onClick={() => handleVote('helpful')}> + <Icon name="hand-thumbs-up-fill" /> + <span className="ms-1">Helpful</span> + </Button> + <Button + variant="link" + className={`p-0 small me-3 ${isUnhelpful ? 'link-primary active' : 'link-secondary'}`} + onClick={() => handleVote('unhelpful')}> + <Icon name="hand-thumbs-down-fill" /> + <span className="ms-1">Unhelpful</span> + </Button> + </div> + )} + </div> + </div> + ); +}; + +export default BubbleAi; diff --cc ui/src/components/index.ts index e8afa157,416db241..5c81739b --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@@ -64,9 -64,7 +64,10 @@@ 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 { Avatar, @@@ -118,8 -116,6 +119,9 @@@ PinList, MobileSideNav, AdminSideNav, + BubbleAi, + BubbleUser, + Sender, + TabNav, }; export type { EditorRef, JSONSchema, UISchema }; diff --cc ui/src/pages/Admin/AiAssistant/components/Action/index.tsx index 577d16c3,00000000..c9702ea6 mode 100644,000000..100644 --- a/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx +++ b/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx @@@ -1,71 -1,0 +1,71 @@@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Dropdown } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { Modal, Icon } from '@/components'; - import { deleteAdminConversation } from '@/enterprise/services'; ++import { 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 --cc ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx index 2d408c36,00000000..c308c980 mode 100644,000000..100644 --- a/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx +++ b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx @@@ -1,67 -1,0 +1,67 @@@ +import { FC, memo } from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + - import { BubbleAi, BubbleUser } from '@/enterprise/components'; - import { useQueryAdminConversationDetail } from '@/enterprise/services'; ++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 --cc ui/src/pages/Admin/AiAssistant/index.tsx index ed1ee333,00000000..f3aabb55 mode 100644,000000..100644 --- a/ui/src/pages/Admin/AiAssistant/index.tsx +++ b/ui/src/pages/Admin/AiAssistant/index.tsx @@@ -1,111 -1,0 +1,111 @@@ +import { useState } from 'react'; +import { Table, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +import { BaseUserCard, FormatTime, Pagination, Empty } from '@/components'; +import { useQueryAdminConversationList } from '@/services'; + +import DetailModal from './components/DetailModal'; +import Action from './components/Action'; + +const Index = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.conversations', + }); + const [urlSearchParams] = useSearchParams(); + const curPage = Number(urlSearchParams.get('page') || '1'); + const PAGE_SIZE = 20; + const [detailModal, setDetailModal] = useState({ + visible: false, + id: '', + }); + const { + data: conversations, + isLoading, + mutate: refreshList, + } = useQueryAdminConversationList({ + page: curPage, + page_size: PAGE_SIZE, + }); + + const handleShowDetailModal = (data) => { + setDetailModal({ + visible: true, + id: data.id, + }); + }; + + const handleHideDetailModal = () => { + setDetailModal({ + visible: false, + id: '', + }); + }; + + return ( + <div className="d-flex flex-column flex-grow-1 position-relative"> - <h3 className="mb-4">{t('title')}</h3> ++ <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 --cc ui/src/pages/Admin/AiSettings/index.tsx index 368b01a0,00000000..2bc30fef mode 100644,000000..100644 --- a/ui/src/pages/Admin/AiSettings/index.tsx +++ b/ui/src/pages/Admin/AiSettings/index.tsx @@@ -1,437 -1,0 +1,467 @@@ +import { useEffect, useState, useRef } from 'react'; +import { Form, InputGroup, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { + getAiConfig, + useQueryAiProvider, + checkAiConfig, + saveAiConfig, +} from '@/services'; +import { aiControlStore } from '@/stores'; +import { handleFormError } from '@/utils'; +import { useToast } from '@/hooks'; +import * as Type from '@/common/interface'; + +const Index = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.ai_settings', + }); + const toast = useToast(); + const historyConfigRef = useRef<Type.AiConfig>(); + // const [historyConfig, setHistoryConfig] = useState<Type.AiConfig>(); + const { data: aiProviders } = useQueryAiProvider(); + + const [formData, setFormData] = useState({ + enabled: { + value: false, + isInvalid: false, + errorMsg: '', + }, + provider: { + value: '', + isInvalid: false, + errorMsg: '', + }, + api_host: { + value: '', + isInvalid: false, + errorMsg: '', + }, + api_key: { + value: '', + isInvalid: false, + isValid: false, + errorMsg: '', + }, + model: { + value: '', + isInvalid: false, + errorMsg: '', + }, + }); + const [apiHostPlaceholder, setApiHostPlaceholder] = useState(''); + const [modelsData, setModels] = useState<{ id: string }[]>([]); + const [isChecking, setIsChecking] = useState(false); + + const getCurrentProviderData = (provider) => { + const findHistoryProvider = + historyConfigRef.current?.ai_providers.find( + (v) => v.provider === provider, + ) || historyConfigRef.current?.ai_providers[0]; + + return findHistoryProvider; + }; + + const checkAiConfigData = (data) => { + const params = data || { + api_host: formData.api_host.value || apiHostPlaceholder, + api_key: formData.api_key.value, + }; + setIsChecking(true); + + checkAiConfig(params) + .then((res) => { + setModels(res); + const findHistoryProvider = getCurrentProviderData( + formData.provider.value, + ); + + setIsChecking(false); ++ + if (!data) { + setFormData({ + ...formData, + api_key: { + ...formData.api_key, + errorMsg: t('api_key.check_success'), + isInvalid: false, + isValid: true, + }, + model: { + value: findHistoryProvider?.model || res[0].id, + errorMsg: '', + isInvalid: false, + }, + }); + } + }) + .catch((err) => { + console.error('Checking AI config:', err); + setIsChecking(false); + }); + }; + + const handleProviderChange = (value) => { + const findHistoryProvider = getCurrentProviderData(value); + setFormData({ + ...formData, + provider: { + value, + isInvalid: false, + errorMsg: '', + }, + api_host: { + value: findHistoryProvider?.api_host || '', + isInvalid: false, + errorMsg: '', + }, + api_key: { + value: findHistoryProvider?.api_key || '', + isInvalid: false, + isValid: false, + errorMsg: '', + }, + model: { + value: findHistoryProvider?.model || '', + isInvalid: false, + errorMsg: '', + }, + }); + const provider = aiProviders?.find((item) => item.name === value); + const host = findHistoryProvider?.api_host || provider?.default_api_host; + if (findHistoryProvider?.model) { + checkAiConfigData({ + api_host: host, + api_key: findHistoryProvider.api_key, + }); + } else { + setModels([]); + } + }; + + const handleValueChange = (value) => { + setFormData((prev) => ({ + ...prev, + ...value, + })); + }; + + const checkValidate = () => { + let bol = true; + + const { api_host, api_key, model } = formData; + + if (!api_host.value) { + bol = false; + formData.api_host = { + value: '', + isInvalid: true, + errorMsg: t('api_host.msg'), + }; + } + + if (!api_key.value) { + bol = false; + formData.api_key = { + value: '', + isInvalid: true, + isValid: false, + errorMsg: t('api_key.msg'), + }; + } + + if (!model.value) { + bol = false; + formData.model = { + value: '', + isInvalid: true, + errorMsg: t('model.msg'), + }; + } + + setFormData({ + ...formData, + }); + + return bol; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + if (!checkValidate()) { + return; + } + const newProviders = historyConfigRef.current?.ai_providers.map((v) => { + if (v.provider === formData.provider.value) { + return { + provider: formData.provider.value, + api_host: formData.api_host.value, + api_key: formData.api_key.value, + model: formData.model.value, + }; + } + return v; + }); + + const params = { + enabled: formData.enabled.value, + chosen_provider: formData.provider.value, + ai_providers: newProviders, + }; + saveAiConfig(params) + .then(() => { + aiControlStore.getState().update({ + ai_enabled: formData.enabled.value, + }); + + historyConfigRef.current = { + ...params, + ai_providers: params.ai_providers || [], + }; + + toast.onShow({ + msg: t('add_success'), + variant: 'success', + }); + }) + .catch((err) => { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + ele?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }; + + const getAiConfigData = async () => { + const aiConfig = await getAiConfig(); + historyConfigRef.current = aiConfig; + + const currentAiConfig = getCurrentProviderData(aiConfig.chosen_provider); + if (currentAiConfig?.model) { + const provider = aiProviders?.find( + (item) => item.name === formData.provider.value, + ); + const host = currentAiConfig.api_host || provider?.default_api_host; + checkAiConfigData({ + api_host: host, + api_key: currentAiConfig.api_key, + }); + } + + setFormData({ + enabled: { + value: aiConfig.enabled || false, + isInvalid: false, + errorMsg: '', + }, + provider: { + value: currentAiConfig?.provider || '', + isInvalid: false, + errorMsg: '', + }, + api_host: { + value: currentAiConfig?.api_host || '', + isInvalid: false, + errorMsg: '', + }, + api_key: { + value: currentAiConfig?.api_key || '', + isInvalid: false, + isValid: false, + errorMsg: '', + }, + model: { + value: currentAiConfig?.model || '', + isInvalid: false, + errorMsg: '', + }, + }); + }; + + useEffect(() => { + getAiConfigData(); + }, []); + + useEffect(() => { + if (formData.provider.value) { + const provider = aiProviders?.find( + (item) => item.name === formData.provider.value, + ); + if (provider) { + setApiHostPlaceholder(provider.default_api_host || ''); + } + } + if (!formData.provider.value && aiProviders) { + setFormData((prev) => ({ + ...prev, + provider: { + ...prev.provider, + value: aiProviders[0].name, + }, + })); + } + }, [aiProviders, formData]); + + return ( + <div> - <h3 className="mb-4">{t('title')}</h3> - <Form noValidate onSubmit={handleSubmit}> - <Form.Group className="mb-3" controlId="enabled"> - <Form.Label>{t('enabled.label')}</Form.Label> - <Form.Switch - type="switch" - id="enabled" - label={t('enabled.check')} - checked={formData.enabled.value} - onChange={(e) => - handleValueChange({ - enabled: { - value: e.target.checked, - errorMsg: '', - isInvalid: false, - }, - }) - } - /> - <Form.Control.Feedback type="invalid"> - {formData.enabled.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="provider"> - <Form.Label>{t('provider.label')}</Form.Label> - <Form.Select - isInvalid={formData.provider.isInvalid} - value={formData.provider.value} - onChange={(e) => handleProviderChange(e.target.value)}> - {aiProviders?.map((provider) => ( - <option key={provider.name} value={provider.name}> - {provider.display_name} - </option> - ))} - </Form.Select> - <Form.Control.Feedback type="invalid"> - {formData.provider.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="api_host"> - <Form.Label>{t('api_host.label')}</Form.Label> - <Form.Control - type="text" - autoComplete="off" - placeholder={apiHostPlaceholder} - isInvalid={formData.api_host.isInvalid} - value={formData.api_host.value} - onChange={(e) => - handleValueChange({ - api_host: { - value: e.target.value, - errorMsg: '', - isInvalid: false, - }, - }) - } - /> - <Form.Control.Feedback type="invalid"> - {formData.api_host.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Form.Group className="mb-3" controlId="api_key"> - <Form.Label>{t('api_key.label')}</Form.Label> - <InputGroup> ++ <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="password" - autoComplete="new-password" - isInvalid={formData.api_key.isInvalid} - isValid={formData.api_key.isValid} - value={formData.api_key.value} ++ type="text" ++ autoComplete="off" ++ placeholder={apiHostPlaceholder} ++ isInvalid={formData.api_host.isInvalid} ++ value={formData.api_host.value} + onChange={(e) => + handleValueChange({ - api_key: { ++ api_host: { + 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 type="invalid"> ++ {formData.api_host.errorMsg} + </Form.Control.Feedback> - </InputGroup> - </Form.Group> - - <Form.Group className="mb-3" controlId="model"> - <Form.Label>{t('model.label')}</Form.Label> - <Form.Select ++ </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> - <Form.Control.Feedback type="invalid"> - {formData.model.errorMsg} - </Form.Control.Feedback> - </Form.Group> - - <Button type="submit">{t('save', { keyPrefix: 'btns' })}</Button> - </Form> ++ </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 --cc ui/src/pages/Admin/Apikeys/components/Action/index.tsx index 577d16c3,00000000..f3a7cbcc mode 100644,000000..100644 --- a/ui/src/pages/Admin/Apikeys/components/Action/index.tsx +++ b/ui/src/pages/Admin/Apikeys/components/Action/index.tsx @@@ -1,71 -1,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 { Modal, Icon } from '@/components'; - import { deleteAdminConversation } from '@/enterprise/services'; - import { useToast } from '@/hooks'; ++import { Icon, Modal } from '@/components'; ++import { deleteApiKey } from '@/services'; ++import { toastStore } from '@/stores'; + - interface Props { - id: string; - refreshList?: () => void; - } - const ConversationsOperation = ({ id, refreshList }: Props) => { ++const ApiActions = ({ itemData, refreshList, showModal }) => { + const { t } = useTranslation('translation', { - keyPrefix: 'admin.conversations', ++ keyPrefix: 'admin.apikeys.delete_modal', + }); - const toast = useToast(); + - const handleAction = (eventKey: string | null) => { - if (eventKey === 'delete') { ++ const handleAction = (type) => { ++ if (type === 'delete') { + Modal.confirm({ - title: t('delete_modal.title'), - content: t('delete_modal.content'), ++ title: t('title'), ++ content: t('content'), + cancelBtnVariant: 'link', + confirmBtnVariant: 'danger', + confirmText: t('delete', { keyPrefix: 'btns' }), + onConfirm: () => { - deleteAdminConversation(id).then(() => { - refreshList?.(); - toast.onShow({ ++ deleteApiKey(itemData.id).then(() => { ++ toastStore.getState().show({ ++ msg: t('api_key_deleted', { keyPrefix: 'messages' }), + variant: 'success', - msg: t('delete_modal.delete_success'), + }); ++ refreshList(); + }); + }, + }); + } ++ ++ if (type === 'edit') { ++ showModal(true); ++ } + }; + + return ( - <Dropdown onSelect={handleAction}> - <Dropdown.Toggle variant="link" className="no-toggle p-0 lh-1"> - <Icon name="three-dots-vertical" title={t('action')} /> ++ <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 eventKey="delete"> ++ <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 ConversationsOperation; ++export default ApiActions; diff --cc ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx index 00000000,00000000..34a3192a new file mode 100644 --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx @@@ -1,0 -1,0 +1,165 @@@ ++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 --cc ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx index 00000000,00000000..10d16744 new file mode 100644 --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx @@@ -1,0 -1,0 +1,36 @@@ ++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 --cc ui/src/pages/Admin/Apikeys/components/index.ts index 00000000,00000000..539b7d79 new file mode 100644 --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/components/index.ts @@@ -1,0 -1,0 +1,5 @@@ ++import Action from './Action'; ++import AddOrEditModal from './AddOrEditModal'; ++import CreatedModal from './CreatedModal'; ++ ++export { Action, AddOrEditModal, CreatedModal }; diff --cc ui/src/pages/Admin/Apikeys/index.tsx index 00000000,00000000..30a994c7 new file mode 100644 --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/index.tsx @@@ -1,0 -1,0 +1,119 @@@ ++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 --cc ui/src/pages/Admin/Mcp/index.tsx index 00000000,00000000..118612b7 new file mode 100644 --- /dev/null +++ b/ui/src/pages/Admin/Mcp/index.tsx @@@ -1,0 -1,0 +1,94 @@@ ++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 --cc ui/src/router/routes.ts index 2328d526,7d7003a1..8423fb7a --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@@ -429,14 -445,6 +445,22 @@@ const routes: RouteNode[] = path: 'badges', page: 'pages/Admin/Badges', }, + { - path: 'conversations', - page: '@/enterprise/pages/Admin/Conversations', ++ path: 'ai-assistant', ++ page: 'pages/Admin/AiAssistant', + }, + { + path: 'ai-settings', - page: '@/enterprise/pages/Admin/AiSettings', ++ page: 'pages/Admin/AiSettings', ++ }, ++ { ++ path: 'apikeys', ++ page: 'pages/Admin/Apikeys', ++ }, ++ { ++ path: 'mcp', ++ page: 'pages/Admin/Mcp', + }, ], }, { @@@ -459,26 -467,6 +483,26 @@@ path: '50x', page: 'pages/50X', }, + // ai + { + page: 'pages/SideNavLayoutWithoutFooter', + children: [ + { + path: '/ai-assistant', - page: '@/enterprise/pages/AiAssistant', ++ page: 'pages/AiAssistant', + guard: () => { + return guard.logged(); + }, + }, + { + path: '/ai-assistant/:id', - page: '@/enterprise/pages/AiAssistant', ++ page: 'pages/AiAssistant', + guard: () => { + return guard.logged(); + }, + }, + ], + }, ], }, { diff --cc ui/src/services/admin/apikeys.ts index 670534e2,1ea0e991..8271e024 --- a/ui/src/services/admin/apikeys.ts +++ b/ui/src/services/admin/apikeys.ts @@@ -17,32 -17,11 +17,35 @@@ * under the License. */ - import qs from 'qs'; -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 useQuestionSearch = (params: Type.AdminContentsReq) => { - const apiUrl = `/answer/admin/api/question/page?${qs.stringify(params)}`; - const { data, error, mutate } = useSWR<Type.ListResult, Error>( - [apiUrl], ++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 changeQuestionStatus = ( - question_id: string, - status: Type.AdminQuestionStatus, - ) => { - return request.put('/answer/admin/api/question/status', { - question_id, - status, ++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 --cc ui/src/services/admin/index.ts index c2c630cf,3fa211ee..76d6ef90 --- a/ui/src/services/admin/index.ts +++ b/ui/src/services/admin/index.ts @@@ -25,4 -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 --cc ui/src/services/admin/mcp.ts index 00000000,00000000..ab86e6db new file mode 100644 --- /dev/null +++ b/ui/src/services/admin/mcp.ts @@@ -1,0 -1,0 +1,16 @@@ ++import request from '@/utils/request'; ++ ++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 --cc ui/src/stores/index.ts index 1e3aa25d,1bd2fece..41af8d55 --- a/ui/src/stores/index.ts +++ b/ui/src/stores/index.ts @@@ -33,8 -33,7 +33,8 @@@ import loginToContinueStore from './log import errorCodeStore from './errorCode'; import sideNavStore from './sideNav'; import commentReplyStore from './commentReply'; - import siteLealStore from './siteLegal'; + import siteSecurityStore from './siteSecurity'; +import aiControlStore from './aiControl'; export { toastStore, @@@ -53,6 -52,5 +53,6 @@@ sideNavStore, commentReplyStore, writeSettingStore, - siteLealStore, + siteSecurityStore, + aiControlStore, }; diff --cc ui/src/utils/guard.ts index e1d2ceb5,6e6a6d80..fc78fa12 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@@ -30,8 -30,7 +30,8 @@@ import loginToContinueStore, pageTagStore, writeSettingStore, - siteLealStore, + siteSecurityStore, + aiControlStore, } from '@/stores'; import { RouteAlias } from '@/router/alias'; import { @@@ -383,15 -382,11 +383,14 @@@ export const initAppSettingsStore = asy themeSettingStore.getState().update(appSettings.theme); seoSettingStore.getState().update(appSettings.site_seo); writeSettingStore.getState().update({ - restrict_answer: appSettings.site_write.restrict_answer, - ...appSettings.site_write, - }); - siteLealStore.getState().update({ - external_content_display: appSettings.site_legal.external_content_display, + ...appSettings.site_advanced, + ...appSettings.site_questions, + ...appSettings.site_tags, }); + aiControlStore.getState().update({ + ai_enabled: appSettings.ai_enabled, + }); + siteSecurityStore.getState().update(appSettings.site_security); } };
