diff --git a/frontend/src/router/DocsQA.tsx b/frontend/src/router/DocsQA.tsx index 717014ad..6a9e448b 100644 --- a/frontend/src/router/DocsQA.tsx +++ b/frontend/src/router/DocsQA.tsx @@ -6,7 +6,7 @@ import ScreenFallbackLoader from '@/components/base/molecules/ScreenFallbackLoad import DataHub from '@/screens/dashboard/docsqa/DataSources' import NavBar from '@/screens/dashboard/docsqa/Navbar' import Applications from '@/screens/dashboard/docsqa/Applications' -const DocsQA = lazy(() => import('@/screens/dashboard/docsqa')) +const DocsQA = lazy(() => import('@/screens/dashboard/docsqa/main')) const DocsQAChatbot = lazy(() => import('@/screens/dashboard/docsqa/Chatbot')) const DocsQASettings = lazy(() => import('@/screens/dashboard/docsqa/settings')) diff --git a/frontend/src/screens/dashboard/docsqa/main/DocsQA.tsx b/frontend/src/screens/dashboard/docsqa/main/DocsQA.tsx new file mode 100644 index 00000000..d4f05997 --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/DocsQA.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react' + +import Spinner from '@/components/base/atoms/Spinner/Spinner' +import ApplicationModal from './components/ApplicationModal' +import NoCollections from '../NoCollections' +import { useDocsQAContext } from './context' +import ConfigSidebar from './components/ConfigSidebar' +import Chat from './components/Chat' + +const DocsQA = () => { + const { selectedCollection, isCollectionsLoading } = useDocsQAContext() + + const [isCreateApplicationModalOpen, setIsCreateApplicationModalOpen] = + useState(false) + + return ( + <> + {isCreateApplicationModalOpen && ( + + )} +
+ {isCollectionsLoading ? ( +
+ +
+ ) : selectedCollection ? ( + <> + + + + ) : ( + + )} +
+ + ) +} + +export default DocsQA diff --git a/frontend/src/screens/dashboard/docsqa/main/components/Answer.tsx b/frontend/src/screens/dashboard/docsqa/main/components/Answer.tsx new file mode 100644 index 00000000..93a28d27 --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/Answer.tsx @@ -0,0 +1,27 @@ +import React from 'react' + +import SourceDocsPreview from '../../DocsQA/SourceDocsPreview' +import IconProvider from '@/components/assets/IconProvider' +import Markdown from 'react-markdown' +import { useDocsQAContext } from '../context' + +const Answer = (props: any) => { + const { sourceDocs, answer } = useDocsQAContext() + + return ( +
+
+
+ +
+
+
Answer:
+ {answer} +
+
+ {sourceDocs && } +
+ ) +} + +export default Answer diff --git a/frontend/src/screens/dashboard/docsqa/main/components/ApplicationModal.tsx b/frontend/src/screens/dashboard/docsqa/main/components/ApplicationModal.tsx new file mode 100644 index 00000000..555cac5f --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/ApplicationModal.tsx @@ -0,0 +1,181 @@ +import React, { useState } from 'react' + +import { LightTooltip } from '@/components/base/atoms/Tooltip' +import { useCreateApplicationMutation } from '@/stores/qafoundry' +import notify from '@/components/base/molecules/Notify' +import Button from '@/components/base/atoms/Button' +import Modal from '@/components/base/atoms/Modal' +import Input from '@/components/base/atoms/Input' +import { useDocsQAContext } from '../context' + +const ApplicationModal = (props: any) => { + const { + allEnabledModels, + selectedCollection, + modelConfig, + retrieverConfig, + selectedQueryModel, + selectedRetriever, + promptTemplate, + selectedQueryController, + } = useDocsQAContext() + + const [createApplication, { isLoading: isCreateApplicationLoading }] = + useCreateApplicationMutation() + + const { isCreateApplicationModalOpen, setIsCreateApplicationModalOpen } = + props + + const [applicationName, setApplicationName] = useState('') + const [questions, setQuestions] = useState([]) + + const pattern = /^[a-z][a-z0-9-]*$/ + const isValidApplicationName = pattern.test(applicationName) + + const createChatApplication = async ( + applicationName: string, + questions: string[], + setApplicationName: (name: string) => void, + ) => { + if (!applicationName) { + return notify('error', 'Application name is required') + } + const selectedModel = allEnabledModels.find( + (model: any) => model.name == selectedQueryModel, + ) + + try { + await createApplication({ + name: `${applicationName}-rag-app`, + config: { + collection_name: selectedCollection, + model_configuration: { + name: selectedModel.name, + provider: selectedModel.provider, + ...JSON.parse(modelConfig), + }, + retriever_name: selectedRetriever?.name ?? '', + retriever_config: JSON.parse(retrieverConfig), + prompt_template: promptTemplate, + query_controller: selectedQueryController, + }, + questions, + }).unwrap() + setApplicationName('') + setIsCreateApplicationModalOpen(false) + notify('success', 'Application created successfully') + } catch (err: any) { + notify('error', 'Failed to create application', err?.data?.detail) + } + } + + return ( + { + setApplicationName('') + setQuestions([]) + setIsCreateApplicationModalOpen(false) + }} + > +
+
+ Create Application +
+
+
Enter the name of the application
+ setApplicationName(e.target.value)} + className="py-1 input-sm mt-1" + placeholder="E.g. query-bot" + /> + {applicationName && !isValidApplicationName ? ( +
+ Application name should start with a lowercase letter and can only + contain lowercase letters, numbers and hyphens +
+ ) : applicationName ? ( +
+ The application name will be generated as{' '} + "{applicationName}-rag-app" +
+ ) : ( + <> + )} +
Questions (Optional)
+ {questions.map((question: any, index: any) => ( +
+
+ { + const updatedQuestions = [...questions] + updatedQuestions[index] = e.target.value + setQuestions(updatedQuestions) + }} + className="py-1 input-sm w-full" + placeholder={`Question ${index + 1}`} + maxLength={100} + /> +
+
+ ))} + +
+
+
+
+
+
+
+
+ ) +} + +export default ApplicationModal diff --git a/frontend/src/screens/dashboard/docsqa/main/components/Chat.tsx b/frontend/src/screens/dashboard/docsqa/main/components/Chat.tsx new file mode 100644 index 00000000..a6117ddc --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/Chat.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react' + +import Spinner from '@/components/base/atoms/Spinner' +import { useDocsQAContext } from '../context' +import PromptForm from './PromptForm' +import ErrorAnswer from './ErrorAnswer' +import Answer from './Answer' +import NoAnswer from './NoAnswer' + +const Right = () => { + const { errorMessage, answer } = useDocsQAContext() + + const [isRunningPrompt, setIsRunningPrompt] = useState(false) + + return ( +
+ + {answer ? ( + + ) : isRunningPrompt ? ( +
+ +
Fetching Answer...
+
+ ) : errorMessage ? ( + + ) : ( + + )} +
+ ) +} + +export default Right diff --git a/frontend/src/screens/dashboard/docsqa/main/components/ConfigSelector.tsx b/frontend/src/screens/dashboard/docsqa/main/components/ConfigSelector.tsx new file mode 100644 index 00000000..8e524f3d --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/ConfigSelector.tsx @@ -0,0 +1,57 @@ +import React from 'react' + +import { MenuItem, Select } from '@mui/material' + +interface ConfigProps { + title: string + placeholder: string + initialValue: string + data: any[] | undefined + handleOnChange: (e: any) => void + renderItem?: (e: any) => React.ReactNode + className?: string +} + +const ConfigSelector = (props: ConfigProps) => { + const { + title, + placeholder, + initialValue, + data, + className, + handleOnChange, + renderItem, + } = props + return ( +
+
{title}:
+ +
+ ) +} + +export default ConfigSelector diff --git a/frontend/src/screens/dashboard/docsqa/main/components/ConfigSidebar.tsx b/frontend/src/screens/dashboard/docsqa/main/components/ConfigSidebar.tsx new file mode 100644 index 00000000..4ed5419f --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/ConfigSidebar.tsx @@ -0,0 +1,142 @@ +import { MenuItem, Switch, TextareaAutosize } from '@mui/material' +import React from 'react' + +import SimpleCodeEditor from '@/components/base/molecules/SimpleCodeEditor' +import ConfigSelector from './ConfigSelector' +import Button from '@/components/base/atoms/Button' +import { useDocsQAContext } from '../context' + +const defaultModelConfig = `{ + "parameters": { + "temperature": 0.1 + } +}` + +const Left = (props: any) => { + const { + selectedCollection, + selectedQueryController, + selectedQueryModel, + selectedRetriever, + retrieverConfig, + promptTemplate, + isInternetSearchEnabled, + collections, + allQueryControllers, + allEnabledModels, + allRetrieverOptions, + setSelectedQueryModel, + setIsInternetSearchEnabled, + setSelectedCollection, + setSelectedQueryController, + setSelectedRetriever, + setModelConfig, + setRetrieverConfig, + setPromptTemplate, + resetQA, + } = useDocsQAContext() + + const { setIsCreateApplicationModalOpen } = props + + return ( +
+
+ { + resetQA() + setSelectedCollection(e.target.value) + }} + /> + { + resetQA() + setSelectedQueryController(e.target.value) + }} + /> + { + resetQA() + setSelectedQueryModel(e.target.value) + }} + renderItem={(item) => ( + + {item.name} + + )} + /> +
+ +
Model Configuration:
+ setModelConfig(updatedConfig ?? '')} + /> + {allRetrieverOptions && selectedRetriever?.key && ( + { + const retriever = allRetrieverOptions.find( + (retriever) => retriever.key === e.target.value, + ) + setSelectedRetriever(retriever) + setPromptTemplate(retriever?.promptTemplate) + }} + renderItem={(item) => ( + + {item.summary} + + )} + /> + )} +
Retrievers Configuration:
+ setRetrieverConfig(updatedConfig ?? '')} + /> + +
+
Internet Search
+ setIsInternetSearchEnabled(e.target.checked)} + /> +
+ +
Prompt Template:
+ setPromptTemplate(e.target.value)} + /> +
+ ) +} + +export default Left diff --git a/frontend/src/screens/dashboard/docsqa/main/components/ErrorAnswer.tsx b/frontend/src/screens/dashboard/docsqa/main/components/ErrorAnswer.tsx new file mode 100644 index 00000000..7ab5da40 --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/ErrorAnswer.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import IconProvider from '@/components/assets/IconProvider' + +const ErrorAnswer = () => { + return ( +
+
+ +
+
+
Error
+ We failed to get answer for your query, please try again by resending + query or try again in some time. +
+
+ ) +} + +export default ErrorAnswer diff --git a/frontend/src/screens/dashboard/docsqa/main/components/NoAnswer.tsx b/frontend/src/screens/dashboard/docsqa/main/components/NoAnswer.tsx new file mode 100644 index 00000000..afa7f74e --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/NoAnswer.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +import DocsQaInformation from '../../DocsQaInformation' + +const NoAnswer = () => { + return ( +
+
+ +

+ Select a collection from sidebar, +
review all the settings and start asking Questions +

+ + } + /> +
+
+ ) +} + +export default NoAnswer diff --git a/frontend/src/screens/dashboard/docsqa/main/components/PromptForm.tsx b/frontend/src/screens/dashboard/docsqa/main/components/PromptForm.tsx new file mode 100644 index 00000000..4528b2ef --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/components/PromptForm.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react' +import { SSE } from 'sse.js' + +import { baseQAFoundryPath, CollectionQueryDto } from '@/stores/qafoundry' +import Input from '@/components/base/atoms/Input' +import Button from '@/components/base/atoms/Button' +import { useDocsQAContext } from '../context' +import { notifyError } from '@/utils/error' + +const Form = (props: any) => { + const { + setErrorMessage, + setSourceDocs, + setAnswer, + setPrompt, + selectedQueryModel, + allEnabledModels, + modelConfig, + retrieverConfig, + selectedCollection, + selectedRetriever, + promptTemplate, + prompt, + isInternetSearchEnabled, + selectedQueryController, + } = useDocsQAContext() + + const { isRunningPrompt, setIsRunningPrompt } = props + + const handlePromptSubmit = async () => { + setIsRunningPrompt(true) + setAnswer('') + setSourceDocs([]) + setErrorMessage(false) + try { + const selectedModel = allEnabledModels.find( + (model: any) => model.name == selectedQueryModel, + ) + if (!selectedModel) { + throw new Error('Model not found') + } + try { + JSON.parse(modelConfig) + } catch (err: any) { + throw new Error('Invalid Model Configuration') + } + try { + JSON.parse(retrieverConfig) + } catch (err: any) { + throw new Error('Invalid Retriever Configuration') + } + + const params: CollectionQueryDto = Object.assign( + { + collection_name: selectedCollection, + query: prompt, + model_configuration: { + name: selectedModel.name, + provider: selectedModel.provider, + ...JSON.parse(modelConfig), + }, + retriever_name: selectedRetriever?.name ?? '', + retriever_config: JSON.parse(retrieverConfig), + prompt_template: promptTemplate, + internet_search_enabled: isInternetSearchEnabled, + }, + {}, + ) + + const sseRequest = new SSE( + `${baseQAFoundryPath}/retrievers/${selectedQueryController}/answer`, + { + payload: JSON.stringify({ + ...params, + stream: true, + }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + sseRequest.addEventListener('data', (event: any) => { + try { + const parsed = JSON.parse(event.data) + if (parsed?.type === 'answer') { + setAnswer((prevAnswer: string) => prevAnswer + parsed.content) + setIsRunningPrompt(false) + } else if (parsed?.type === 'docs') { + setSourceDocs((prevDocs) => [...prevDocs, ...parsed.content]) + } + } catch (err: any) { + throw new Error('An error occurred while processing the response.') + } + }) + + sseRequest.addEventListener('end', (event: any) => { + sseRequest.close() + }) + + sseRequest.addEventListener('error', (event: any) => { + sseRequest.close() + setPrompt('') + setIsRunningPrompt(false) + const message = JSON.parse(event.data).detail[0].msg + notifyError('Failed to retrieve answer', { message }) + }) + } catch (err: any) { + setPrompt('') + setIsRunningPrompt(false) + notifyError('Failed to retrieve answer', err) + } + } + + return ( +
+
e.preventDefault()}> + setPrompt(e.target.value)} + /> +
+ ) +} + +export default Form diff --git a/frontend/src/screens/dashboard/docsqa/main/context.tsx b/frontend/src/screens/dashboard/docsqa/main/context.tsx new file mode 100644 index 00000000..eda5f47d --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/context.tsx @@ -0,0 +1,166 @@ +import React, { + createContext, + useState, + useEffect, + useMemo, + ReactNode, + useContext, +} from 'react' + +import { + SourceDocs, + useGetAllEnabledChatModelsQuery, + useGetCollectionNamesQuery, + useGetOpenapiSpecsQuery, +} from '@/stores/qafoundry' + +import { DocsQAContextType, SelectedRetrieverType } from './types' + +interface DocsQAProviderProps { + children: ReactNode +} + +const defaultRetrieverConfig = `{ + "search_type": "similarity", + "k": 20, + "fetch_k": 20, + "filter": {} +}` + +const defaultModelConfig = `{ + "parameters": { + "temperature": 0.1 + } +}` + +const defaultPrompt = + 'Answer the question based only on the following context:\nContext: {context} \nQuestion: {question}' + +const DocsQAContext = createContext(undefined) + +export const DocsQAProvider: React.FC = ({ children }) => { + const [selectedQueryModel, setSelectedQueryModel] = React.useState('') + const [selectedCollection, setSelectedCollection] = useState('') + const [selectedQueryController, setSelectedQueryController] = useState('') + const [selectedRetriever, setSelectedRetriever] = useState< + SelectedRetrieverType | undefined + >() + + const [isInternetSearchEnabled, setIsInternetSearchEnabled] = useState(false) + const [retrieverConfig, setRetrieverConfig] = useState(defaultRetrieverConfig) + const [modelConfig, setModelConfig] = useState(defaultModelConfig) + const [promptTemplate, setPromptTemplate] = useState(defaultPrompt) + const [sourceDocs, setSourceDocs] = useState([]) + const [errorMessage, setErrorMessage] = useState(false) + const [answer, setAnswer] = useState('') + const [prompt, setPrompt] = useState('') + + const { data: collections, isLoading: isCollectionsLoading } = + useGetCollectionNamesQuery() + const { data: allEnabledModels } = useGetAllEnabledChatModelsQuery() + const { data: openapiSpecs } = useGetOpenapiSpecsQuery() + + const allQueryControllers = useMemo(() => { + if (!openapiSpecs?.paths) return [] + return Object.keys(openapiSpecs?.paths) + .filter((path) => path.includes('/retrievers/')) + .map((str) => { + var parts = str.split('/') + return parts[2] + }) + }, [openapiSpecs]) + + const allRetrieverOptions = useMemo(() => { + const queryControllerPath = `/retrievers/${selectedQueryController}/answer` + const examples = + openapiSpecs?.paths[queryControllerPath]?.post?.requestBody?.content?.[ + 'application/json' + ]?.examples + if (!examples) return [] + return Object.entries(examples).map(([key, value]: [string, any]) => ({ + key, + name: value.value.retriever_name, + summary: value.summary, + config: value.value.retriever_config, + promptTemplate: value.value.prompt_template ?? defaultPrompt, + })) + }, [selectedQueryController, openapiSpecs]) + + const resetQA = () => { + setAnswer('') + setErrorMessage(false) + setPrompt('') + } + + useEffect(() => { + if (collections && collections.length) setSelectedCollection(collections[0]) + }, [collections]) + + useEffect(() => { + if (allQueryControllers && allQueryControllers.length) + setSelectedQueryController(allQueryControllers[0]) + }, [allQueryControllers]) + + useEffect(() => { + if (allEnabledModels && allEnabledModels.length) { + setSelectedQueryModel(allEnabledModels[0].name) + } + }, [allEnabledModels]) + + useEffect(() => { + if (allRetrieverOptions && allRetrieverOptions.length) { + setSelectedRetriever(allRetrieverOptions[0]) + setPromptTemplate(allRetrieverOptions[0].promptTemplate) + } + }, [allRetrieverOptions]) + + useEffect(() => { + if (selectedRetriever) + setRetrieverConfig(JSON.stringify(selectedRetriever.config, null, 2)) + }, [selectedRetriever]) + + const value = { + selectedQueryModel, + selectedCollection, + selectedQueryController, + selectedRetriever, + prompt, + answer, + sourceDocs, + errorMessage, + modelConfig, + retrieverConfig, + promptTemplate, + isInternetSearchEnabled, + collections, + isCollectionsLoading, + allEnabledModels, + allQueryControllers, + allRetrieverOptions, + setSelectedQueryModel, + setSelectedCollection, + setSelectedQueryController, + setSelectedRetriever, + setSourceDocs, + setErrorMessage, + setModelConfig, + setRetrieverConfig, + setPromptTemplate, + setIsInternetSearchEnabled, + resetQA, + setPrompt, + setAnswer, + } + + return ( + {children} + ) +} + +export const useDocsQAContext = () => { + const context = useContext(DocsQAContext) + if (!context) { + throw new Error('useDocsQAContext must be used within a DocsQAProvider') + } + return context +} diff --git a/frontend/src/screens/dashboard/docsqa/main/index.tsx b/frontend/src/screens/dashboard/docsqa/main/index.tsx new file mode 100644 index 00000000..bf811aea --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/index.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { DocsQAProvider } from './context' +import DocsQA from './DocsQA' + +const index = () => { + return ( + + + + ) +} + +export default index diff --git a/frontend/src/screens/dashboard/docsqa/main/types.tsx b/frontend/src/screens/dashboard/docsqa/main/types.tsx new file mode 100644 index 00000000..9a1d4bc4 --- /dev/null +++ b/frontend/src/screens/dashboard/docsqa/main/types.tsx @@ -0,0 +1,42 @@ +export interface SelectedRetrieverType { + key: string + name: string + summary: string + config: any +} + +export interface DocsQAContextType { + selectedQueryModel: string + selectedCollection: string + selectedQueryController: string + selectedRetriever: SelectedRetrieverType | undefined + prompt: string + answer: string + sourceDocs: any[] + errorMessage: boolean + modelConfig: string + retrieverConfig: string + promptTemplate: string + isInternetSearchEnabled: boolean + collections: any[] | undefined + isCollectionsLoading: boolean + allEnabledModels: any + allQueryControllers: string[] + allRetrieverOptions: SelectedRetrieverType[] + + setSelectedQueryModel: React.Dispatch> + setSelectedCollection: React.Dispatch> + setSelectedQueryController: React.Dispatch> + setSelectedRetriever: React.Dispatch< + React.SetStateAction + > + setAnswer: React.Dispatch> + setPrompt: React.Dispatch> + setSourceDocs: React.Dispatch> + setErrorMessage: React.Dispatch> + setModelConfig: React.Dispatch> + setRetrieverConfig: React.Dispatch> + setPromptTemplate: React.Dispatch> + setIsInternetSearchEnabled: React.Dispatch> + resetQA: () => void +} diff --git a/frontend/src/types/retrieverTypes.ts b/frontend/src/types/retrieverTypes.ts new file mode 100644 index 00000000..5e0658db --- /dev/null +++ b/frontend/src/types/retrieverTypes.ts @@ -0,0 +1,6 @@ +export interface SelectedRetrieverType { + key: string + name: string + summary: string + config: any +}