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');
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() {
try {
const response = await userService.getPinnedAgents(token);

View File

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

View File

@@ -57,9 +57,7 @@ export default function SharedAgent() {
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(
fetchAnswer({ question, indx: index, isPreview: false }),
);
fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
},
[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;
showEdit?: boolean;
onEdit?: (value: { name: string; id: string; type: string }) => void;
showDelete?: boolean;
showDelete?: boolean | ((option: any) => boolean);
onDelete?: (value: string) => void;
placeholder?: string;
placeholderTextColor?: string;
@@ -173,8 +173,15 @@ function Dropdown({
)}
{showDelete && onDelete && (
<button
onClick={() => onDelete(option.id)}
disabled={option.type === 'public'}
onClick={(e) => {
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
src={Trash}

View File

@@ -157,12 +157,7 @@ const ConversationBubble = forwardRef<
{!isEditClicked && (
<>
<div className="relative mr-2 flex w-full flex-col">
<div
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 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">
<div
ref={messageRef}
className={`${isQuestionCollapsed ? 'line-clamp-4' : ''} w-full`}
@@ -175,7 +170,7 @@ const ConversationBubble = forwardRef<
e.stopPropagation();
setIsQuestionCollapsed(!isQuestionCollapsed);
}}
className="rounded-full p-2.5 hover:bg-[#D9D9D933]"
className="ml-1 rounded-full p-2 hover:bg-[#D9D9D933]"
>
<img
src={ChevronDown}

View File

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

View File

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

View File

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