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