Backend:
- task_manager.py: add result/error/completed_at storage, TTL sweeper (5min), store_task_result() helper
- tasks.py: add GET /<task_id> endpoint returning stored result; cancel route stores 'cancelled' status
- __init__.py: start TTL sweeper on app startup
- All 8 bg functions: store result before emitting lightweight WS hint (no payload data)
Frontend:
- src/lib/taskPolling.ts: waitForTaskResult() — polls GET /tasks/{id} every 2s, WS hint triggers immediate poll, 5min timeout
- src/hooks/useTaskPolling.ts: drop-in replacement for useCancellableGeneration using polling
- Migrate 6 Promise-based WS listeners → waitForTaskResult() in DiscussionPanel, FocusGroupSession (×2), PersonaProfile, PersonaModificationModal, useDiscussionGuideGeneration
- Migrate 3 hook-based consumers → useTaskPolling in AIRecruiter, SyntheticUsers, BulkExportProgressModal
Fixes WS Promise leak: polling survives disconnects, background tabs, page reloads.
WS events retained as zero-payload hints for near-zero latency when connected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
220 lines
7.6 KiB
TypeScript
Executable file
220 lines
7.6 KiB
TypeScript
Executable file
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { UseFormReturn } from 'react-hook-form';
|
|
import { toast } from 'sonner';
|
|
import { focusGroupsApi } from '@/lib/api';
|
|
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
|
|
import { waitForTaskResult } from '@/lib/taskPolling';
|
|
import { getSocket } from '@/services/websocketServiceNew';
|
|
|
|
interface DiscussionGuideGenerationOptions {
|
|
form: UseFormReturn<any>;
|
|
}
|
|
|
|
interface DiscussionGuideGenerationReturn {
|
|
discussionGuide: any | null;
|
|
setDiscussionGuide: (guide: any | null) => void;
|
|
discussionGuideRef: React.MutableRefObject<any | null>;
|
|
isEditingGuide: boolean;
|
|
setIsEditingGuide: (editing: boolean) => void;
|
|
guideGenerationState: {
|
|
isGenerating: boolean;
|
|
isComplete: boolean;
|
|
hasError: boolean;
|
|
isCancelling: boolean;
|
|
taskId: string | null;
|
|
};
|
|
guideGenerationControls: {
|
|
startGeneration: () => void;
|
|
setTaskId: (id: string) => void;
|
|
completeGeneration: () => void;
|
|
failGeneration: (error: string) => void;
|
|
cancelGeneration: () => void;
|
|
resetGeneration: () => void;
|
|
};
|
|
isGuideProgressModalOpen: boolean;
|
|
setIsGuideProgressModalOpen: (open: boolean) => void;
|
|
isDownloadingGuide: boolean;
|
|
generateDiscussionGuide: (values: any, focusGroupId?: string) => Promise<string>;
|
|
handleSaveDiscussionGuide: (updatedGuide: any) => void;
|
|
handleDownloadDiscussionGuide: () => Promise<void>;
|
|
handleEditingStateChange: (editing: boolean) => void;
|
|
handleGuideProgressComplete: () => void;
|
|
isJsonFormat: (guide: any) => boolean;
|
|
}
|
|
|
|
export function useDiscussionGuideGeneration({
|
|
form,
|
|
}: DiscussionGuideGenerationOptions): DiscussionGuideGenerationReturn {
|
|
const socket = getSocket();
|
|
const [guideGenerationState, guideGenerationControls] = useCancellableGeneration('discussion guide generation', socket);
|
|
const [isGuideProgressModalOpen, setIsGuideProgressModalOpen] = useState(false);
|
|
|
|
const [discussionGuide, setDiscussionGuide] = useState<any | null>(null);
|
|
const [isEditingGuide, setIsEditingGuide] = useState(false);
|
|
const [isDownloadingGuide, setIsDownloadingGuide] = useState(false);
|
|
|
|
// Ref to access current discussionGuide in callbacks without adding it as dependency
|
|
const discussionGuideRef = useRef(discussionGuide);
|
|
discussionGuideRef.current = discussionGuide;
|
|
|
|
// Helper function to determine if discussion guide is JSON format
|
|
const isJsonFormat = useCallback((guide: any): boolean => {
|
|
return guide && typeof guide === 'object' && guide.title && guide.sections;
|
|
}, []);
|
|
|
|
// Function to generate a discussion guide via the API
|
|
const generateDiscussionGuide = useCallback(async (values: any, focusGroupId?: string): Promise<string> => {
|
|
guideGenerationControls.startGeneration();
|
|
setIsGuideProgressModalOpen(true);
|
|
|
|
try {
|
|
const requestData = {
|
|
name: values.focusGroupName,
|
|
description: values.researchBrief,
|
|
objective: values.researchBrief,
|
|
topic: values.discussionTopics,
|
|
duration: parseInt(values.duration),
|
|
llm_model: values.llm_model,
|
|
reasoning_effort: values.reasoning_effort,
|
|
verbosity: values.verbosity
|
|
};
|
|
|
|
const response = focusGroupId
|
|
? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData)
|
|
: await focusGroupsApi.generateDiscussionGuide(requestData);
|
|
|
|
const taskId = response.data?.task_id;
|
|
if (taskId) {
|
|
guideGenerationControls.setTaskId(taskId);
|
|
}
|
|
|
|
// Backend returns 202 immediately — wait for result via HTTP polling
|
|
if (response.status === 202 && taskId) {
|
|
const taskResult = await waitForTaskResult(taskId);
|
|
if (taskResult.status === 'completed') {
|
|
const guide = taskResult.result?.discussionGuide;
|
|
if (guide) {
|
|
guideGenerationControls.completeGeneration();
|
|
return guide;
|
|
} else {
|
|
guideGenerationControls.failGeneration('No guide returned');
|
|
throw new Error('No discussion guide returned');
|
|
}
|
|
} else if (taskResult.status === 'failed') {
|
|
guideGenerationControls.failGeneration(taskResult.error || 'Generation failed');
|
|
throw new Error(taskResult.error || 'Generation failed');
|
|
} else {
|
|
// cancelled
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Fallback: synchronous response with guide in body
|
|
if (response.data?.discussionGuide) {
|
|
guideGenerationControls.completeGeneration();
|
|
return response.data.discussionGuide;
|
|
}
|
|
|
|
throw new Error("Failed to generate discussion guide");
|
|
} catch (error: any) {
|
|
if (error.response?.status === 499) {
|
|
return '';
|
|
}
|
|
|
|
console.error("Error generating discussion guide:", error);
|
|
guideGenerationControls.failGeneration(error.message || 'Failed to generate discussion guide');
|
|
|
|
let errorMessage = 'Unknown error occurred';
|
|
if (error?.response?.data?.error) {
|
|
errorMessage = error.response.data.error;
|
|
} else if (error?.message) {
|
|
errorMessage = error.message;
|
|
}
|
|
|
|
toast.error("Failed to generate discussion guide", {
|
|
description: errorMessage,
|
|
action: {
|
|
label: "Retry",
|
|
onClick: () => generateDiscussionGuide(values, focusGroupId)
|
|
}
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}, [guideGenerationControls]);
|
|
|
|
const handleGuideProgressComplete = useCallback(() => {
|
|
setIsGuideProgressModalOpen(false);
|
|
guideGenerationControls.resetGeneration();
|
|
}, [guideGenerationControls]);
|
|
|
|
// Stable callback for saving discussion guide changes
|
|
const handleSaveDiscussionGuide = useCallback((updatedGuide: any) => {
|
|
if (!isEditingGuide) {
|
|
setDiscussionGuide(updatedGuide);
|
|
toast.success('Discussion guide updated', {
|
|
description: 'Your changes have been saved.'
|
|
});
|
|
} else {
|
|
discussionGuideRef.current = updatedGuide;
|
|
}
|
|
}, [isEditingGuide]);
|
|
|
|
// Handle editing state changes from DiscussionGuideViewer
|
|
const handleEditingStateChange = useCallback((editing: boolean) => {
|
|
setIsEditingGuide(editing);
|
|
|
|
if (!editing && discussionGuideRef.current) {
|
|
setDiscussionGuide(discussionGuideRef.current);
|
|
}
|
|
}, []);
|
|
|
|
// Function to download discussion guide as markdown
|
|
const handleDownloadDiscussionGuide = useCallback(async () => {
|
|
if (!discussionGuideRef.current) {
|
|
toast.error("No discussion guide available", {
|
|
description: "Please generate a discussion guide first"
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsDownloadingGuide(true);
|
|
|
|
try {
|
|
const { downloadDiscussionGuideAsMarkdown } = await import('@/utils/discussionGuideMarkdown');
|
|
const formValues = form.getValues();
|
|
|
|
downloadDiscussionGuideAsMarkdown(discussionGuideRef.current, formValues.focusGroupName);
|
|
|
|
toast.success("Discussion guide downloaded", {
|
|
description: "The guide has been saved to your downloads folder"
|
|
});
|
|
} catch (error) {
|
|
console.error('Error downloading discussion guide:', error);
|
|
toast.error("Download failed", {
|
|
description: "Unable to download the discussion guide. Please try again."
|
|
});
|
|
} finally {
|
|
setIsDownloadingGuide(false);
|
|
}
|
|
}, [form]);
|
|
|
|
return {
|
|
discussionGuide,
|
|
setDiscussionGuide,
|
|
discussionGuideRef,
|
|
isEditingGuide,
|
|
setIsEditingGuide,
|
|
guideGenerationState,
|
|
guideGenerationControls,
|
|
isGuideProgressModalOpen,
|
|
setIsGuideProgressModalOpen,
|
|
isDownloadingGuide,
|
|
generateDiscussionGuide,
|
|
handleSaveDiscussionGuide,
|
|
handleDownloadDiscussionGuide,
|
|
handleEditingStateChange,
|
|
handleGuideProgressComplete,
|
|
isJsonFormat,
|
|
};
|
|
}
|