Merge pull request #1851 from ManishMadan2882/main

Fixed conflict while switching conversations, separated concerns
This commit is contained in:
Alex
2025-06-20 13:07:24 +01:00
committed by GitHub
9 changed files with 522 additions and 173 deletions

View File

@@ -81,8 +81,27 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
useState<ActiveState>('INACTIVE'); useState<ActiveState>('INACTIVE');
const [recentAgents, setRecentAgents] = useState<Agent[]>([]); const [recentAgents, setRecentAgents] = useState<Agent[]>([]);
const navRef = useRef(null); const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
navRef.current &&
!navRef.current.contains(event.target as Node) &&
(isMobile || isTablet) &&
navOpen
) {
setNavOpen(false);
}
}
//event listener only for mobile/tablet when nav is open
if ((isMobile || isTablet) && navOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [navOpen, isMobile, isTablet, setNavOpen]);
async function fetchRecentAgents() { async function fetchRecentAgents() {
try { try {
const response = await userService.getPinnedAgents(token); const response = await userService.getPinnedAgents(token);

View File

@@ -6,24 +6,23 @@ import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels'; import { Query } from '../conversation/conversationModels';
import { import {
addQuery, addQuery,
fetchAnswer, fetchPreviewAnswer,
handleAbort, handlePreviewAbort,
resendQuery, resendQuery,
resetConversation, resetPreview,
selectQueries, selectPreviewQueries,
selectStatus, selectPreviewStatus,
} from '../conversation/conversationSlice'; } from './agentPreviewSlice';
import { selectSelectedAgent } from '../preferences/preferenceSlice'; import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store'; import { AppDispatch } from '../store';
export default function AgentPreview() { export default function AgentPreview() {
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectQueries); const queries = useSelector(selectPreviewQueries);
const status = useSelector(selectStatus); const status = useSelector(selectPreviewStatus);
const selectedAgent = useSelector(selectSelectedAgent); const selectedAgent = useSelector(selectSelectedAgent);
const [input, setInput] = useState('');
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const fetchStream = useRef<any>(null); const fetchStream = useRef<any>(null);
@@ -31,7 +30,7 @@ export default function AgentPreview() {
const handleFetchAnswer = useCallback( const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => { ({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch( fetchStream.current = dispatch(
fetchAnswer({ question, indx: index, isPreview: true }), fetchPreviewAnswer({ question, indx: index }),
); );
}, },
[dispatch], [dispatch],
@@ -95,11 +94,11 @@ export default function AgentPreview() {
}; };
useEffect(() => { useEffect(() => {
dispatch(resetConversation()); dispatch(resetPreview());
return () => { return () => {
if (fetchStream.current) fetchStream.current.abort(); if (fetchStream.current) fetchStream.current.abort();
handleAbort(); handlePreviewAbort();
dispatch(resetConversation()); dispatch(resetPreview());
}; };
}, [dispatch]); }, [dispatch]);

View File

@@ -57,9 +57,7 @@ export default function SharedAgent() {
const handleFetchAnswer = useCallback( const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => { ({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch( fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
fetchAnswer({ question, indx: index, isPreview: false }),
);
}, },
[dispatch], [dispatch],
); );

View File

@@ -0,0 +1,319 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import {
Answer,
ConversationState,
Query,
Status,
} from '../conversation/conversationModels';
import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from '../conversation/conversationHandlers';
import {
selectCompletedAttachments,
clearAttachments,
} from '../upload/uploadSlice';
import store from '../store';
const initialState: ConversationState = {
queries: [],
status: 'idle',
conversationId: null,
};
const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true';
let abortController: AbortController | null = null;
export function handlePreviewAbort() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
export const fetchPreviewAnswer = createAsyncThunk<
Answer,
{ question: string; indx?: number }
>(
'agentPreview/fetchAnswer',
async ({ question, indx }, { dispatch, getState }) => {
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
const state = getState() as RootState;
const attachmentIds = selectCompletedAttachments(state)
.filter((a) => a.id)
.map((a) => a.id) as string[];
if (attachmentIds.length > 0) {
dispatch(clearAttachments());
}
if (state.preference) {
if (API_STREAMING) {
await handleFetchAnswerSteaming(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.agentPreview.queries,
null, // No conversation ID for previews
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
const targetIndex = indx ?? state.agentPreview.queries.length - 1;
if (data.type === 'end') {
dispatch(agentPreviewSlice.actions.setStatus('idle'));
} else if (data.type === 'thought') {
dispatch(
updateThought({
index: targetIndex,
query: { thought: data.thought },
}),
);
} else if (data.type === 'source') {
dispatch(
updateStreamingSource({
index: targetIndex,
query: { sources: data.source ?? [] },
}),
);
} else if (data.type === 'tool_call') {
dispatch(
updateToolCall({
index: targetIndex,
tool_call: data.data,
}),
);
} else if (data.type === 'error') {
dispatch(agentPreviewSlice.actions.setStatus('failed'));
dispatch(
agentPreviewSlice.actions.raiseError({
index: targetIndex,
message: data.error,
}),
);
} else {
dispatch(
updateStreamingQuery({
index: targetIndex,
query: { response: data.answer },
}),
);
}
},
indx,
state.preference.selectedAgent?.id,
attachmentIds,
false, // Don't save preview conversations
);
} else {
// Non-streaming implementation
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.agentPreview.queries,
null, // No conversation ID for previews
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachmentIds,
false, // Don't save preview conversations
);
if (answer) {
const 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.agentPreview.queries.length - 1;
dispatch(
updateQuery({
index: targetIndex,
query: {
response: answer.answer,
thought: answer.thought,
sources: sourcesPrepped,
tool_calls: answer.toolCalls,
},
}),
);
dispatch(agentPreviewSlice.actions.setStatus('idle'));
}
}
}
return {
conversationId: null,
title: null,
answer: '',
query: question,
result: '',
thought: '',
sources: [],
tool_calls: [],
};
},
);
export const agentPreviewSlice = createSlice({
name: 'agentPreview',
initialState,
reducers: {
addQuery(state, action: PayloadAction<Query>) {
state.queries.push(action.payload);
},
resendQuery(
state,
action: PayloadAction<{ index: number; prompt: string; query?: Query }>,
) {
state.queries = [
...state.queries.splice(0, action.payload.index),
action.payload,
];
},
updateStreamingQuery(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (state.status === 'idle') return;
if (query.response != undefined) {
state.queries[index].response =
(state.queries[index].response || '') + query.response;
}
},
updateThought(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (query.thought != undefined) {
state.queries[index].thought =
(state.queries[index].thought || '') + query.thought;
}
},
updateStreamingSource(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (!state.queries[index].sources) {
state.queries[index].sources = query?.sources;
} else if (query.sources) {
state.queries[index].sources!.push(...query.sources);
}
},
updateToolCall(state, action) {
const { index, tool_call } = action.payload;
if (!state.queries[index].tool_calls) {
state.queries[index].tool_calls = [];
}
const existingIndex = state.queries[index].tool_calls.findIndex(
(call) => call.call_id === tool_call.call_id,
);
if (existingIndex !== -1) {
const existingCall = state.queries[index].tool_calls[existingIndex];
state.queries[index].tool_calls[existingIndex] = {
...existingCall,
...tool_call,
};
} else state.queries[index].tool_calls.push(tool_call);
},
updateQuery(
state,
action: PayloadAction<{ index: number; query: Partial<Query> }>,
) {
const { index, query } = action.payload;
state.queries[index] = {
...state.queries[index],
...query,
};
},
setStatus(state, action: PayloadAction<Status>) {
state.status = action.payload;
},
raiseError(
state,
action: PayloadAction<{
index: number;
message: string;
}>,
) {
const { index, message } = action.payload;
state.queries[index].error = message;
},
resetPreview: (state) => {
state.queries = initialState.queries;
state.status = initialState.status;
state.conversationId = initialState.conversationId;
handlePreviewAbort();
},
},
extraReducers(builder) {
builder
.addCase(fetchPreviewAnswer.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchPreviewAnswer.rejected, (state, action) => {
if (action.meta.aborted) {
state.status = 'idle';
return state;
}
state.status = 'failed';
state.queries[state.queries.length - 1].error = 'Something went wrong';
});
},
});
type RootState = ReturnType<typeof store.getState>;
export const selectPreviewQueries = (state: RootState) =>
state.agentPreview.queries;
export const selectPreviewStatus = (state: RootState) =>
state.agentPreview.status;
export const {
addQuery,
updateQuery,
resendQuery,
updateStreamingQuery,
updateThought,
updateStreamingSource,
updateToolCall,
setStatus,
raiseError,
resetPreview,
} = agentPreviewSlice.actions;
export default agentPreviewSlice.reducer;

View File

@@ -53,7 +53,7 @@ function Dropdown({
darkBorderColor?: string; darkBorderColor?: string;
showEdit?: boolean; showEdit?: boolean;
onEdit?: (value: { name: string; id: string; type: string }) => void; onEdit?: (value: { name: string; id: string; type: string }) => void;
showDelete?: boolean; showDelete?: boolean | ((option: any) => boolean);
onDelete?: (value: string) => void; onDelete?: (value: string) => void;
placeholder?: string; placeholder?: string;
placeholderTextColor?: string; placeholderTextColor?: string;
@@ -173,8 +173,15 @@ function Dropdown({
)} )}
{showDelete && onDelete && ( {showDelete && onDelete && (
<button <button
onClick={() => onDelete(option.id)} onClick={(e) => {
disabled={option.type === 'public'} e.stopPropagation();
onDelete?.(typeof option === 'string' ? option : option.id);
}}
className={`${
typeof showDelete === 'function' && !showDelete(option)
? 'hidden'
: ''
} mr-2 h-4 w-4 cursor-pointer hover:opacity-50`}
> >
<img <img
src={Trash} src={Trash}

View File

@@ -157,12 +157,7 @@ const ConversationBubble = forwardRef<
{!isEditClicked && ( {!isEditClicked && (
<> <>
<div className="relative mr-2 flex w-full flex-col"> <div className="relative mr-2 flex w-full flex-col">
<div <div className="ml-2 mr-2 flex max-w-full items-start gap-2 whitespace-pre-wrap break-words rounded-[28px] bg-gradient-to-b from-medium-purple to-slate-blue px-5 py-4 text-sm leading-normal text-white sm:text-base">
style={{
wordBreak: 'break-word',
}}
className="ml-2 mr-2 flex max-w-full items-start gap-2 whitespace-pre-wrap rounded-[28px] bg-gradient-to-b from-medium-purple to-slate-blue py-[14px] pl-[19px] pr-3 text-sm leading-normal text-white sm:text-base"
>
<div <div
ref={messageRef} ref={messageRef}
className={`${isQuestionCollapsed ? 'line-clamp-4' : ''} w-full`} className={`${isQuestionCollapsed ? 'line-clamp-4' : ''} w-full`}
@@ -175,7 +170,7 @@ const ConversationBubble = forwardRef<
e.stopPropagation(); e.stopPropagation();
setIsQuestionCollapsed(!isQuestionCollapsed); setIsQuestionCollapsed(!isQuestionCollapsed);
}} }}
className="rounded-full p-2.5 hover:bg-[#D9D9D933]" className="ml-1 rounded-full p-2 hover:bg-[#D9D9D933]"
> >
<img <img
src={ChevronDown} src={ChevronDown}

View File

@@ -11,13 +11,7 @@ import {
handleFetchAnswer, handleFetchAnswer,
handleFetchAnswerSteaming, handleFetchAnswerSteaming,
} from './conversationHandlers'; } from './conversationHandlers';
import { import { Answer, ConversationState, Query, Status } from './conversationModels';
Answer,
Attachment,
ConversationState,
Query,
Status,
} from './conversationModels';
import { ToolCallsType } from './types'; import { ToolCallsType } from './types';
const initialState: ConversationState = { const initialState: ConversationState = {
@@ -38,10 +32,8 @@ export function handleAbort() {
export const fetchAnswer = createAsyncThunk< export const fetchAnswer = createAsyncThunk<
Answer, Answer,
{ question: string; indx?: number; isPreview?: boolean } { question: string; indx?: number }
>( >('fetchAnswer', async ({ question, indx }, { dispatch, getState }) => {
'fetchAnswer',
async ({ question, indx, isPreview = false }, { dispatch, getState }) => {
if (abortController) abortController.abort(); if (abortController) abortController.abort();
abortController = new AbortController(); abortController = new AbortController();
const { signal } = abortController; const { signal } = abortController;
@@ -57,8 +49,6 @@ export const fetchAnswer = createAsyncThunk<
} }
const currentConversationId = state.conversation.conversationId; const currentConversationId = state.conversation.conversationId;
const conversationIdToSend = isPreview ? null : currentConversationId;
const save_conversation = isPreview ? false : true;
if (state.preference) { if (state.preference) {
if (API_STREAMING) { if (API_STREAMING) {
@@ -68,7 +58,7 @@ export const fetchAnswer = createAsyncThunk<
state.preference.token, state.preference.token,
state.preference.selectedDocs!, state.preference.selectedDocs!,
state.conversation.queries, state.conversation.queries,
conversationIdToSend, currentConversationId,
state.preference.prompt.id, state.preference.prompt.id,
state.preference.chunks, state.preference.chunks,
state.preference.token_limit, state.preference.token_limit,
@@ -76,9 +66,10 @@ export const fetchAnswer = createAsyncThunk<
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
const targetIndex = indx ?? state.conversation.queries.length - 1; const targetIndex = indx ?? state.conversation.queries.length - 1;
// Only process events if they match the current conversation
if (currentConversationId === state.conversation.conversationId) {
if (data.type === 'end') { if (data.type === 'end') {
dispatch(conversationSlice.actions.setStatus('idle')); dispatch(conversationSlice.actions.setStatus('idle'));
if (!isPreview) {
getConversations(state.preference.token) getConversations(state.preference.token)
.then((fetchedConversations) => { .then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations)); dispatch(setConversations(fetchedConversations));
@@ -86,17 +77,19 @@ export const fetchAnswer = createAsyncThunk<
.catch((error) => { .catch((error) => {
console.error('Failed to fetch conversations: ', error); console.error('Failed to fetch conversations: ', error);
}); });
}
if (!isSourceUpdated) { if (!isSourceUpdated) {
dispatch( dispatch(
updateStreamingSource({ updateStreamingSource({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { sources: [] }, query: { sources: [] },
}), }),
); );
} }
} else if (data.type === 'id') { } else if (data.type === 'id') {
if (!isPreview) { // Only update the conversationId if it's currently null
const currentState = getState() as RootState;
if (currentState.conversation.conversationId === null) {
dispatch( dispatch(
updateConversationId({ updateConversationId({
query: { conversationId: data.id }, query: { conversationId: data.id },
@@ -107,6 +100,7 @@ export const fetchAnswer = createAsyncThunk<
const result = data.thought; const result = data.thought;
dispatch( dispatch(
updateThought({ updateThought({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { thought: result }, query: { thought: result },
}), }),
@@ -115,6 +109,7 @@ export const fetchAnswer = createAsyncThunk<
isSourceUpdated = true; isSourceUpdated = true;
dispatch( dispatch(
updateStreamingSource({ updateStreamingSource({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { sources: data.source ?? [] }, query: { sources: data.source ?? [] },
}), }),
@@ -131,6 +126,7 @@ export const fetchAnswer = createAsyncThunk<
dispatch(conversationSlice.actions.setStatus('failed')); dispatch(conversationSlice.actions.setStatus('failed'));
dispatch( dispatch(
conversationSlice.actions.raiseError({ conversationSlice.actions.raiseError({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
message: data.error, message: data.error,
}), }),
@@ -138,16 +134,18 @@ export const fetchAnswer = createAsyncThunk<
} else { } else {
dispatch( dispatch(
updateStreamingQuery({ updateStreamingQuery({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { response: data.answer }, query: { response: data.answer },
}), }),
); );
} }
}
}, },
indx, indx,
state.preference.selectedAgent?.id, state.preference.selectedAgent?.id,
attachmentIds, attachmentIds,
save_conversation, true, // Always save conversation
); );
} else { } else {
const answer = await handleFetchAnswer( const answer = await handleFetchAnswer(
@@ -162,7 +160,7 @@ export const fetchAnswer = createAsyncThunk<
state.preference.token_limit, state.preference.token_limit,
state.preference.selectedAgent?.id, state.preference.selectedAgent?.id,
attachmentIds, attachmentIds,
save_conversation, true, // Always save conversation
); );
if (answer) { if (answer) {
let sourcesPrepped = []; let sourcesPrepped = [];
@@ -190,7 +188,6 @@ export const fetchAnswer = createAsyncThunk<
}, },
}), }),
); );
if (!isPreview) {
dispatch( dispatch(
updateConversationId({ updateConversationId({
query: { conversationId: answer.conversationId }, query: { conversationId: answer.conversationId },
@@ -203,7 +200,6 @@ export const fetchAnswer = createAsyncThunk<
.catch((error) => { .catch((error) => {
console.error('Failed to fetch conversations: ', error); console.error('Failed to fetch conversations: ', error);
}); });
}
dispatch(conversationSlice.actions.setStatus('idle')); dispatch(conversationSlice.actions.setStatus('idle'));
} }
} }
@@ -218,8 +214,7 @@ export const fetchAnswer = createAsyncThunk<
sources: [], sources: [],
tool_calls: [], tool_calls: [],
}; };
}, });
);
export const conversationSlice = createSlice({ export const conversationSlice = createSlice({
name: 'conversation', name: 'conversation',
@@ -242,18 +237,20 @@ export const conversationSlice = createSlice({
}, },
updateStreamingQuery( updateStreamingQuery(
state, state,
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{
conversationId: string | null;
index: number;
query: Partial<Query>;
}>,
) { ) {
if (state.status === 'idle') return; const { conversationId, index, query } = action.payload;
const { index, query } = action.payload; // Only update if this update is for the current conversation
if (state.status === 'idle' || state.conversationId !== conversationId)
return;
if (query.response != undefined) { if (query.response != undefined) {
state.queries[index].response = state.queries[index].response =
(state.queries[index].response || '') + query.response; (state.queries[index].response || '') + query.response;
} else {
state.queries[index] = {
...state.queries[index],
...query,
};
} }
}, },
updateConversationId( updateConversationId(
@@ -265,28 +262,35 @@ export const conversationSlice = createSlice({
}, },
updateThought( updateThought(
state, state,
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{
conversationId: string | null;
index: number;
query: Partial<Query>;
}>,
) { ) {
const { index, query } = action.payload; const { conversationId, index, query } = action.payload;
if (state.conversationId !== conversationId) return;
if (query.thought != undefined) { if (query.thought != undefined) {
state.queries[index].thought = state.queries[index].thought =
(state.queries[index].thought || '') + query.thought; (state.queries[index].thought || '') + query.thought;
} else {
state.queries[index] = {
...state.queries[index],
...query,
};
} }
}, },
updateStreamingSource( updateStreamingSource(
state, state,
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{
conversationId: string | null;
index: number;
query: Partial<Query>;
}>,
) { ) {
const { index, query } = action.payload; const { conversationId, index, query } = action.payload;
if (state.conversationId !== conversationId) return;
if (!state.queries[index].sources) { if (!state.queries[index].sources) {
state.queries[index].sources = query?.sources; state.queries[index].sources = query?.sources;
} else { } else if (query.sources) {
state.queries[index].sources!.push(query.sources![0]); state.queries[index].sources!.push(...query.sources);
} }
}, },
updateToolCall(state, action) { updateToolCall(state, action) {
@@ -323,9 +327,15 @@ export const conversationSlice = createSlice({
}, },
raiseError( raiseError(
state, state,
action: PayloadAction<{ index: number; message: string }>, action: PayloadAction<{
conversationId: string | null;
index: number;
message: string;
}>,
) { ) {
const { index, message } = action.payload; const { conversationId, index, message } = action.payload;
if (state.conversationId !== conversationId) return;
state.queries[index].error = message; state.queries[index].error = message;
}, },

View File

@@ -177,7 +177,7 @@ export default function Prompts({
rounded="3xl" rounded="3xl"
border="border" border="border"
showEdit showEdit
showDelete showDelete={(prompt) => prompt.type !== 'public'}
onEdit={({ onEdit={({
id, id,
name, name,

View File

@@ -8,6 +8,7 @@ import {
prefSlice, prefSlice,
} from './preferences/preferenceSlice'; } from './preferences/preferenceSlice';
import uploadReducer from './upload/uploadSlice'; import uploadReducer from './upload/uploadSlice';
import agentPreviewReducer from './agents/agentPreviewSlice';
const key = localStorage.getItem('DocsGPTApiKey'); const key = localStorage.getItem('DocsGPTApiKey');
const prompt = localStorage.getItem('DocsGPTPrompt'); const prompt = localStorage.getItem('DocsGPTPrompt');
@@ -54,6 +55,7 @@ const store = configureStore({
conversation: conversationSlice.reducer, conversation: conversationSlice.reducer,
sharedConversation: sharedConversationSlice.reducer, sharedConversation: sharedConversationSlice.reducer,
upload: uploadReducer, upload: uploadReducer,
agentPreview: agentPreviewReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(prefListenerMiddleware.middleware), getDefaultMiddleware().concat(prefListenerMiddleware.middleware),