Merge pull request #618 from langflow-ai/fix/chat_filter_clearing

fix: chat filter being cleared
This commit is contained in:
Sebastián Estévez 2025-12-05 18:09:07 -05:00 committed by GitHub
commit d908174758
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 252 additions and 368 deletions

View file

@ -18,390 +18,261 @@ interface OnboardingUploadProps {
} }
const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => { const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [currentStep, setCurrentStep] = useState<number | null>(null); const [currentStep, setCurrentStep] = useState<number | null>(null);
const [uploadedFilename, setUploadedFilename] = useState<string | null>(null); const [uploadedFilename, setUploadedFilename] = useState<string | null>(null);
const [uploadedTaskId, setUploadedTaskId] = useState<string | null>(null); const [uploadedTaskId, setUploadedTaskId] = useState<string | null>(null);
const [shouldCreateFilter, setShouldCreateFilter] = useState(false); const [shouldCreateFilter, setShouldCreateFilter] = useState(false);
const [isCreatingFilter, setIsCreatingFilter] = useState(false); const [isCreatingFilter, setIsCreatingFilter] = useState(false);
const [error, setError] = useState<string | null>(null);
// Track which tasks we've already handled to prevent infinite loops const createFilterMutation = useCreateFilter();
const handledFailedTasksRef = useRef<Set<string>>(new Set());
const createFilterMutation = useCreateFilter(); const STEP_LIST = [
"Uploading your document",
"Generating embeddings",
"Ingesting document",
"Processing your document",
];
const STEP_LIST = [ // Query tasks to track completion
"Uploading your document", const { data: tasks } = useGetTasksQuery({
"Generating embeddings", enabled: currentStep !== null, // Only poll when upload has started
"Ingesting document", refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during upload
"Processing your document", });
];
// Query tasks to track completion const { refetch: refetchNudges } = useGetNudgesQuery(null);
const { data: tasks } = useGetTasksQuery({
enabled: currentStep !== null, // Only poll when upload has started
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during upload
});
// Monitor tasks and call onComplete when file processing is done // Monitor tasks and call onComplete when file processing is done
useEffect(() => { useEffect(() => {
if (currentStep === null || !tasks || !uploadedTaskId) { if (currentStep === null || !tasks || !uploadedTaskId) {
return; return;
} }
// Find the task by task ID from the upload response // Find the task by task ID from the upload response
const matchingTask = tasks.find((task) => task.task_id === uploadedTaskId); const matchingTask = tasks.find((task) => task.task_id === uploadedTaskId);
// If no matching task found, wait for it to appear // If no matching task found, wait for it to appear
if (!matchingTask) { if (!matchingTask) {
return; return;
} }
// Skip if this task was already handled as a failed task (from a previous failed upload) // Check if the matching task is still active (pending, running, or processing)
// This prevents processing old failed tasks when a new upload starts const isTaskActive =
if (handledFailedTasksRef.current.has(matchingTask.task_id)) { matchingTask.status === "pending" ||
// Check if it's a failed task that we've already handled matchingTask.status === "running" ||
const hasFailedFile = matchingTask.status === "processing";
matchingTask.files &&
Object.values(matchingTask.files).some(
(file) => file.status === "failed" || file.status === "error",
);
if (hasFailedFile) {
// This is an old failed task that we've already handled, ignore it
console.log(
"Skipping already-handled failed task:",
matchingTask.task_id,
);
return;
}
// If it's not a failed task, remove it from handled list (it might have succeeded on retry)
handledFailedTasksRef.current.delete(matchingTask.task_id);
}
// Check if any file failed in the matching task // If task is completed or has processed files, complete the onboarding step
const hasFailedFile = (() => { if (!isTaskActive || (matchingTask.processed_files ?? 0) > 0) {
// Must have files object // Set to final step to show "Done"
if (!matchingTask.files || typeof matchingTask.files !== "object") { setCurrentStep(STEP_LIST.length);
return false;
}
const fileEntries = Object.values(matchingTask.files); // Create knowledge filter for uploaded document if requested
// Guard against race condition: only create if not already creating
if (shouldCreateFilter && uploadedFilename && !isCreatingFilter) {
// Reset flags immediately (synchronously) to prevent duplicate creation
setShouldCreateFilter(false);
const filename = uploadedFilename;
setUploadedFilename(null);
setIsCreatingFilter(true);
// Must have at least one file // Get display name from filename (remove extension for cleaner name)
if (fileEntries.length === 0) { const displayName = filename.includes(".")
return false; ? filename.substring(0, filename.lastIndexOf("."))
} : filename;
// Check if any file has failed status const queryData = JSON.stringify({
return fileEntries.some( query: "",
(file) => file.status === "failed" || file.status === "error", filters: {
); data_sources: [filename],
})(); document_types: ["*"],
owners: ["*"],
connector_types: ["*"],
},
limit: 10,
scoreThreshold: 0,
color: "green",
icon: "file",
});
// If any file failed, show error and jump back one step (like onboarding-card.tsx) // Wait for filter creation to complete before proceeding
// Only handle if we haven't already handled this task createFilterMutation
if ( .mutateAsync({
hasFailedFile && name: displayName,
!isCreatingFilter && description: `Filter for ${filename}`,
!handledFailedTasksRef.current.has(matchingTask.task_id) queryData: queryData,
) { })
console.error("File failed in task, jumping back one step", matchingTask); .then((result) => {
if (result.filter?.id && typeof window !== "undefined") {
localStorage.setItem(
ONBOARDING_USER_DOC_FILTER_ID_KEY,
result.filter.id,
);
console.log(
"Created knowledge filter for uploaded document",
result.filter.id,
);
}
})
.catch((error) => {
console.error("Failed to create knowledge filter:", error);
})
.finally(() => {
setIsCreatingFilter(false);
// Refetch nudges to get new ones
refetchNudges();
// Mark this task as handled to prevent infinite loops // Wait a bit before completing (after filter is created)
handledFailedTasksRef.current.add(matchingTask.task_id); setTimeout(() => {
onComplete();
}, 1000);
});
} else {
// No filter to create, just complete
// Refetch nudges to get new ones
refetchNudges();
// Extract error messages from failed files // Wait a bit before completing
const errorMessages: string[] = []; setTimeout(() => {
if (matchingTask.files) { onComplete();
Object.values(matchingTask.files).forEach((file) => { }, 1000);
if ( }
(file.status === "failed" || file.status === "error") && }
file.error }, [
) { tasks,
errorMessages.push(file.error); currentStep,
} onComplete,
}); refetchNudges,
} shouldCreateFilter,
uploadedFilename,
uploadedTaskId,
createFilterMutation,
isCreatingFilter,
]);
// Also check task-level error const resetFileInput = () => {
if (matchingTask.error) { if (fileInputRef.current) {
errorMessages.push(matchingTask.error); fileInputRef.current.value = "";
} }
};
// Use the first error message, or a generic message if no errors found const handleUploadClick = () => {
const errorMessage = fileInputRef.current?.click();
errorMessages.length > 0 };
? errorMessages[0]
: "Document failed to ingest. Please try again with a different file.";
// Set error message and jump back one step const performUpload = async (file: File) => {
setError(errorMessage); setIsUploading(true);
setCurrentStep(STEP_LIST.length); try {
setCurrentStep(0);
const result = await uploadFile(file, true, true); // Pass createFilter=true
console.log("Document upload task started successfully");
// Clear filter creation flags since ingestion failed // Store task ID to track the specific upload task
setShouldCreateFilter(false); if (result.taskId) {
setUploadedFilename(null); setUploadedTaskId(result.taskId);
}
// Jump back one step after 1 second (go back to upload step) // Store filename and createFilter flag in state to create filter after ingestion succeeds
setTimeout(() => { if (result.createFilter && result.filename) {
setCurrentStep(null); setUploadedFilename(result.filename);
}, 1000); setShouldCreateFilter(true);
return; }
}
// Check if the matching task is still active (pending, running, or processing) // Move to processing step - task monitoring will handle completion
const isTaskActive = setTimeout(() => {
matchingTask.status === "pending" || setCurrentStep(1);
matchingTask.status === "running" || }, 1500);
matchingTask.status === "processing"; } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Upload failed";
console.error("Upload failed", errorMessage);
// If task is completed successfully (no failures) and has processed files, complete the onboarding step // Dispatch event that chat context can listen to
if ( // This avoids circular dependency issues
(!isTaskActive || (matchingTask.processed_files ?? 0) > 0) && if (typeof window !== "undefined") {
!hasFailedFile window.dispatchEvent(
) { new CustomEvent("ingestionFailed", {
// Set to final step to show "Done" detail: { source: "onboarding" },
setCurrentStep(STEP_LIST.length); }),
);
}
// Create knowledge filter for uploaded document if requested // Show error toast notification
// Guard against race condition: only create if not already creating toast.error("Document upload failed", {
if (shouldCreateFilter && uploadedFilename && !isCreatingFilter) { description: errorMessage,
// Reset flags immediately (synchronously) to prevent duplicate creation duration: 5000,
setShouldCreateFilter(false); });
const filename = uploadedFilename;
setUploadedFilename(null);
setIsCreatingFilter(true);
// Get display name from filename (remove extension for cleaner name) // Reset on error
const displayName = filename.includes(".") setCurrentStep(null);
? filename.substring(0, filename.lastIndexOf(".")) setUploadedTaskId(null);
: filename; } finally {
setIsUploading(false);
}
};
const queryData = JSON.stringify({ const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
query: "", const selectedFile = event.target.files?.[0];
filters: { if (!selectedFile) {
data_sources: [filename], resetFileInput();
document_types: ["*"], return;
owners: ["*"], }
connector_types: ["*"],
},
limit: 10,
scoreThreshold: 0,
color: "green",
icon: "file",
});
// Wait for filter creation to complete before proceeding try {
createFilterMutation await performUpload(selectedFile);
.mutateAsync({ } catch (error) {
name: displayName, console.error(
description: `Filter for ${filename}`, "Unable to prepare file for upload",
queryData: queryData, (error as Error).message,
}) );
.then((result) => { } finally {
if (result.filter?.id && typeof window !== "undefined") { resetFileInput();
localStorage.setItem( }
ONBOARDING_USER_DOC_FILTER_ID_KEY, };
result.filter.id,
);
console.log(
"Created knowledge filter for uploaded document",
result.filter.id,
);
}
})
.catch((error) => {
console.error("Failed to create knowledge filter:", error);
})
.finally(() => {
setIsCreatingFilter(false);
// Wait a bit before completing (after filter is created)
setTimeout(() => {
onComplete();
}, 1000);
});
} else {
// No filter to create, just complete
// Wait a bit before completing return (
setTimeout(() => { <AnimatePresence mode="wait">
onComplete(); {currentStep === null ? (
}, 1000); <motion.div
} key="user-ingest"
} initial={{ opacity: 1, y: 0 }}
}, [ exit={{ opacity: 0, y: -24 }}
tasks, transition={{ duration: 0.4, ease: "easeInOut" }}
currentStep, >
onComplete, <Button
shouldCreateFilter, size="sm"
uploadedFilename, variant="outline"
uploadedTaskId, onClick={handleUploadClick}
createFilterMutation, disabled={isUploading}
isCreatingFilter, >
]); <div>{isUploading ? "Uploading..." : "Add a document"}</div>
</Button>
const resetFileInput = () => { <input
if (fileInputRef.current) { ref={fileInputRef}
fileInputRef.current.value = ""; type="file"
} onChange={handleFileChange}
}; className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
const handleUploadClick = () => { />
// Clear any previous error when user clicks to upload again </motion.div>
setError(null); ) : (
fileInputRef.current?.click(); <motion.div
}; key="ingest-steps"
initial={{ opacity: 0, y: 24 }}
const performUpload = async (file: File) => { animate={{ opacity: 1, y: 0 }}
setIsUploading(true); transition={{ duration: 0.4, ease: "easeInOut" }}
// Clear any previous error when starting a new upload >
setError(null); <AnimatedProviderSteps
// Clear handled tasks ref to allow retry currentStep={currentStep}
handledFailedTasksRef.current.clear(); setCurrentStep={setCurrentStep}
// Reset task ID to prevent matching old failed tasks isCompleted={false}
setUploadedTaskId(null); steps={STEP_LIST}
// Clear filter creation flags storageKey={ONBOARDING_UPLOAD_STEPS_KEY}
setShouldCreateFilter(false); />
setUploadedFilename(null); </motion.div>
)}
try { </AnimatePresence>
setCurrentStep(0); );
const result = await uploadFile(file, true, true); // Pass createFilter=true
console.log("Document upload task started successfully");
// Store task ID to track the specific upload task
if (result.taskId) {
setUploadedTaskId(result.taskId);
}
// Store filename and createFilter flag in state to create filter after ingestion succeeds
if (result.createFilter && result.filename) {
setUploadedFilename(result.filename);
setShouldCreateFilter(true);
}
// Move to processing step - task monitoring will handle completion
setTimeout(() => {
setCurrentStep(1);
}, 1500);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Upload failed";
console.error("Upload failed", errorMessage);
// Dispatch event that chat context can listen to
// This avoids circular dependency issues
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("ingestionFailed", {
detail: { source: "onboarding" },
}),
);
}
// Show error toast notification
toast.error("Document upload failed", {
description: errorMessage,
duration: 5000,
});
// Reset on error
setCurrentStep(null);
setUploadedTaskId(null);
setError(errorMessage);
setShouldCreateFilter(false);
setUploadedFilename(null);
} finally {
setIsUploading(false);
}
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) {
resetFileInput();
return;
}
try {
await performUpload(selectedFile);
} catch (error) {
console.error(
"Unable to prepare file for upload",
(error as Error).message,
);
} finally {
resetFileInput();
}
};
return (
<AnimatePresence mode="wait">
{currentStep === null ? (
<motion.div
key="user-ingest"
initial={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -24 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<div className="w-full flex flex-col gap-4">
<AnimatePresence mode="wait">
{error && (
<motion.div
key="error"
initial={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
>
<div className="pb-2 flex items-center gap-4">
<X className="w-4 h-4 text-destructive shrink-0" />
<span className="text-sm text-muted-foreground">
{error}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div>
<Button
size="sm"
variant="outline"
onClick={handleUploadClick}
disabled={isUploading}
>
<div>{isUploading ? "Uploading..." : "Add a document"}</div>
</Button>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
</div>
</motion.div>
) : (
<motion.div
key="ingest-steps"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<AnimatedProviderSteps
currentStep={currentStep}
setCurrentStep={setCurrentStep}
isCompleted={false}
steps={STEP_LIST}
storageKey={ONBOARDING_UPLOAD_STEPS_KEY}
hasError={!!error}
/>
</motion.div>
)}
</AnimatePresence>
);
}; };
export default OnboardingUpload; export default OnboardingUpload;

View file

@ -172,12 +172,14 @@ export function ChatRenderer({
// Mark onboarding as complete in context // Mark onboarding as complete in context
setOnboardingComplete(true); setOnboardingComplete(true);
// Clear ALL conversation state so next message starts fresh // Store the user document filter as default for new conversations FIRST
await startNewConversation(); // This must happen before startNewConversation() so the filter is available
// Store the user document filter as default for new conversations and load it
await storeDefaultFilterForNewConversations(true); await storeDefaultFilterForNewConversations(true);
// Clear ALL conversation state so next message starts fresh
// This will pick up the default filter we just set
await startNewConversation();
// Clean up onboarding filter IDs now that we've set the default // Clean up onboarding filter IDs now that we've set the default
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem(ONBOARDING_OPENRAG_DOCS_FILTER_ID_KEY); localStorage.removeItem(ONBOARDING_OPENRAG_DOCS_FILTER_ID_KEY);

View file

@ -262,6 +262,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
const startNewConversation = useCallback(async () => { const startNewConversation = useCallback(async () => {
console.log("[CONVERSATION] Starting new conversation"); console.log("[CONVERSATION] Starting new conversation");
// Check if there's existing conversation data - if so, this is a manual "new conversation" action
// Check state values before clearing them
const hasExistingConversation = conversationData !== null || placeholderConversation !== null;
// Clear current conversation data and reset state // Clear current conversation data and reset state
setCurrentConversationId(null); setCurrentConversationId(null);
setPreviousResponseIds({ chat: null, langflow: null }); setPreviousResponseIds({ chat: null, langflow: null });
@ -295,15 +299,22 @@ export function ChatProvider({ children }: ChatProviderProps) {
setConversationFilterState(null); setConversationFilterState(null);
} }
} else { } else {
console.log("[CONVERSATION] No default filter set"); // No default filter in localStorage
setConversationFilterState(null); if (hasExistingConversation) {
// User is manually starting a new conversation - clear the filter
console.log("[CONVERSATION] Manual new conversation - clearing filter");
setConversationFilterState(null);
} else {
// First time after onboarding - preserve existing filter if set
// This prevents clearing the filter when startNewConversation is called multiple times during onboarding
console.log("[CONVERSATION] No default filter set, preserving existing filter if any");
// Don't clear the filter - it may have been set by storeDefaultFilterForNewConversations
}
} }
} else {
setConversationFilterState(null);
} }
// Create a temporary placeholder conversation to show in sidebar // Create a temporary placeholder conversation to show in sidebar
const placeholderConversation: ConversationData = { const newPlaceholderConversation: ConversationData = {
response_id: "new-conversation-" + Date.now(), response_id: "new-conversation-" + Date.now(),
title: "New conversation", title: "New conversation",
endpoint: endpoint, endpoint: endpoint,
@ -318,10 +329,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
last_activity: new Date().toISOString(), last_activity: new Date().toISOString(),
}; };
setPlaceholderConversation(placeholderConversation); setPlaceholderConversation(newPlaceholderConversation);
// Force immediate refresh to ensure sidebar shows correct state // Force immediate refresh to ensure sidebar shows correct state
refreshConversations(true); refreshConversations(true);
}, [endpoint, refreshConversations]); }, [endpoint, refreshConversations, conversationData, placeholderConversation]);
const addConversationDoc = useCallback((filename: string) => { const addConversationDoc = useCallback((filename: string) => {
setConversationDocs((prev) => [ setConversationDocs((prev) => [