mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
- Fix race condition where agent responses went to wrong threads when users switched conversations - Add activeThreadId state to track which thread is currently sending/receiving messages - Implement single active conversation pattern to prevent concurrent message sending - Fix optimistic message persistence bug where user messages disappeared after conversation switch - Replace optimistic messaging with immediate persistent storage for user messages - Add clear UI feedback when other conversations are active with disabled inputs - Ensure responses always go to original sending thread regardless of current selection - Simplify addInferenceResponse logic by removing complex retroactive user message handling - Add proper error handling for failed message sends with cleanup of persisted messages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
|
|
|
import { injectAll } from '../lib/ai/injector';
|
|
import type { Message } from '../lib/ai/providers/interface';
|
|
import { threadApi } from '../services/api/threadApi';
|
|
import type { Thread, ThreadMessage } from '../types/thread';
|
|
|
|
interface ThreadState {
|
|
// Existing local data (will be persisted)
|
|
threads: Thread[];
|
|
selectedThreadId: string | null;
|
|
panelWidth: number;
|
|
lastViewedAt: Record<string, number>;
|
|
activeThreadId: string | null; // Track which thread is currently sending/receiving
|
|
|
|
// NEW: Add efficient message storage
|
|
messagesByThreadId: Record<string, ThreadMessage[]>;
|
|
|
|
// Current messages view (not persisted)
|
|
messages: ThreadMessage[];
|
|
|
|
// Keep these API states (NOT persisted)
|
|
isLoadingMessages: boolean; // For AI response waiting
|
|
messagesError: string | null; // For send API errors
|
|
sendStatus: 'idle' | 'loading' | 'success' | 'error';
|
|
sendError: string | null;
|
|
deleteStatus: 'idle' | 'loading' | 'success' | 'error';
|
|
purgeStatus: 'idle' | 'loading' | 'success' | 'error';
|
|
suggestedQuestions: Array<{ text: string; confidence: number }>;
|
|
isLoadingSuggestions: boolean;
|
|
suggestError: string | null;
|
|
}
|
|
|
|
const initialState: ThreadState = {
|
|
threads: [],
|
|
selectedThreadId: null,
|
|
panelWidth: 320,
|
|
lastViewedAt: {},
|
|
activeThreadId: null,
|
|
messagesByThreadId: {},
|
|
messages: [],
|
|
isLoadingMessages: false,
|
|
messagesError: null,
|
|
sendStatus: 'idle',
|
|
sendError: null,
|
|
deleteStatus: 'idle',
|
|
purgeStatus: 'idle',
|
|
suggestedQuestions: [],
|
|
isLoadingSuggestions: false,
|
|
suggestError: null,
|
|
};
|
|
|
|
// Removed fetchThreads - threads are now managed locally
|
|
|
|
// Removed fetchThreadMessages - messages are now managed locally
|
|
|
|
// Removed createThread - using local thread creation
|
|
|
|
// Removed deleteThread - using local thread deletion
|
|
|
|
export const purgeThreads = createAsyncThunk(
|
|
'thread/purgeThreads',
|
|
async (_, { dispatch, rejectWithValue }) => {
|
|
try {
|
|
const data = await threadApi.purge({
|
|
messages: false,
|
|
agentThreads: true,
|
|
deleteEverything: true,
|
|
});
|
|
// Clear local threads after successful purge
|
|
dispatch({ type: 'thread/clearAllThreads' });
|
|
return data;
|
|
} catch (error) {
|
|
const msg =
|
|
error && typeof error === 'object' && 'error' in error
|
|
? String(error.error)
|
|
: 'Failed to purge threads';
|
|
return rejectWithValue(msg);
|
|
}
|
|
}
|
|
);
|
|
|
|
export const sendMessage = createAsyncThunk(
|
|
'thread/sendMessage',
|
|
async (
|
|
{ threadId, message }: { threadId: string; message: string },
|
|
{ dispatch, getState, rejectWithValue }
|
|
) => {
|
|
// 1. Add user message locally immediately (optimistic update)
|
|
const userMessage: ThreadMessage = {
|
|
id: `msg_${Date.now()}_${Math.random()}`,
|
|
content: message,
|
|
type: 'text',
|
|
extraMetadata: {},
|
|
sender: 'user',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
try {
|
|
dispatch(addMessageLocal({ threadId, message: userMessage }));
|
|
|
|
// 2. Process message with SOUL + TOOLS injection before sending to API
|
|
let processedMessage = message;
|
|
try {
|
|
const userMessage: Message = { role: 'user', content: [{ type: 'text', text: message }] };
|
|
|
|
const injectedMessage = await injectAll(userMessage, {
|
|
mode: 'context-block',
|
|
includeMetadata: false,
|
|
});
|
|
|
|
// Extract the processed text
|
|
processedMessage = injectedMessage.content
|
|
.filter(block => block.type === 'text')
|
|
.map(block => (block as { text: string }).text)
|
|
.join('\n');
|
|
|
|
console.log('✅ SOUL + TOOLS injection successful in Redux sendMessage thunk');
|
|
} catch (injectionError) {
|
|
console.warn(
|
|
'⚠️ SOUL + TOOLS injection failed in Redux sendMessage thunk:',
|
|
injectionError
|
|
);
|
|
// Continue with original message
|
|
}
|
|
|
|
// 3. Send to API with processed message (disable injection in threadApi to avoid double injection)
|
|
const data = await threadApi.sendMessage(processedMessage, threadId, { injectSoul: false });
|
|
|
|
// 4. For now, we'll handle AI response via the existing inference API
|
|
// The AI response will be added separately via addInferenceResponse
|
|
|
|
return data;
|
|
} catch (error) {
|
|
// Remove optimistic user message on failure
|
|
const state = (getState() as { thread: ThreadState }).thread;
|
|
const messages = state.messagesByThreadId[threadId] || [];
|
|
const filteredMessages = messages.filter(m => m.id !== userMessage.id);
|
|
dispatch(updateMessagesForThread({ threadId, messages: filteredMessages }));
|
|
|
|
const msg =
|
|
error && typeof error === 'object' && 'error' in error
|
|
? String((error as { error: unknown }).error)
|
|
: 'Failed to send message';
|
|
return rejectWithValue(msg);
|
|
}
|
|
}
|
|
);
|
|
|
|
export const fetchSuggestedQuestions = createAsyncThunk(
|
|
'thread/fetchSuggestedQuestions',
|
|
async (conversationId: string | undefined, { rejectWithValue }) => {
|
|
try {
|
|
const data = await threadApi.getSuggestQuestions(conversationId);
|
|
return data.suggestions;
|
|
} catch (error) {
|
|
const msg =
|
|
error && typeof error === 'object' && 'error' in error
|
|
? String(error.error)
|
|
: 'Failed to load suggestions';
|
|
return rejectWithValue(msg);
|
|
}
|
|
}
|
|
);
|
|
|
|
const threadSlice = createSlice({
|
|
name: 'thread',
|
|
initialState,
|
|
reducers: {
|
|
setSelectedThread: (state, action: { payload: string }) => {
|
|
state.selectedThreadId = action.payload;
|
|
// Load messages from local storage instead of clearing
|
|
state.messages = state.messagesByThreadId[action.payload] || [];
|
|
state.messagesError = null;
|
|
state.suggestedQuestions = [];
|
|
state.suggestError = null;
|
|
},
|
|
clearSelectedThread: state => {
|
|
state.selectedThreadId = null;
|
|
state.messages = [];
|
|
state.messagesError = null;
|
|
state.suggestedQuestions = [];
|
|
state.suggestError = null;
|
|
},
|
|
clearSuggestedQuestions: state => {
|
|
state.suggestedQuestions = [];
|
|
state.suggestError = null;
|
|
},
|
|
clearDeleteStatus: state => {
|
|
state.deleteStatus = 'idle';
|
|
},
|
|
clearPurgeStatus: state => {
|
|
state.purgeStatus = 'idle';
|
|
},
|
|
addOptimisticMessage: (state, action: { payload: { content: string } }) => {
|
|
state.messages.push({
|
|
id: `optimistic-${Date.now()}`,
|
|
content: action.payload.content,
|
|
type: 'text',
|
|
extraMetadata: {},
|
|
sender: 'user',
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
},
|
|
addInferenceResponse: (state, action: { payload: { content: string; threadId?: string } }) => {
|
|
const aiMessage: ThreadMessage = {
|
|
id: `inference-${Date.now()}`,
|
|
content: action.payload.content,
|
|
type: 'text',
|
|
extraMetadata: {},
|
|
sender: 'agent',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Use provided threadId or fall back to activeThreadId to ensure response goes to correct thread
|
|
const targetThreadId = action.payload.threadId || state.activeThreadId;
|
|
|
|
if (targetThreadId) {
|
|
// Ensure messagesByThreadId exists for this thread
|
|
if (!state.messagesByThreadId[targetThreadId]) {
|
|
state.messagesByThreadId[targetThreadId] = [];
|
|
}
|
|
|
|
// Add the AI response to persistent storage
|
|
state.messagesByThreadId[targetThreadId].push(aiMessage);
|
|
|
|
// Add to current messages view only if it's the currently selected thread
|
|
if (targetThreadId === state.selectedThreadId) {
|
|
state.messages.push(aiMessage);
|
|
}
|
|
|
|
// Update thread metadata
|
|
const thread = state.threads.find(t => t.id === targetThreadId);
|
|
if (thread) {
|
|
thread.messageCount = state.messagesByThreadId[targetThreadId].length;
|
|
thread.lastMessageAt = aiMessage.createdAt;
|
|
}
|
|
}
|
|
|
|
// Clear active thread when response is received
|
|
state.activeThreadId = null;
|
|
},
|
|
removeOptimisticMessages: state => {
|
|
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
|
|
},
|
|
clearSendError: state => {
|
|
state.sendError = null;
|
|
},
|
|
setPanelWidth: (state, action: { payload: number }) => {
|
|
state.panelWidth = action.payload;
|
|
},
|
|
setLastViewed: (state, action: { payload: string }) => {
|
|
const ts = Date.now();
|
|
state.lastViewedAt[action.payload] = ts;
|
|
},
|
|
// Local thread management
|
|
createThreadLocal: (
|
|
state,
|
|
action: { payload: { id: string; title: string; createdAt: string } }
|
|
) => {
|
|
const newThread: Thread = {
|
|
id: action.payload.id,
|
|
title: action.payload.title,
|
|
chatId: null,
|
|
isActive: true,
|
|
messageCount: 0,
|
|
lastMessageAt: action.payload.createdAt,
|
|
createdAt: action.payload.createdAt,
|
|
};
|
|
state.threads.unshift(newThread);
|
|
state.messagesByThreadId[action.payload.id] = [];
|
|
},
|
|
addMessageLocal: (state, action: { payload: { threadId: string; message: ThreadMessage } }) => {
|
|
const { threadId, message } = action.payload;
|
|
if (!state.messagesByThreadId[threadId]) {
|
|
state.messagesByThreadId[threadId] = [];
|
|
}
|
|
state.messagesByThreadId[threadId].push(message);
|
|
|
|
// Update thread metadata
|
|
const thread = state.threads.find(t => t.id === threadId);
|
|
if (thread) {
|
|
thread.messageCount = state.messagesByThreadId[threadId].length;
|
|
thread.lastMessageAt = message.createdAt;
|
|
}
|
|
},
|
|
deleteThreadLocal: (state, action: { payload: string }) => {
|
|
const threadId = action.payload;
|
|
state.threads = state.threads.filter(t => t.id !== threadId);
|
|
delete state.messagesByThreadId[threadId];
|
|
delete state.lastViewedAt[threadId];
|
|
if (state.selectedThreadId === threadId) {
|
|
state.selectedThreadId = null;
|
|
}
|
|
},
|
|
updateMessagesForThread: (
|
|
state,
|
|
action: { payload: { threadId: string; messages: ThreadMessage[] } }
|
|
) => {
|
|
const { threadId, messages } = action.payload;
|
|
state.messagesByThreadId[threadId] = messages;
|
|
|
|
// Update thread metadata
|
|
const thread = state.threads.find(t => t.id === threadId);
|
|
if (thread) {
|
|
thread.messageCount = messages.length;
|
|
thread.lastMessageAt =
|
|
messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt;
|
|
}
|
|
},
|
|
clearAllThreads: state => {
|
|
state.threads = [];
|
|
state.messagesByThreadId = {};
|
|
state.selectedThreadId = null;
|
|
state.messages = [];
|
|
state.lastViewedAt = {};
|
|
state.activeThreadId = null;
|
|
},
|
|
setActiveThread: (state, action: { payload: string | null }) => {
|
|
state.activeThreadId = action.payload;
|
|
},
|
|
},
|
|
extraReducers: builder => {
|
|
builder
|
|
// Removed fetchThreads and fetchThreadMessages cases
|
|
// Removed createThread cases - using local thread creation
|
|
// Removed deleteThread cases - using local thread deletion
|
|
// purgeThreads
|
|
.addCase(purgeThreads.pending, state => {
|
|
state.purgeStatus = 'loading';
|
|
})
|
|
.addCase(purgeThreads.fulfilled, state => {
|
|
state.purgeStatus = 'success';
|
|
// clearAllThreads is dispatched from the thunk
|
|
})
|
|
.addCase(purgeThreads.rejected, state => {
|
|
state.purgeStatus = 'error';
|
|
})
|
|
// clearAllThreads action
|
|
.addCase('thread/clearAllThreads', state => {
|
|
state.threads = [];
|
|
state.messagesByThreadId = {};
|
|
state.selectedThreadId = null;
|
|
state.messages = [];
|
|
state.lastViewedAt = {};
|
|
state.activeThreadId = null;
|
|
})
|
|
// sendMessage
|
|
.addCase(sendMessage.pending, (state, action) => {
|
|
state.sendStatus = 'loading';
|
|
state.sendError = null;
|
|
// Set the active thread when message sending starts
|
|
state.activeThreadId = action.meta.arg.threadId;
|
|
})
|
|
.addCase(sendMessage.fulfilled, state => {
|
|
state.sendStatus = 'success';
|
|
state.suggestedQuestions = [];
|
|
state.suggestError = null;
|
|
// Don't clear activeThreadId here - let addInferenceResponse handle it
|
|
})
|
|
.addCase(sendMessage.rejected, (state, action) => {
|
|
state.sendStatus = 'error';
|
|
state.sendError = action.payload as string;
|
|
// Remove optimistic messages so the user doesn't see phantom messages
|
|
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
|
|
// Clear active thread on error
|
|
state.activeThreadId = null;
|
|
})
|
|
// fetchSuggestedQuestions
|
|
.addCase(fetchSuggestedQuestions.pending, state => {
|
|
state.isLoadingSuggestions = true;
|
|
state.suggestError = null;
|
|
})
|
|
.addCase(fetchSuggestedQuestions.fulfilled, (state, action) => {
|
|
state.isLoadingSuggestions = false;
|
|
state.suggestedQuestions = action.payload;
|
|
})
|
|
.addCase(fetchSuggestedQuestions.rejected, (state, action) => {
|
|
state.isLoadingSuggestions = false;
|
|
state.suggestError = action.payload as string;
|
|
state.suggestedQuestions = [];
|
|
});
|
|
},
|
|
});
|
|
|
|
export const {
|
|
setSelectedThread,
|
|
clearSelectedThread,
|
|
clearDeleteStatus,
|
|
clearPurgeStatus,
|
|
addOptimisticMessage,
|
|
addInferenceResponse,
|
|
removeOptimisticMessages,
|
|
clearSendError,
|
|
clearSuggestedQuestions,
|
|
setPanelWidth,
|
|
setLastViewed,
|
|
createThreadLocal,
|
|
addMessageLocal,
|
|
deleteThreadLocal,
|
|
updateMessagesForThread,
|
|
clearAllThreads,
|
|
setActiveThread,
|
|
} = threadSlice.actions;
|
|
export default threadSlice.reducer;
|