mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
|
|
|
import { threadApi } from '../services/api/threadApi';
|
|
import type { Thread, ThreadMessage } from '../types/thread';
|
|
import { injectAll } from '../lib/ai/injector';
|
|
import type { Message } from '../lib/ai/providers/interface';
|
|
import type { RootState } from './index';
|
|
|
|
interface ThreadState {
|
|
// Existing local data (will be persisted)
|
|
threads: Thread[];
|
|
selectedThreadId: string | null;
|
|
panelWidth: number;
|
|
lastViewedAt: Record<string, number>;
|
|
|
|
// 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: {},
|
|
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 } }) => {
|
|
const aiMessage: ThreadMessage = {
|
|
id: `inference-${Date.now()}`,
|
|
content: action.payload.content,
|
|
type: 'text',
|
|
extraMetadata: {},
|
|
sender: 'agent',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
// Add to current messages view
|
|
state.messages.push(aiMessage);
|
|
|
|
// Also add to persistent storage
|
|
if (state.selectedThreadId) {
|
|
if (!state.messagesByThreadId[state.selectedThreadId]) {
|
|
state.messagesByThreadId[state.selectedThreadId] = [];
|
|
}
|
|
|
|
// CRITICAL FIX: Ensure the preceding user message is also persisted
|
|
// Find the last user message that might not be in persistent storage yet
|
|
const lastUserMessage = state.messages.filter(m => m.sender === 'user').pop();
|
|
|
|
if (lastUserMessage) {
|
|
const persistedMessages = state.messagesByThreadId[state.selectedThreadId];
|
|
const userMessageExists = persistedMessages.some(m => m.id === lastUserMessage.id);
|
|
|
|
// If user message isn't persisted yet, add it first.
|
|
// Replace optimistic- prefix with a stable id so historySnapshot
|
|
// (which filters out optimistic- ids) includes it on future sends.
|
|
if (!userMessageExists) {
|
|
const stableMessage = lastUserMessage.id.startsWith('optimistic-')
|
|
? { ...lastUserMessage, id: `msg_${Date.now()}_user` }
|
|
: lastUserMessage;
|
|
persistedMessages.push(stableMessage);
|
|
// Keep state.messages in sync with the stable id
|
|
const idx = state.messages.findLastIndex(m => m.id === lastUserMessage.id);
|
|
if (idx !== -1) state.messages[idx] = stableMessage;
|
|
}
|
|
}
|
|
|
|
// Now add the AI response
|
|
state.messagesByThreadId[state.selectedThreadId].push(aiMessage);
|
|
|
|
// Update thread metadata
|
|
const thread = state.threads.find(t => t.id === state.selectedThreadId);
|
|
if (thread) {
|
|
thread.messageCount = state.messagesByThreadId[state.selectedThreadId].length;
|
|
thread.lastMessageAt = aiMessage.createdAt;
|
|
}
|
|
}
|
|
},
|
|
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 = {};
|
|
},
|
|
},
|
|
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 = {};
|
|
})
|
|
// sendMessage
|
|
.addCase(sendMessage.pending, state => {
|
|
state.sendStatus = 'loading';
|
|
state.sendError = null;
|
|
})
|
|
.addCase(sendMessage.fulfilled, state => {
|
|
state.sendStatus = 'success';
|
|
state.suggestedQuestions = [];
|
|
state.suggestError = null;
|
|
})
|
|
.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-'));
|
|
})
|
|
// 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,
|
|
} = threadSlice.actions;
|
|
export default threadSlice.reducer;
|