From 36c7bd92066c1d1ff031e150bf9866bde767904e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 9 Feb 2026 14:27:53 +0000 Subject: [PATCH] Thinking stream (#2276) * feat: stream thinking tokens * fix: retry bug * fix test --- frontend/src/agents/agentPreviewSlice.ts | 26 +++++++--- frontend/src/conversation/Conversation.tsx | 19 +++++--- .../src/conversation/ConversationMessages.tsx | 7 ++- .../src/conversation/conversationSlice.ts | 21 ++++++--- .../conversation/sharedConversationSlice.ts | 7 ++- frontend/src/preferences/preferenceApi.ts | 47 +++++++++++++++++-- frontend/src/store.ts | 4 +- frontend/src/upload/uploadSlice.ts | 8 ++-- 8 files changed, 103 insertions(+), 36 deletions(-) diff --git a/frontend/src/agents/agentPreviewSlice.ts b/frontend/src/agents/agentPreviewSlice.ts index d765b789..55152d52 100644 --- a/frontend/src/agents/agentPreviewSlice.ts +++ b/frontend/src/agents/agentPreviewSlice.ts @@ -195,12 +195,21 @@ export const agentPreviewSlice = createSlice({ }, resendQuery( state, - action: PayloadAction<{ index: number; prompt: string; query?: Query }>, + action: PayloadAction<{ index: number; prompt: string }>, ) { - state.queries = [ - ...state.queries.splice(0, action.payload.index), - action.payload, - ]; + const { index, prompt } = action.payload; + if (index < 0 || index >= state.queries.length) return; + + state.queries.splice(index + 1); + state.queries[index].prompt = prompt; + delete state.queries[index].response; + delete state.queries[index].thought; + delete state.queries[index].sources; + delete state.queries[index].tool_calls; + delete state.queries[index].error; + delete state.queries[index].structured; + delete state.queries[index].schema; + delete state.queries[index].feedback; }, updateStreamingQuery( state, @@ -309,10 +318,13 @@ export const agentPreviewSlice = createSlice({ .addCase(fetchPreviewAnswer.rejected, (state, action) => { if (action.meta.aborted) { state.status = 'idle'; - return state; + return; } state.status = 'failed'; - state.queries[state.queries.length - 1].error = 'Something went wrong'; + if (state.queries.length > 0) { + state.queries[state.queries.length - 1].error = + 'Something went wrong'; + } }); }, }); diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 62fe7c54..c36d6a7c 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -120,18 +120,20 @@ export default function Conversation() { if (updated === true) { handleQuestion({ question: question as string, index: indx }); } else if (question && status !== 'loading') { - if (lastQueryReturnedErr) { + if (lastQueryReturnedErr && queries.length > 0) { + const retryIndex = queries.length - 1; dispatch( updateQuery({ - index: queries.length - 1, + index: retryIndex, query: { prompt: question, }, }), ); handleQuestion({ - question: question, + question, isRetry: true, + index: retryIndex, }); } else { handleQuestion({ @@ -152,11 +154,14 @@ export default function Conversation() { }; useEffect(() => { - if (queries.length) { - queries[queries.length - 1].error && setLastQueryReturnedErr(true); - queries[queries.length - 1].response && setLastQueryReturnedErr(false); + if (queries.length === 0) { + setLastQueryReturnedErr(false); + return; } - }, [queries[queries.length - 1]]); + + const lastQuery = queries[queries.length - 1]; + setLastQueryReturnedErr(!!lastQuery.error && !lastQuery.response); + }, [queries]); return (
diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index bd099f12..02d5b831 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -24,13 +24,12 @@ type ConversationMessagesProps = { handleQuestion: (params: { question: string; isRetry?: boolean; - updated?: boolean | null; - indx?: number; + index?: number; }) => void; handleQuestionSubmission: ( updatedQuestion?: string, updated?: boolean, - indx?: number, + index?: number, ) => void; handleFeedback?: (query: Query, feedback: FEEDBACK, index: number) => void; queries: Query[]; @@ -169,7 +168,7 @@ export default function ConversationMessages({ handleQuestion({ question: questionToRetry, isRetry: true, - indx: index, + index, }); }} aria-label={t('Retry') || 'Retry'} diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 0298d78c..936d2de8 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -241,12 +241,21 @@ export const conversationSlice = createSlice({ }, resendQuery( state, - action: PayloadAction<{ index: number; prompt: string; query?: Query }>, + action: PayloadAction<{ index: number; prompt: string }>, ) { - state.queries = [ - ...state.queries.splice(0, action.payload.index), - action.payload, - ]; + const { index, prompt } = action.payload; + if (index < 0 || index >= state.queries.length) return; + + state.queries.splice(index + 1); + state.queries[index].prompt = prompt; + delete state.queries[index].response; + delete state.queries[index].thought; + delete state.queries[index].sources; + delete state.queries[index].tool_calls; + delete state.queries[index].error; + delete state.queries[index].structured; + delete state.queries[index].schema; + delete state.queries[index].feedback; }, updateStreamingQuery( state, @@ -370,7 +379,7 @@ export const conversationSlice = createSlice({ .addCase(fetchAnswer.rejected, (state, action) => { if (action.meta.aborted) { state.status = 'idle'; - return state; + return; } state.status = 'failed'; if (state.queries.length > 0) { diff --git a/frontend/src/conversation/sharedConversationSlice.ts b/frontend/src/conversation/sharedConversationSlice.ts index df2650a3..2d52c59a 100644 --- a/frontend/src/conversation/sharedConversationSlice.ts +++ b/frontend/src/conversation/sharedConversationSlice.ts @@ -266,10 +266,13 @@ export const sharedConversationSlice = createSlice({ .addCase(fetchSharedAnswer.rejected, (state, action) => { if (action.meta.aborted) { state.status = 'idle'; - return state; + return; } state.status = 'failed'; - state.queries[state.queries.length - 1].error = 'Something went wrong'; + if (state.queries.length > 0) { + state.queries[state.queries.length - 1].error = + 'Something went wrong'; + } }); }, }); diff --git a/frontend/src/preferences/preferenceApi.ts b/frontend/src/preferences/preferenceApi.ts index 39396959..f94a0f20 100644 --- a/frontend/src/preferences/preferenceApi.ts +++ b/frontend/src/preferences/preferenceApi.ts @@ -90,12 +90,49 @@ export function getLocalApiKey(): string | null { return key; } -export function getLocalRecentDocs(sourceDocs?: Doc[] | null): Doc[] | null { - const docsString = localStorage.getItem('DocsGPTRecentDocs'); - const selectedDocs = docsString ? (JSON.parse(docsString) as Doc[]) : null; +function parseStoredRecentDocs(docsString: string | null): Doc[] | null { + if (!docsString) { + return null; + } - if (!sourceDocs || !selectedDocs || selectedDocs.length === 0) { - return selectedDocs; + try { + const parsedDocs: unknown = JSON.parse(docsString); + + if (Array.isArray(parsedDocs)) { + const docs = parsedDocs.filter( + (doc): doc is Doc => typeof doc === 'object' && doc !== null, + ); + return docs.length > 0 ? docs : null; + } + + if (typeof parsedDocs === 'object' && parsedDocs !== null) { + return [parsedDocs as Doc]; + } + } catch (error) { + console.warn('Failed to parse DocsGPTRecentDocs from localStorage', error); + } + + return null; +} + +export function getStoredRecentDocs(): Doc[] { + const recentDocs = parseStoredRecentDocs( + localStorage.getItem('DocsGPTRecentDocs'), + ); + + if (!recentDocs || recentDocs.length === 0) { + localStorage.removeItem('DocsGPTRecentDocs'); + return []; + } + + return recentDocs; +} + +export function getLocalRecentDocs(sourceDocs?: Doc[] | null): Doc[] | null { + const selectedDocs = getStoredRecentDocs(); + + if (!sourceDocs || selectedDocs.length === 0) { + return selectedDocs.length > 0 ? selectedDocs : null; } const isDocAvailable = (selected: Doc) => { return sourceDocs.some((source) => { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index f5844a9a..6371570a 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit'; import agentPreviewReducer from './agents/agentPreviewSlice'; import { conversationSlice } from './conversation/conversationSlice'; import { sharedConversationSlice } from './conversation/sharedConversationSlice'; +import { getStoredRecentDocs } from './preferences/preferenceApi'; import { Preference, prefListenerMiddleware, @@ -13,7 +14,6 @@ import uploadReducer from './upload/uploadSlice'; const key = localStorage.getItem('DocsGPTApiKey'); const prompt = localStorage.getItem('DocsGPTPrompt'); const chunks = localStorage.getItem('DocsGPTChunks'); -const doc = localStorage.getItem('DocsGPTRecentDocs'); const selectedModel = localStorage.getItem('DocsGPTSelectedModel'); const preloadedState: { preference: Preference } = { @@ -30,7 +30,7 @@ const preloadedState: { preference: Preference } = { { name: 'strict', id: 'strict', type: 'public' }, ], chunks: JSON.parse(chunks ?? '2').toString(), - selectedDocs: doc !== null ? JSON.parse(doc) : [], + selectedDocs: getStoredRecentDocs(), conversations: { data: null, loading: false, diff --git a/frontend/src/upload/uploadSlice.ts b/frontend/src/upload/uploadSlice.ts index d997b1fb..44c67843 100644 --- a/frontend/src/upload/uploadSlice.ts +++ b/frontend/src/upload/uploadSlice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { RootState } from '../store'; export interface Attachment { @@ -147,8 +147,10 @@ export const { } = uploadSlice.actions; export const selectAttachments = (state: RootState) => state.upload.attachments; -export const selectCompletedAttachments = (state: RootState) => - state.upload.attachments.filter((att) => att.status === 'completed'); +export const selectCompletedAttachments = createSelector( + [selectAttachments], + (attachments) => attachments.filter((att) => att.status === 'completed'), +); export const selectUploadTasks = (state: RootState) => state.upload.tasks; export default uploadSlice.reducer;