diff --git a/frontend/src/agents/AgentPreview.tsx b/frontend/src/agents/AgentPreview.tsx new file mode 100644 index 00000000..5eaf10a9 --- /dev/null +++ b/frontend/src/agents/AgentPreview.tsx @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import MessageInput from '../components/MessageInput'; +import ConversationMessages from '../conversation/ConversationMessages'; +import { Query } from '../conversation/conversationModels'; +import { + addQuery, + fetchAnswer, + handleAbort, + resendQuery, + resetConversation, + selectQueries, + selectStatus, +} from '../conversation/conversationSlice'; +import { selectSelectedAgent } from '../preferences/preferenceSlice'; +import { AppDispatch } from '../store'; + +export default function AgentPreview() { + const dispatch = useDispatch(); + + const queries = useSelector(selectQueries); + const status = useSelector(selectStatus); + const selectedAgent = useSelector(selectSelectedAgent); + + const [input, setInput] = useState(''); + const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); + + const fetchStream = useRef(null); + + const handleFetchAnswer = useCallback( + ({ question, index }: { question: string; index?: number }) => { + fetchStream.current = dispatch( + fetchAnswer({ question, indx: index, isPreview: true }), + ); + }, + [dispatch], + ); + + const handleQuestion = useCallback( + ({ + question, + isRetry = false, + index = undefined, + }: { + question: string; + isRetry?: boolean; + index?: number; + }) => { + const trimmedQuestion = question.trim(); + if (trimmedQuestion === '') return; + + if (index !== undefined) { + if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion })); + handleFetchAnswer({ question: trimmedQuestion, index }); + } else { + if (!isRetry) { + const newQuery: Query = { prompt: trimmedQuestion }; + dispatch(addQuery(newQuery)); + } + handleFetchAnswer({ question: trimmedQuestion, index: undefined }); + } + }, + [dispatch, handleFetchAnswer], + ); + + const handleQuestionSubmission = ( + updatedQuestion?: string, + updated?: boolean, + indx?: number, + ) => { + if ( + updated === true && + updatedQuestion !== undefined && + indx !== undefined + ) { + handleQuestion({ + question: updatedQuestion, + index: indx, + isRetry: false, + }); + } else if (input.trim() && status !== 'loading') { + const currentInput = input.trim(); + if (lastQueryReturnedErr && queries.length > 0) { + const lastQueryIndex = queries.length - 1; + handleQuestion({ + question: currentInput, + isRetry: true, + index: lastQueryIndex, + }); + } else { + handleQuestion({ + question: currentInput, + isRetry: false, + index: undefined, + }); + } + setInput(''); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + handleQuestionSubmission(); + } + }; + + useEffect(() => { + dispatch(resetConversation()); + return () => { + if (fetchStream.current) fetchStream.current.abort(); + handleAbort(); + dispatch(resetConversation()); + }; + }, [dispatch]); + + useEffect(() => { + if (queries.length > 0) { + const lastQuery = queries[queries.length - 1]; + setLastQueryReturnedErr(!!lastQuery.error); + } else setLastQueryReturnedErr(false); + }, [queries]); + return ( +
+
+
+ +
+
+ setInput(e.target.value)} + onSubmit={() => handleQuestionSubmission()} + loading={status === 'loading'} + showSourceButton={selectedAgent ? false : true} + showToolButton={selectedAgent ? false : true} + /> +

+ This is a preview of the agent. You can publish it to start using it + in conversations. +

+
+
+
+ ); +} diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index db733db2..2f6c83c6 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { useNavigate, useParams } from 'react-router-dom'; import userService from '../api/services/userService'; @@ -7,12 +7,18 @@ import ArrowLeft from '../assets/arrow-left.svg'; import SourceIcon from '../assets/source.svg'; import Dropdown from '../components/Dropdown'; import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup'; -import { ActiveState, Doc } from '../models/misc'; -import { selectSourceDocs, selectToken } from '../preferences/preferenceSlice'; -import { UserToolType } from '../settings/types'; -import { Agent } from './types'; -import ConfirmationModal from '../modals/ConfirmationModal'; import AgentDetailsModal from '../modals/AgentDetailsModal'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import { ActiveState, Doc } from '../models/misc'; +import { + selectSourceDocs, + selectToken, + setSelectedAgent, + selectSelectedAgent, +} from '../preferences/preferenceSlice'; +import { UserToolType } from '../settings/types'; +import AgentPreview from './AgentPreview'; +import { Agent } from './types'; const embeddingsName = import.meta.env.VITE_EMBEDDINGS_NAME || @@ -20,9 +26,12 @@ const embeddingsName = export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const navigate = useNavigate(); + const dispatch = useDispatch(); const { agentId } = useParams(); + const token = useSelector(selectToken); const sourceDocs = useSelector(selectSourceDocs); + const selectedAgent = useSelector(selectSelectedAgent); const [effectiveMode, setEffectiveMode] = useState(mode); const [agent, setAgent] = useState({ @@ -100,7 +109,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ); }; - const handleCancel = () => navigate('/agents'); + const handleCancel = () => { + if (selectedAgent) dispatch(setSelectedAgent(null)); + navigate('/agents'); + }; const handleDelete = async (agentId: string) => { const response = await userService.deleteAgent(agentId, token); @@ -139,8 +151,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ); if (!response.ok) throw new Error('Failed to publish agent'); const data = await response.json(); + if (data.id) setAgent((prev) => ({ ...prev, id: data.id })); if (data.key) setAgent((prev) => ({ ...prev, key: data.key })); - if (effectiveMode === 'new') setAgentDetails('ACTIVE'); + if (effectiveMode === 'new') { + setAgentDetails('ACTIVE'); + setEffectiveMode('edit'); + } }; useEffect(() => { @@ -221,12 +237,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { .filter((id): id is string => typeof id === 'string'), })); }, [selectedToolIds]); + + useEffect(() => { + if (isPublishable()) dispatch(setSelectedAgent(agent)); + }, [agent, dispatch]); return (
@@ -287,7 +307,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
-
+

Meta

@@ -469,7 +489,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {

Preview

- +
+
+ {selectedAgent?.id ? ( +
+ +
+ ) : ( +
+ {' '} +

+ Published agents can be previewd here +

+
+ )} +
); } diff --git a/frontend/src/assets/science-spark-dark.svg b/frontend/src/assets/science-spark-dark.svg new file mode 100644 index 00000000..dba61b59 --- /dev/null +++ b/frontend/src/assets/science-spark-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/science-spark.svg b/frontend/src/assets/science-spark.svg new file mode 100644 index 00000000..c4c46b5c --- /dev/null +++ b/frontend/src/assets/science-spark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/conversation/conversationHandlers.ts b/frontend/src/conversation/conversationHandlers.ts index f6216ca0..2fcb2e28 100644 --- a/frontend/src/conversation/conversationHandlers.ts +++ b/frontend/src/conversation/conversationHandlers.ts @@ -15,6 +15,7 @@ export function handleFetchAnswer( token_limit: number, agentId?: string, attachments?: string[], + save_conversation: boolean = true, ): Promise< | { result: any; @@ -52,6 +53,7 @@ export function handleFetchAnswer( token_limit: token_limit, isNoneDoc: selectedDocs === null, agent_id: agentId, + save_conversation: save_conversation, }; // Add attachments to payload if they exist @@ -101,6 +103,7 @@ export function handleFetchAnswerSteaming( indx?: number, agentId?: string, attachments?: string[], + save_conversation: boolean = true, ): Promise { history = history.map((item) => { return { @@ -120,6 +123,7 @@ export function handleFetchAnswerSteaming( isNoneDoc: selectedDocs === null, index: indx, agent_id: agentId, + save_conversation: save_conversation, }; // Add attachments to payload if they exist diff --git a/frontend/src/conversation/conversationModels.ts b/frontend/src/conversation/conversationModels.ts index a61a5f99..77e102a0 100644 --- a/frontend/src/conversation/conversationModels.ts +++ b/frontend/src/conversation/conversationModels.ts @@ -53,4 +53,5 @@ export interface RetrievalPayload { index?: number; agent_id?: string; attachments?: string[]; + save_conversation?: boolean; } diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index f28fb1c9..6fb077ec 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -28,35 +28,157 @@ export function handleAbort() { export const fetchAnswer = createAsyncThunk< Answer, - { question: string; indx?: number } ->('fetchAnswer', async ({ question, indx }, { dispatch, getState }) => { - if (abortController) { - abortController.abort(); - } - abortController = new AbortController(); - const { signal } = abortController; + { question: string; indx?: number; isPreview?: boolean } +>( + 'fetchAnswer', + async ({ question, indx, isPreview = false }, { dispatch, getState }) => { + if (abortController) abortController.abort(); + abortController = new AbortController(); + const { signal } = abortController; - let isSourceUpdated = false; - const state = getState() as RootState; - const attachments = state.conversation.attachments?.map((a) => a.id) || []; + let isSourceUpdated = false; + const state = getState() as RootState; + const attachments = state.conversation.attachments?.map((a) => a.id) || []; + const currentConversationId = state.conversation.conversationId; + const conversationIdToSend = isPreview ? null : currentConversationId; + const save_conversation = isPreview ? false : true; - if (state.preference) { - if (API_STREAMING) { - await handleFetchAnswerSteaming( - question, - signal, - state.preference.token, - state.preference.selectedDocs!, - state.conversation.queries, - state.conversation.conversationId, - state.preference.prompt.id, - state.preference.chunks, - state.preference.token_limit, - (event) => { - const data = JSON.parse(event.data); + if (state.preference) { + if (API_STREAMING) { + await handleFetchAnswerSteaming( + question, + signal, + state.preference.token, + state.preference.selectedDocs!, + state.conversation.queries, + conversationIdToSend, + state.preference.prompt.id, + state.preference.chunks, + state.preference.token_limit, + (event) => { + const data = JSON.parse(event.data); + const targetIndex = indx ?? state.conversation.queries.length - 1; - if (data.type === 'end') { - dispatch(conversationSlice.actions.setStatus('idle')); + if (data.type === 'end') { + dispatch(conversationSlice.actions.setStatus('idle')); + if (!isPreview) { + getConversations(state.preference.token) + .then((fetchedConversations) => { + dispatch(setConversations(fetchedConversations)); + }) + .catch((error) => { + console.error('Failed to fetch conversations: ', error); + }); + } + if (!isSourceUpdated) { + dispatch( + updateStreamingSource({ + index: targetIndex, + query: { sources: [] }, + }), + ); + } + } else if (data.type === 'id') { + if (!isPreview) { + dispatch( + updateConversationId({ + query: { conversationId: data.id }, + }), + ); + } + } else if (data.type === 'thought') { + const result = data.thought; + dispatch( + updateThought({ + index: targetIndex, + query: { thought: result }, + }), + ); + } else if (data.type === 'source') { + isSourceUpdated = true; + dispatch( + updateStreamingSource({ + index: targetIndex, + query: { sources: data.source ?? [] }, + }), + ); + } else if (data.type === 'tool_calls') { + dispatch( + updateToolCalls({ + index: targetIndex, + query: { tool_calls: data.tool_calls }, + }), + ); + } else if (data.type === 'error') { + // set status to 'failed' + dispatch(conversationSlice.actions.setStatus('failed')); + dispatch( + conversationSlice.actions.raiseError({ + index: targetIndex, + message: data.error, + }), + ); + } else { + dispatch( + updateStreamingQuery({ + index: targetIndex, + query: { response: data.answer }, + }), + ); + } + }, + indx, + state.preference.selectedAgent?.id, + attachments, + save_conversation, + ); + } else { + const answer = await handleFetchAnswer( + question, + signal, + state.preference.token, + state.preference.selectedDocs!, + state.conversation.queries, + state.conversation.conversationId, + state.preference.prompt.id, + state.preference.chunks, + state.preference.token_limit, + state.preference.selectedAgent?.id, + attachments, + save_conversation, + ); + if (answer) { + let sourcesPrepped = []; + sourcesPrepped = answer.sources.map((source: { title: string }) => { + if (source && source.title) { + const titleParts = source.title.split('/'); + return { + ...source, + title: titleParts[titleParts.length - 1], + }; + } + return source; + }); + + const targetIndex = indx ?? state.conversation.queries.length - 1; + + dispatch( + updateQuery({ + index: targetIndex, + query: { + response: answer.answer, + thought: answer.thought, + sources: sourcesPrepped, + tool_calls: answer.toolCalls, + }, + }), + ); + if (!isPreview) { + dispatch( + updateConversationId({ + query: { conversationId: answer.conversationId }, + }), + ); getConversations(state.preference.token) .then((fetchedConversations) => { dispatch(setConversations(fetchedConversations)); @@ -64,131 +186,23 @@ export const fetchAnswer = createAsyncThunk< .catch((error) => { console.error('Failed to fetch conversations: ', error); }); - if (!isSourceUpdated) { - dispatch( - updateStreamingSource({ - index: indx ?? state.conversation.queries.length - 1, - query: { sources: [] }, - }), - ); - } - } else if (data.type === 'id') { - dispatch( - updateConversationId({ - query: { conversationId: data.id }, - }), - ); - } else if (data.type === 'thought') { - const result = data.thought; - dispatch( - updateThought({ - index: indx ?? state.conversation.queries.length - 1, - query: { thought: result }, - }), - ); - } else if (data.type === 'source') { - isSourceUpdated = true; - dispatch( - updateStreamingSource({ - index: indx ?? state.conversation.queries.length - 1, - query: { sources: data.source ?? [] }, - }), - ); - } else if (data.type === 'tool_calls') { - dispatch( - updateToolCalls({ - index: indx ?? state.conversation.queries.length - 1, - query: { tool_calls: data.tool_calls }, - }), - ); - } else if (data.type === 'error') { - // set status to 'failed' - dispatch(conversationSlice.actions.setStatus('failed')); - dispatch( - conversationSlice.actions.raiseError({ - index: indx ?? state.conversation.queries.length - 1, - message: data.error, - }), - ); - } else { - const result = data.answer; - dispatch( - updateStreamingQuery({ - index: indx ?? state.conversation.queries.length - 1, - query: { response: result }, - }), - ); } - }, - indx, - state.preference.selectedAgent?.id, - attachments, - ); - } else { - const answer = await handleFetchAnswer( - question, - signal, - state.preference.token, - state.preference.selectedDocs!, - state.conversation.queries, - state.conversation.conversationId, - state.preference.prompt.id, - state.preference.chunks, - state.preference.token_limit, - state.preference.selectedAgent?.id, - attachments, - ); - if (answer) { - let sourcesPrepped = []; - sourcesPrepped = answer.sources.map((source: { title: string }) => { - if (source && source.title) { - const titleParts = source.title.split('/'); - return { - ...source, - title: titleParts[titleParts.length - 1], - }; - } - return source; - }); - - dispatch( - updateQuery({ - index: indx ?? state.conversation.queries.length - 1, - query: { - response: answer.answer, - thought: answer.thought, - sources: sourcesPrepped, - tool_calls: answer.toolCalls, - }, - }), - ); - dispatch( - updateConversationId({ - query: { conversationId: answer.conversationId }, - }), - ); - dispatch(conversationSlice.actions.setStatus('idle')); - getConversations(state.preference.token) - .then((fetchedConversations) => { - dispatch(setConversations(fetchedConversations)); - }) - .catch((error) => { - console.error('Failed to fetch conversations: ', error); - }); + dispatch(conversationSlice.actions.setStatus('idle')); + } } } - } - return { - conversationId: null, - title: null, - answer: '', - query: question, - result: '', - thought: '', - sources: [], - tool_calls: [], - }; -}); + return { + conversationId: null, + title: null, + answer: '', + query: question, + result: '', + thought: '', + sources: [], + tool_calls: [], + }; + }, +); export const conversationSlice = createSlice({ name: 'conversation', @@ -293,6 +307,13 @@ export const conversationSlice = createSlice({ ) => { state.attachments = action.payload; }, + resetConversation: (state) => { + state.queries = initialState.queries; + state.status = initialState.status; + state.conversationId = initialState.conversationId; + state.attachments = initialState.attachments; + handleAbort(); + }, }, extraReducers(builder) { builder @@ -327,5 +348,6 @@ export const { updateToolCalls, setConversation, setAttachments, + resetConversation, } = conversationSlice.actions; export default conversationSlice.reducer; diff --git a/frontend/src/modals/AgentDetailsModal.tsx b/frontend/src/modals/AgentDetailsModal.tsx index 613ae112..377dd7bd 100644 --- a/frontend/src/modals/AgentDetailsModal.tsx +++ b/frontend/src/modals/AgentDetailsModal.tsx @@ -22,7 +22,7 @@ export default function AgentDetailsModal({ { - if (mode === 'new') navigate('/agents'); + // if (mode === 'new') navigate('/agents'); setModalState('INACTIVE'); }} >