semblance/src/hooks/useDiscussionGuideGeneration.ts
Vadym Samoilenko 1b387daacf Migrate task result delivery from WebSocket to HTTP polling
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>
2026-03-23 16:46:58 +00:00

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,
};
}