mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-22 11:15:47 +00:00
fix: preserve task prompts in replay and history
This commit is contained in:
parent
c0a257fe9c
commit
22955708c7
9 changed files with 255 additions and 55 deletions
|
|
@ -134,6 +134,11 @@ export default function ProjectGroup({
|
|||
const taskIdsList = project.tasks
|
||||
?.map((t) => t.task_id)
|
||||
.filter(Boolean) || [project.project_id];
|
||||
const taskQuestions = Object.fromEntries(
|
||||
(project.tasks || [])
|
||||
.filter((task) => task.task_id && task.question)
|
||||
.map((task) => [task.task_id, task.question])
|
||||
);
|
||||
|
||||
setIsLoadingProject(true);
|
||||
try {
|
||||
|
|
@ -144,7 +149,8 @@ export default function ProjectGroup({
|
|||
question,
|
||||
historyId,
|
||||
taskIdsList,
|
||||
project.project_name
|
||||
project.project_name,
|
||||
taskQuestions
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load project:', error);
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ export default function HistorySidebar() {
|
|||
const taskIdsList = project?.tasks.map(
|
||||
(task: HistoryTask) => task.task_id
|
||||
) || [projectId];
|
||||
const taskQuestions = Object.fromEntries(
|
||||
(project?.tasks || [])
|
||||
.filter((task: HistoryTask) => task.task_id && task.question)
|
||||
.map((task: HistoryTask) => [task.task_id, task.question])
|
||||
);
|
||||
|
||||
// If no tasks to replay, create an empty project
|
||||
if (!taskIdsList || taskIdsList.length === 0) {
|
||||
|
|
@ -165,7 +170,8 @@ export default function HistorySidebar() {
|
|||
question,
|
||||
historyId,
|
||||
taskIdsList,
|
||||
project?.project_name
|
||||
project?.project_name,
|
||||
taskQuestions
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ export function SearchHistoryDialog() {
|
|||
projectId: string,
|
||||
question: string,
|
||||
historyId: string,
|
||||
project?: { tasks: { task_id: string }[]; project_name?: string }
|
||||
project?: {
|
||||
tasks: { task_id: string; question?: string }[];
|
||||
project_name?: string;
|
||||
}
|
||||
) => {
|
||||
const existingProject = projectStore.getProjectById(projectId);
|
||||
if (existingProject) {
|
||||
|
|
@ -63,6 +66,11 @@ export function SearchHistoryDialog() {
|
|||
const taskIdsList = project?.tasks
|
||||
?.map((t) => t.task_id)
|
||||
.filter(Boolean) || [projectId];
|
||||
const taskQuestions = Object.fromEntries(
|
||||
(project?.tasks || [])
|
||||
.filter((task) => task.task_id && task.question)
|
||||
.map((task) => [task.task_id, task.question as string])
|
||||
);
|
||||
await loadProjectFromHistory(
|
||||
projectStore,
|
||||
navigate,
|
||||
|
|
@ -70,7 +78,8 @@ export function SearchHistoryDialog() {
|
|||
question,
|
||||
historyId,
|
||||
taskIdsList,
|
||||
project?.project_name
|
||||
project?.project_name,
|
||||
taskQuestions
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,29 @@ import { ChatStore } from '@/store/chatStore';
|
|||
import { ProjectStore } from '@/store/projectStore';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
const getTaskQuestion = (task: ChatStore['tasks'][string] | undefined) => {
|
||||
if (!task?.messages?.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const firstUserMessage = task.messages.find((message) => {
|
||||
return (
|
||||
message.role === 'user' &&
|
||||
typeof message.content === 'string' &&
|
||||
message.content.trim().length > 0
|
||||
);
|
||||
});
|
||||
|
||||
if (firstUserMessage?.content) {
|
||||
return firstUserMessage.content.trim();
|
||||
}
|
||||
|
||||
const firstMessage = task.messages[0];
|
||||
return typeof firstMessage?.content === 'string'
|
||||
? firstMessage.content.trim()
|
||||
: '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Load project from history with final state (no animation).
|
||||
* Waits for loading to complete before navigating.
|
||||
|
|
@ -28,6 +51,8 @@ import { NavigateFunction } from 'react-router-dom';
|
|||
* @param historyId - The history ID
|
||||
* @param taskIdsList - Optional list of task IDs (defaults to [projectId])
|
||||
* @param projectName - Optional project display name
|
||||
* @param taskQuestions - Optional taskId-to-question map used to preserve
|
||||
* each task's original prompt when loading multi-task projects from history
|
||||
*/
|
||||
export const loadProjectFromHistory = async (
|
||||
projectStore: ProjectStore,
|
||||
|
|
@ -36,7 +61,8 @@ export const loadProjectFromHistory = async (
|
|||
question: string,
|
||||
historyId: string,
|
||||
taskIdsList?: string[],
|
||||
projectName?: string
|
||||
projectName?: string,
|
||||
taskQuestions?: Record<string, string>
|
||||
) => {
|
||||
const taskIds = taskIdsList || [projectId];
|
||||
await projectStore.loadProjectFromHistory(
|
||||
|
|
@ -44,7 +70,8 @@ export const loadProjectFromHistory = async (
|
|||
question,
|
||||
projectId,
|
||||
historyId,
|
||||
projectName
|
||||
projectName,
|
||||
taskQuestions
|
||||
);
|
||||
navigate({ pathname: '/' });
|
||||
};
|
||||
|
|
@ -94,48 +121,11 @@ export const replayActiveTask = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// Extract the very first available question from all chat stores and tasks
|
||||
let question = '';
|
||||
let earliestTimestamp = Infinity;
|
||||
let question = getTaskQuestion(chatStore.tasks[taskId]);
|
||||
|
||||
// Get the project data to access all chat stores
|
||||
const project = projectStore.projects[projectId];
|
||||
if (project && project.chatStores) {
|
||||
Object.entries(project.chatStores).forEach(
|
||||
([chatStoreId, chatStoreData]: [string, any]) => {
|
||||
const timestamp = project.chatStoreTimestamps[chatStoreId] || 0;
|
||||
const chatState = chatStoreData.getState();
|
||||
|
||||
if (chatState.tasks) {
|
||||
Object.values(chatState.tasks).forEach((task: any) => {
|
||||
// Check messages for user content
|
||||
if (task.messages && task.messages.length > 0) {
|
||||
const userMessage = task.messages.find(
|
||||
(msg: any) => msg.role === 'user'
|
||||
);
|
||||
if (
|
||||
userMessage &&
|
||||
userMessage.content &&
|
||||
timestamp < earliestTimestamp
|
||||
) {
|
||||
question = userMessage.content.trim();
|
||||
earliestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to current task's first message if no question found
|
||||
if (
|
||||
!question &&
|
||||
chatStore.tasks[taskId] &&
|
||||
chatStore.tasks[taskId].messages[0]
|
||||
) {
|
||||
question = chatStore.tasks[taskId].messages[0].content;
|
||||
console.log('[REPLAY] question fall back to ', question);
|
||||
if (!question) {
|
||||
console.log('[REPLAY] No user question found on active task, using fallback');
|
||||
question = chatStore.tasks[taskId]?.messages?.[0]?.content || '';
|
||||
}
|
||||
|
||||
const historyId = projectStore.getHistoryId(projectId);
|
||||
|
|
|
|||
|
|
@ -193,7 +193,10 @@ export default function Project() {
|
|||
projectId: string,
|
||||
question: string,
|
||||
historyId: string,
|
||||
project?: { tasks: { task_id: string }[]; project_name?: string }
|
||||
project?: {
|
||||
tasks: { task_id: string; question?: string }[];
|
||||
project_name?: string;
|
||||
}
|
||||
) => {
|
||||
const existingProject = projectStore.getProjectById(projectId);
|
||||
if (existingProject) {
|
||||
|
|
@ -204,6 +207,11 @@ export default function Project() {
|
|||
const taskIdsList = project?.tasks
|
||||
?.map((t) => t.task_id)
|
||||
.filter(Boolean) || [projectId];
|
||||
const taskQuestions = Object.fromEntries(
|
||||
(project?.tasks || [])
|
||||
.filter((task) => task.task_id && task.question)
|
||||
.map((task) => [task.task_id, task.question as string])
|
||||
);
|
||||
await loadProjectFromHistory(
|
||||
projectStore,
|
||||
navigate,
|
||||
|
|
@ -211,7 +219,8 @@ export default function Project() {
|
|||
question,
|
||||
historyId,
|
||||
taskIdsList,
|
||||
project?.project_name
|
||||
project?.project_name,
|
||||
taskQuestions
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -96,7 +96,8 @@ interface ProjectStore {
|
|||
question: string,
|
||||
projectId: string,
|
||||
historyId?: string,
|
||||
projectName?: string
|
||||
projectName?: string,
|
||||
taskQuestions?: Record<string, string>
|
||||
) => Promise<string>;
|
||||
|
||||
// Project-level queued messages management
|
||||
|
|
@ -635,7 +636,8 @@ const projectStore = create<ProjectStore>()((set, get) => ({
|
|||
question: string,
|
||||
projectId: string,
|
||||
historyId?: string,
|
||||
projectName?: string
|
||||
projectName?: string,
|
||||
taskQuestions?: Record<string, string>
|
||||
) => {
|
||||
const { projects, removeProject, createProject, createChatStore } = get();
|
||||
|
||||
|
|
@ -679,7 +681,8 @@ const projectStore = create<ProjectStore>()((set, get) => ({
|
|||
const chatStore = project.chatStores[chatId];
|
||||
if (chatStore) {
|
||||
try {
|
||||
await chatStore.getState().replay(taskId, question, 0);
|
||||
const taskQuestion = taskQuestions?.[taskId] || question;
|
||||
await chatStore.getState().replay(taskId, taskQuestion, 0);
|
||||
console.log(`[ProjectStore] Loaded task ${taskId}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import '../../mocks/sse.mock';
|
|||
import '../../../src/store/chatStore';
|
||||
|
||||
import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter';
|
||||
import { replayActiveTask, replayProject } from '../../../src/lib';
|
||||
import {
|
||||
loadProjectFromHistory,
|
||||
replayActiveTask,
|
||||
replayProject,
|
||||
} from '../../../src/lib';
|
||||
import { useProjectStore } from '../../../src/store/projectStore';
|
||||
import {
|
||||
createSSESequence,
|
||||
|
|
@ -973,4 +977,171 @@ describe('Issue #619 - Duplicate Task Boxes after replay', () => {
|
|||
// Verify navigation was called for replay
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('should replay the active task question instead of the earliest project question', async () => {
|
||||
const { result, rerender } = renderHook(() => useChatStoreAdapter());
|
||||
|
||||
const firstQuestion = 'First task question';
|
||||
const secondQuestion = 'Second task question';
|
||||
const projectId = result.current.projectStore.activeProjectId as string;
|
||||
|
||||
const replaySequence = createSSESequence([
|
||||
{
|
||||
event: {
|
||||
step: 'confirmed',
|
||||
data: { question: 'Server replay fallback' },
|
||||
},
|
||||
delay: 50,
|
||||
},
|
||||
{
|
||||
event: {
|
||||
step: 'end',
|
||||
data: '--- Replay complete ---',
|
||||
},
|
||||
delay: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
mockFetchEventSource.mockImplementation(
|
||||
async (url: string, options: any) => {
|
||||
if (!options.onmessage) return;
|
||||
|
||||
if (url.includes('/api/chat/steps/playback/')) {
|
||||
await replaySequence(options.onmessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const { chatStore, projectStore } = result.current;
|
||||
|
||||
chatStore.addMessages(chatStore.activeTaskId, {
|
||||
id: generateUniqueId(),
|
||||
role: 'user',
|
||||
content: firstQuestion,
|
||||
});
|
||||
chatStore.setHasMessages(chatStore.activeTaskId, true);
|
||||
|
||||
const secondChatResult = projectStore.appendInitChatStore(
|
||||
projectId,
|
||||
undefined,
|
||||
'Second'
|
||||
);
|
||||
expect(secondChatResult).not.toBeNull();
|
||||
|
||||
const { taskId: secondTaskId, chatStore: secondChatStore } =
|
||||
secondChatResult!;
|
||||
|
||||
secondChatStore.getState().addMessages(secondTaskId, {
|
||||
id: generateUniqueId(),
|
||||
role: 'user',
|
||||
content: secondQuestion,
|
||||
});
|
||||
secondChatStore.getState().setHasMessages(secondTaskId, true);
|
||||
projectStore.setActiveChatStore(projectId, Object.keys(
|
||||
projectStore.getProjectById(projectId)!.chatStores
|
||||
).at(-1)!);
|
||||
|
||||
rerender();
|
||||
});
|
||||
|
||||
const chatStores = result.current.projectStore.getAllChatStores(projectId);
|
||||
const secondChatStore = chatStores[chatStores.length - 1].chatStore;
|
||||
|
||||
await act(async () => {
|
||||
await replayActiveTask(
|
||||
secondChatStore!.getState() as any,
|
||||
result.current.projectStore,
|
||||
mockNavigate
|
||||
);
|
||||
rerender();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
rerender();
|
||||
const projects = result.current.projectStore.getAllProjects();
|
||||
const replayProject = projects.find((project: any) =>
|
||||
project.name.includes(`Replay Project ${secondQuestion}`)
|
||||
);
|
||||
|
||||
expect(replayProject).toBeDefined();
|
||||
|
||||
const replayChatStores = result.current.projectStore.getAllChatStores(
|
||||
replayProject!.id
|
||||
);
|
||||
const replayChatStore = replayChatStores[1].chatStore;
|
||||
const replayTaskId = replayChatStore.getState().activeTaskId;
|
||||
const replayTask = replayChatStore.getState().tasks[replayTaskId];
|
||||
|
||||
expect(replayTask.messages[0].content).toBe(secondQuestion);
|
||||
expect(replayTask.messages[0].content).not.toBe(firstQuestion);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve per-task questions when loading multi-task history', async () => {
|
||||
const { result, rerender } = renderHook(() => useChatStoreAdapter());
|
||||
|
||||
const firstQuestion = 'History task one';
|
||||
const secondQuestion = 'History task two';
|
||||
|
||||
mockFetchEventSource.mockImplementation(
|
||||
async (_url: string, options: any) => {
|
||||
if (!options.onmessage) return;
|
||||
|
||||
const sequence = createSSESequence([
|
||||
{
|
||||
event: {
|
||||
step: 'confirmed',
|
||||
data: { question: 'History confirmed' },
|
||||
},
|
||||
delay: 10,
|
||||
},
|
||||
{
|
||||
event: {
|
||||
step: 'end',
|
||||
data: '--- History load complete ---',
|
||||
},
|
||||
delay: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
await sequence(options.onmessage);
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await loadProjectFromHistory(
|
||||
result.current.projectStore,
|
||||
mockNavigate,
|
||||
'history-project',
|
||||
firstQuestion,
|
||||
'history-id',
|
||||
['history-task-1', 'history-task-2'],
|
||||
'Loaded Project',
|
||||
{
|
||||
'history-task-1': firstQuestion,
|
||||
'history-task-2': secondQuestion,
|
||||
}
|
||||
);
|
||||
rerender();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
rerender();
|
||||
const chatStores = result.current.projectStore.getAllChatStores(
|
||||
'history-project'
|
||||
);
|
||||
|
||||
expect(chatStores).toHaveLength(3);
|
||||
|
||||
const prompts = chatStores.slice(1).map(({ chatStore }) => {
|
||||
const activeTaskId = chatStore.getState().activeTaskId;
|
||||
return chatStore.getState().tasks[activeTaskId].messages[0].content;
|
||||
});
|
||||
|
||||
expect(prompts).toEqual([firstQuestion, secondQuestion]);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,5 +43,6 @@ vi.mock('../../src/store/authStore', () => ({
|
|||
share_token: null,
|
||||
workerListData: {},
|
||||
})),
|
||||
getWorkerList: vi.fn(() => []),
|
||||
useWorkerList: vi.fn(() => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const mockImplementation = {
|
|||
}),
|
||||
fetchPut: vi.fn(() => Promise.resolve({ success: true })),
|
||||
getBaseURL: vi.fn(() => Promise.resolve('http://localhost:8000')),
|
||||
waitForBackendReady: vi.fn(() => Promise.resolve(true)),
|
||||
proxyFetchPost: vi.fn((url, _data) => {
|
||||
// Mock history creation
|
||||
if (url.includes('/api/chat/history')) {
|
||||
|
|
@ -61,7 +62,10 @@ const mockImplementation = {
|
|||
return Promise.resolve([]);
|
||||
}
|
||||
// Mock snapshots - return empty array to prevent the error
|
||||
if (url.includes('/api/chat/snapshots')) {
|
||||
if (
|
||||
url.includes('/api/chat/snapshots') ||
|
||||
url.includes('/api/v1/chat/snapshots')
|
||||
) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve({});
|
||||
|
|
@ -75,4 +79,5 @@ vi.mock('../../src/api/http', () => mockImplementation);
|
|||
vi.mock('@/api/http', () => mockImplementation);
|
||||
|
||||
// Export the mocked functions for use in tests
|
||||
export const { proxyFetchGet, proxyFetchPost, fetchPost } = mockImplementation;
|
||||
export const { proxyFetchGet, proxyFetchPost, fetchPost, waitForBackendReady } =
|
||||
mockImplementation;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue