Merge pull request #616 from langflow-ai/fix/watsonx_fixes

This commit is contained in:
Edwin Jose 2025-12-05 17:55:20 -05:00 committed by GitHub
commit ddee4679b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2240 additions and 1825 deletions

View file

@ -4,6 +4,7 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import type { EndpointType } from "@/contexts/chat-context"; import type { EndpointType } from "@/contexts/chat-context";
import { useChat } from "@/contexts/chat-context";
export interface RawConversation { export interface RawConversation {
response_id: string; response_id: string;
@ -50,6 +51,7 @@ export const useGetConversationsQuery = (
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">, options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOnboardingComplete } = useChat();
async function getConversations(context: { signal?: AbortSignal }): Promise<ChatConversation[]> { async function getConversations(context: { signal?: AbortSignal }): Promise<ChatConversation[]> {
try { try {
@ -95,6 +97,11 @@ export const useGetConversationsQuery = (
} }
} }
// Extract enabled from options and combine with onboarding completion check
// Query is only enabled if onboarding is complete AND the caller's enabled condition is met
const callerEnabled = options?.enabled ?? true;
const enabled = isOnboardingComplete && callerEnabled;
const queryResult = useQuery( const queryResult = useQuery(
{ {
queryKey: ["conversations", endpoint, refreshTrigger], queryKey: ["conversations", endpoint, refreshTrigger],
@ -106,6 +113,7 @@ export const useGetConversationsQuery = (
refetchOnMount: false, // Don't refetch on every mount refetchOnMount: false, // Don't refetch on every mount
refetchOnWindowFocus: false, // Don't refetch when window regains focus refetchOnWindowFocus: false, // Don't refetch when window regains focus
...options, ...options,
enabled, // Override enabled after spreading options to ensure onboarding check is applied
}, },
queryClient, queryClient,
); );

View file

@ -3,6 +3,8 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useChat } from "@/contexts/chat-context";
import { useProviderHealthQuery } from "./useProviderHealthQuery";
type Nudge = string; type Nudge = string;
@ -27,6 +29,13 @@ export const useGetNudgesQuery = (
) => { ) => {
const { chatId, filters, limit, scoreThreshold } = params ?? {}; const { chatId, filters, limit, scoreThreshold } = params ?? {};
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOnboardingComplete } = useChat();
// Check if LLM provider is healthy
// If health data is not available yet, assume healthy (optimistic)
// Only disable if health data exists and shows LLM error
const { data: health } = useProviderHealthQuery();
const isLLMHealthy = health === undefined || (health?.status === "healthy" && !health?.llm_error);
function cancel() { function cancel() {
queryClient.removeQueries({ queryClient.removeQueries({
@ -77,6 +86,11 @@ export const useGetNudgesQuery = (
} }
} }
// Extract enabled from options and combine with onboarding completion and LLM health checks
// Query is only enabled if onboarding is complete AND LLM provider is healthy AND the caller's enabled condition is met
const callerEnabled = options?.enabled ?? true;
const enabled = isOnboardingComplete && isLLMHealthy && callerEnabled;
const queryResult = useQuery( const queryResult = useQuery(
{ {
queryKey: ["nudges", chatId, filters, limit, scoreThreshold], queryKey: ["nudges", chatId, filters, limit, scoreThreshold],
@ -91,6 +105,7 @@ export const useGetNudgesQuery = (
return Array.isArray(data) && data.length === 0 ? 5000 : false; return Array.isArray(data) && data.length === 0 ? 5000 : false;
}, },
...options, ...options,
enabled, // Override enabled after spreading options to ensure onboarding check is applied
}, },
queryClient, queryClient,
); );

View file

@ -5,6 +5,7 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useChat } from "@/contexts/chat-context"; import { useChat } from "@/contexts/chat-context";
import { useGetSettingsQuery } from "./useGetSettingsQuery"; import { useGetSettingsQuery } from "./useGetSettingsQuery";
import { useGetTasksQuery } from "./useGetTasksQuery";
export interface ProviderHealthDetails { export interface ProviderHealthDetails {
llm_model: string; llm_model: string;
@ -40,11 +41,20 @@ export const useProviderHealthQuery = (
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Get chat error state from context (ChatProvider wraps the entire app in layout.tsx) // Get chat error state and onboarding completion from context (ChatProvider wraps the entire app in layout.tsx)
const { hasChatError, setChatError } = useChat(); const { hasChatError, setChatError, isOnboardingComplete } = useChat();
const { data: settings = {} } = useGetSettingsQuery(); const { data: settings = {} } = useGetSettingsQuery();
// Check if there are any active ingestion tasks
const { data: tasks = [] } = useGetTasksQuery();
const hasActiveIngestion = tasks.some(
(task) =>
task.status === "pending" ||
task.status === "running" ||
task.status === "processing",
);
async function checkProviderHealth(): Promise<ProviderHealthResponse> { async function checkProviderHealth(): Promise<ProviderHealthResponse> {
try { try {
const url = new URL("/api/provider/health", window.location.origin); const url = new URL("/api/provider/health", window.location.origin);
@ -55,6 +65,7 @@ export const useProviderHealthQuery = (
} }
// Add test_completion query param if specified or if chat error exists // Add test_completion query param if specified or if chat error exists
// Use the same testCompletion value that's in the queryKey
const testCompletion = params?.test_completion ?? hasChatError; const testCompletion = params?.test_completion ?? hasChatError;
if (testCompletion) { if (testCompletion) {
url.searchParams.set("test_completion", "true"); url.searchParams.set("test_completion", "true");
@ -101,7 +112,10 @@ export const useProviderHealthQuery = (
} }
} }
const queryKey = ["provider", "health", params?.test_completion]; // Include hasChatError in queryKey so React Query refetches when it changes
// This ensures the health check runs with test_completion=true when chat errors occur
const testCompletion = params?.test_completion ?? hasChatError;
const queryKey = ["provider", "health", testCompletion, hasChatError];
const failureCountKey = queryKey.join("-"); const failureCountKey = queryKey.join("-");
const queryResult = useQuery( const queryResult = useQuery(
@ -143,7 +157,11 @@ export const useProviderHealthQuery = (
refetchOnWindowFocus: false, // Disabled to reduce unnecessary calls on tab switches refetchOnWindowFocus: false, // Disabled to reduce unnecessary calls on tab switches
refetchOnMount: true, refetchOnMount: true,
staleTime: 30000, // Consider data stale after 30 seconds staleTime: 30000, // Consider data stale after 30 seconds
enabled: !!settings?.edited && options?.enabled !== false, // Only run after onboarding is complete enabled:
!!settings?.edited &&
isOnboardingComplete &&
!hasActiveIngestion && // Disable health checks when ingestion is happening
options?.enabled !== false, // Only run after onboarding is complete
...options, ...options,
}, },
queryClient, queryClient,

View file

@ -10,6 +10,7 @@ import { useTask } from "@/contexts/task-context";
import { useChatStreaming } from "@/hooks/useChatStreaming"; import { useChatStreaming } from "@/hooks/useChatStreaming";
import { FILE_CONFIRMATION, FILES_REGEX } from "@/lib/constants"; import { FILE_CONFIRMATION, FILES_REGEX } from "@/lib/constants";
import { useLoadingStore } from "@/stores/loadingStore"; import { useLoadingStore } from "@/stores/loadingStore";
import { useGetConversationsQuery } from "../api/queries/useGetConversationsQuery";
import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery";
import { AssistantMessage } from "./_components/assistant-message"; import { AssistantMessage } from "./_components/assistant-message";
import { ChatInput, type ChatInputHandle } from "./_components/chat-input"; import { ChatInput, type ChatInputHandle } from "./_components/chat-input";
@ -36,6 +37,7 @@ function ChatPage() {
forkFromResponse, forkFromResponse,
refreshConversations, refreshConversations,
refreshConversationsSilent, refreshConversationsSilent,
refreshTrigger,
previousResponseIds, previousResponseIds,
setPreviousResponseIds, setPreviousResponseIds,
placeholderConversation, placeholderConversation,
@ -71,6 +73,12 @@ function ChatPage() {
const lastLoadedConversationRef = useRef<string | null>(null); const lastLoadedConversationRef = useRef<string | null>(null);
const { addTask } = useTask(); const { addTask } = useTask();
// Check if chat history is loading
const { isLoading: isConversationsLoading } = useGetConversationsQuery(
endpoint,
refreshTrigger,
);
// Use conversation-specific filter instead of global filter // Use conversation-specific filter instead of global filter
const selectedFilter = conversationFilter; const selectedFilter = conversationFilter;
@ -116,7 +124,12 @@ function ChatPage() {
if (conversationFilter && typeof window !== "undefined") { if (conversationFilter && typeof window !== "undefined") {
const newKey = `conversation_filter_${responseId}`; const newKey = `conversation_filter_${responseId}`;
localStorage.setItem(newKey, conversationFilter.id); localStorage.setItem(newKey, conversationFilter.id);
console.log("[CHAT] Saved filter association:", newKey, "=", conversationFilter.id); console.log(
"[CHAT] Saved filter association:",
newKey,
"=",
conversationFilter.id,
);
} }
} }
}, },
@ -507,6 +520,9 @@ function ChatPage() {
setTimeout(() => { setTimeout(() => {
chatInputRef.current?.focusInput(); chatInputRef.current?.focusInput();
}, 100); }, 100);
} else if (!conversationData) {
// No conversation selected (new conversation)
lastLoadedConversationRef.current = null;
} }
}, [ }, [
conversationData, conversationData,
@ -677,7 +693,7 @@ function ChatPage() {
scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, scoreThreshold: parsedFilterData?.scoreThreshold ?? 0,
}, },
{ {
enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete enabled: isOnboardingComplete && !isConversationsLoading, // Only fetch nudges after onboarding is complete AND chat history is not loading
}, },
); );
@ -841,7 +857,12 @@ function ChatPage() {
// Store the response ID if present for this endpoint // Store the response ID if present for this endpoint
if (result.response_id) { if (result.response_id) {
console.log("[DEBUG] Received response_id:", result.response_id, "currentConversationId:", currentConversationId); console.log(
"[DEBUG] Received response_id:",
result.response_id,
"currentConversationId:",
currentConversationId,
);
setPreviousResponseIds((prev) => ({ setPreviousResponseIds((prev) => ({
...prev, ...prev,
@ -850,11 +871,16 @@ function ChatPage() {
// If this is a new conversation (no currentConversationId), set it now // If this is a new conversation (no currentConversationId), set it now
if (!currentConversationId) { if (!currentConversationId) {
console.log("[DEBUG] Setting currentConversationId to:", result.response_id); console.log(
"[DEBUG] Setting currentConversationId to:",
result.response_id,
);
setCurrentConversationId(result.response_id); setCurrentConversationId(result.response_id);
refreshConversations(true); refreshConversations(true);
} else { } else {
console.log("[DEBUG] Existing conversation, doing silent refresh"); console.log(
"[DEBUG] Existing conversation, doing silent refresh",
);
// For existing conversations, do a silent refresh to keep backend in sync // For existing conversations, do a silent refresh to keep backend in sync
refreshConversationsSilent(); refreshConversationsSilent();
} }
@ -863,7 +889,12 @@ function ChatPage() {
if (conversationFilter && typeof window !== "undefined") { if (conversationFilter && typeof window !== "undefined") {
const newKey = `conversation_filter_${result.response_id}`; const newKey = `conversation_filter_${result.response_id}`;
localStorage.setItem(newKey, conversationFilter.id); localStorage.setItem(newKey, conversationFilter.id);
console.log("[DEBUG] Saved filter association:", newKey, "=", conversationFilter.id); console.log(
"[DEBUG] Saved filter association:",
newKey,
"=",
conversationFilter.id,
);
} }
} }
} else { } else {

View file

@ -1,8 +1,8 @@
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import IBMLogo from "@/components/icons/ibm-logo";
import { LabelInput } from "@/components/label-input"; import { LabelInput } from "@/components/label-input";
import { LabelWrapper } from "@/components/label-wrapper"; import { LabelWrapper } from "@/components/label-wrapper";
import IBMLogo from "@/components/icons/ibm-logo";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
@ -39,14 +39,16 @@ export function IBMOnboarding({
hasEnvApiKey?: boolean; hasEnvApiKey?: boolean;
}) { }) {
const [endpoint, setEndpoint] = useState( const [endpoint, setEndpoint] = useState(
alreadyConfigured ? "" : (existingEndpoint || "https://us-south.ml.cloud.ibm.com"), alreadyConfigured
? ""
: existingEndpoint || "https://us-south.ml.cloud.ibm.com",
); );
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const [getFromEnv, setGetFromEnv] = useState( const [getFromEnv, setGetFromEnv] = useState(
hasEnvApiKey && !alreadyConfigured, hasEnvApiKey && !alreadyConfigured,
); );
const [projectId, setProjectId] = useState( const [projectId, setProjectId] = useState(
alreadyConfigured ? "" : (existingProjectId || ""), alreadyConfigured ? "" : existingProjectId || "",
); );
const options = [ const options = [
@ -93,14 +95,12 @@ export function IBMOnboarding({
} = useGetIBMModelsQuery( } = useGetIBMModelsQuery(
{ {
endpoint: debouncedEndpoint ? debouncedEndpoint : undefined, endpoint: debouncedEndpoint ? debouncedEndpoint : undefined,
apiKey: getFromEnv ? "" : (debouncedApiKey ? debouncedApiKey : undefined), apiKey: getFromEnv ? "" : debouncedApiKey ? debouncedApiKey : undefined,
projectId: debouncedProjectId ? debouncedProjectId : undefined, projectId: debouncedProjectId ? debouncedProjectId : undefined,
}, },
{ {
enabled: enabled:
!!debouncedEndpoint || (!!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId) ||
!!debouncedApiKey ||
!!debouncedProjectId ||
getFromEnv || getFromEnv ||
alreadyConfigured, alreadyConfigured,
}, },

View file

@ -507,7 +507,7 @@ const OnboardingCard = ({
hasEnvApiKey={ hasEnvApiKey={
currentSettings?.providers?.openai?.has_api_key === true currentSettings?.providers?.openai?.has_api_key === true
} }
alreadyConfigured={providerAlreadyConfigured} alreadyConfigured={providerAlreadyConfigured && modelProvider === "openai"}
/> />
</TabsContent> </TabsContent>
<TabsContent value="watsonx"> <TabsContent value="watsonx">
@ -517,7 +517,7 @@ const OnboardingCard = ({
setSampleDataset={setSampleDataset} setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels} setIsLoadingModels={setIsLoadingModels}
isEmbedding={isEmbedding} isEmbedding={isEmbedding}
alreadyConfigured={providerAlreadyConfigured} alreadyConfigured={providerAlreadyConfigured && modelProvider === "watsonx"}
existingEndpoint={currentSettings?.providers?.watsonx?.endpoint} existingEndpoint={currentSettings?.providers?.watsonx?.endpoint}
existingProjectId={currentSettings?.providers?.watsonx?.project_id} existingProjectId={currentSettings?.providers?.watsonx?.project_id}
hasEnvApiKey={currentSettings?.providers?.watsonx?.has_api_key === true} hasEnvApiKey={currentSettings?.providers?.watsonx?.has_api_key === true}
@ -530,7 +530,7 @@ const OnboardingCard = ({
setSampleDataset={setSampleDataset} setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels} setIsLoadingModels={setIsLoadingModels}
isEmbedding={isEmbedding} isEmbedding={isEmbedding}
alreadyConfigured={providerAlreadyConfigured} alreadyConfigured={providerAlreadyConfigured && modelProvider === "ollama"}
existingEndpoint={currentSettings?.providers?.ollama?.endpoint} existingEndpoint={currentSettings?.providers?.ollama?.endpoint}
/> />
</TabsContent> </TabsContent>

View file

@ -1,3 +1,4 @@
import { X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -21,8 +22,13 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
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 [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 handledFailedTasksRef = useRef<Set<string>>(new Set());
const createFilterMutation = useCreateFilter(); const createFilterMutation = useCreateFilter();
@ -39,26 +45,122 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during upload refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during upload
}); });
const { refetch: refetchNudges } = useGetNudgesQuery(null);
// 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) { if (currentStep === null || !tasks || !uploadedTaskId) {
return; return;
} }
// Check if there are any active tasks (pending, running, or processing) // Find the task by task ID from the upload response
const activeTasks = tasks.find( const matchingTask = tasks.find((task) => task.task_id === uploadedTaskId);
(task) =>
task.status === "pending" ||
task.status === "running" ||
task.status === "processing",
);
// If no active tasks and we have more than 1 task (initial + new upload), complete it // If no matching task found, wait for it to appear
if (!matchingTask) {
return;
}
// Skip if this task was already handled as a failed task (from a previous failed upload)
// This prevents processing old failed tasks when a new upload starts
if (handledFailedTasksRef.current.has(matchingTask.task_id)) {
// Check if it's a failed task that we've already handled
const hasFailedFile =
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
const hasFailedFile = (() => {
// Must have files object
if (!matchingTask.files || typeof matchingTask.files !== "object") {
return false;
}
const fileEntries = Object.values(matchingTask.files);
// Must have at least one file
if (fileEntries.length === 0) {
return false;
}
// Check if any file has failed status
return fileEntries.some(
(file) => file.status === "failed" || file.status === "error",
);
})();
// If any file failed, show error and jump back one step (like onboarding-card.tsx)
// Only handle if we haven't already handled this task
if ( if (
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) && hasFailedFile &&
tasks.length > 1 !isCreatingFilter &&
!handledFailedTasksRef.current.has(matchingTask.task_id)
) {
console.error("File failed in task, jumping back one step", matchingTask);
// Mark this task as handled to prevent infinite loops
handledFailedTasksRef.current.add(matchingTask.task_id);
// Extract error messages from failed files
const errorMessages: string[] = [];
if (matchingTask.files) {
Object.values(matchingTask.files).forEach((file) => {
if (
(file.status === "failed" || file.status === "error") &&
file.error
) {
errorMessages.push(file.error);
}
});
}
// Also check task-level error
if (matchingTask.error) {
errorMessages.push(matchingTask.error);
}
// Use the first error message, or a generic message if no errors found
const errorMessage =
errorMessages.length > 0
? errorMessages[0]
: "Document failed to ingest. Please try again with a different file.";
// Set error message and jump back one step
setError(errorMessage);
setCurrentStep(STEP_LIST.length);
// Clear filter creation flags since ingestion failed
setShouldCreateFilter(false);
setUploadedFilename(null);
// Jump back one step after 1 second (go back to upload step)
setTimeout(() => {
setCurrentStep(null);
}, 1000);
return;
}
// Check if the matching task is still active (pending, running, or processing)
const isTaskActive =
matchingTask.status === "pending" ||
matchingTask.status === "running" ||
matchingTask.status === "processing";
// If task is completed successfully (no failures) and has processed files, complete the onboarding step
if (
(!isTaskActive || (matchingTask.processed_files ?? 0) > 0) &&
!hasFailedFile
) { ) {
// Set to final step to show "Done" // Set to final step to show "Done"
setCurrentStep(STEP_LIST.length); setCurrentStep(STEP_LIST.length);
@ -91,6 +193,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
icon: "file", icon: "file",
}); });
// Wait for filter creation to complete before proceeding
createFilterMutation createFilterMutation
.mutateAsync({ .mutateAsync({
name: displayName, name: displayName,
@ -114,18 +217,31 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
}) })
.finally(() => { .finally(() => {
setIsCreatingFilter(false); setIsCreatingFilter(false);
});
}
// Refetch nudges to get new ones // Wait a bit before completing (after filter is created)
refetchNudges(); setTimeout(() => {
onComplete();
}, 1000);
});
} else {
// No filter to create, just complete
// Wait a bit before completing // Wait a bit before completing
setTimeout(() => { setTimeout(() => {
onComplete(); onComplete();
}, 1000); }, 1000);
} }
}, [tasks, currentStep, onComplete, refetchNudges, shouldCreateFilter, uploadedFilename]); }
}, [
tasks,
currentStep,
onComplete,
shouldCreateFilter,
uploadedFilename,
uploadedTaskId,
createFilterMutation,
isCreatingFilter,
]);
const resetFileInput = () => { const resetFileInput = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -134,16 +250,33 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
}; };
const handleUploadClick = () => { const handleUploadClick = () => {
// Clear any previous error when user clicks to upload again
setError(null);
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const performUpload = async (file: File) => { const performUpload = async (file: File) => {
setIsUploading(true); setIsUploading(true);
// Clear any previous error when starting a new upload
setError(null);
// Clear handled tasks ref to allow retry
handledFailedTasksRef.current.clear();
// Reset task ID to prevent matching old failed tasks
setUploadedTaskId(null);
// Clear filter creation flags
setShouldCreateFilter(false);
setUploadedFilename(null);
try { try {
setCurrentStep(0); setCurrentStep(0);
const result = await uploadFile(file, true, true); // Pass createFilter=true const result = await uploadFile(file, true, true); // Pass createFilter=true
console.log("Document upload task started successfully"); 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 // Store filename and createFilter flag in state to create filter after ingestion succeeds
if (result.createFilter && result.filename) { if (result.createFilter && result.filename) {
setUploadedFilename(result.filename); setUploadedFilename(result.filename);
@ -155,7 +288,8 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
setCurrentStep(1); setCurrentStep(1);
}, 1500); }, 1500);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Upload failed"; const errorMessage =
error instanceof Error ? error.message : "Upload failed";
console.error("Upload failed", errorMessage); console.error("Upload failed", errorMessage);
// Dispatch event that chat context can listen to // Dispatch event that chat context can listen to
@ -176,6 +310,10 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
// Reset on error // Reset on error
setCurrentStep(null); setCurrentStep(null);
setUploadedTaskId(null);
setError(errorMessage);
setShouldCreateFilter(false);
setUploadedFilename(null);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
@ -209,6 +347,24 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
exit={{ opacity: 0, y: -24 }} exit={{ opacity: 0, y: -24 }}
transition={{ duration: 0.4, ease: "easeInOut" }} 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 <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -217,6 +373,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
> >
<div>{isUploading ? "Uploading..." : "Add a document"}</div> <div>{isUploading ? "Uploading..." : "Add a document"}</div>
</Button> </Button>
</div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -224,6 +381,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
className="hidden" className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/> />
</div>
</motion.div> </motion.div>
) : ( ) : (
<motion.div <motion.div
@ -238,6 +396,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
isCompleted={false} isCompleted={false}
steps={STEP_LIST} steps={STEP_LIST}
storageKey={ONBOARDING_UPLOAD_STEPS_KEY} storageKey={ONBOARDING_UPLOAD_STEPS_KEY}
hasError={!!error}
/> />
</motion.div> </motion.div>
)} )}

View file

@ -47,8 +47,7 @@ export function ChatRenderer({
refreshConversations, refreshConversations,
startNewConversation, startNewConversation,
setConversationFilter, setConversationFilter,
setCurrentConversationId, setOnboardingComplete,
setPreviousResponseIds,
} = useChat(); } = useChat();
// Initialize onboarding state based on local storage and settings // Initialize onboarding state based on local storage and settings
@ -170,6 +169,9 @@ export function ChatRenderer({
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY); localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
} }
// Mark onboarding as complete in context
setOnboardingComplete(true);
// Clear ALL conversation state so next message starts fresh // Clear ALL conversation state so next message starts fresh
await startNewConversation(); await startNewConversation();
@ -202,6 +204,8 @@ export function ChatRenderer({
localStorage.removeItem(ONBOARDING_CARD_STEPS_KEY); localStorage.removeItem(ONBOARDING_CARD_STEPS_KEY);
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY); localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
} }
// Mark onboarding as complete in context
setOnboardingComplete(true);
// Store the OpenRAG docs filter as default for new conversations // Store the OpenRAG docs filter as default for new conversations
storeDefaultFilterForNewConversations(false); storeDefaultFilterForNewConversations(false);
setShowLayout(true); setShowLayout(true);

View file

@ -5,8 +5,8 @@ import { useRouter } from "next/navigation";
import { useProviderHealthQuery } from "@/app/api/queries/useProviderHealthQuery"; import { useProviderHealthQuery } from "@/app/api/queries/useProviderHealthQuery";
import type { ModelProvider } from "@/app/settings/_helpers/model-helpers"; import type { ModelProvider } from "@/app/settings/_helpers/model-helpers";
import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner"; import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner";
import { cn } from "@/lib/utils";
import { useChat } from "@/contexts/chat-context"; import { useChat } from "@/contexts/chat-context";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
interface ProviderHealthBannerProps { interface ProviderHealthBannerProps {
@ -73,8 +73,14 @@ export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
let errorMessage: string; let errorMessage: string;
if (llmError && embeddingError) { if (llmError && embeddingError) {
// Both have errors - show combined message // Both have errors - check if they're the same
errorMessage = health?.message || "Provider validation failed"; if (llmError === embeddingError) {
// Same error for both - show once
errorMessage = llmError;
} else {
// Different errors - show both
errorMessage = `${llmError}; ${embeddingError}`;
}
errorProvider = undefined; // Don't link to a specific provider errorProvider = undefined; // Don't link to a specific provider
} else if (llmError) { } else if (llmError) {
// Only LLM has error // Only LLM has error

View file

@ -10,6 +10,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { ONBOARDING_STEP_KEY } from "@/lib/constants";
export type EndpointType = "chat" | "langflow"; export type EndpointType = "chat" | "langflow";
@ -81,6 +82,8 @@ interface ChatContextType {
setConversationFilter: (filter: KnowledgeFilter | null, responseId?: string | null) => void; setConversationFilter: (filter: KnowledgeFilter | null, responseId?: string | null) => void;
hasChatError: boolean; hasChatError: boolean;
setChatError: (hasError: boolean) => void; setChatError: (hasError: boolean) => void;
isOnboardingComplete: boolean;
setOnboardingComplete: (complete: boolean) => void;
} }
const ChatContext = createContext<ChatContextType | undefined>(undefined); const ChatContext = createContext<ChatContextType | undefined>(undefined);
@ -112,6 +115,37 @@ export function ChatProvider({ children }: ChatProviderProps) {
useState<KnowledgeFilter | null>(null); useState<KnowledgeFilter | null>(null);
const [hasChatError, setChatError] = useState(false); const [hasChatError, setChatError] = useState(false);
// Check if onboarding is complete (onboarding step key should be null)
const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(ONBOARDING_STEP_KEY) === null;
});
// Sync onboarding completion state with localStorage
useEffect(() => {
const checkOnboarding = () => {
if (typeof window !== "undefined") {
setIsOnboardingComplete(
localStorage.getItem(ONBOARDING_STEP_KEY) === null,
);
}
};
// Check on mount
checkOnboarding();
// Listen for storage events (for cross-tab sync)
window.addEventListener("storage", checkOnboarding);
return () => {
window.removeEventListener("storage", checkOnboarding);
};
}, []);
const setOnboardingComplete = useCallback((complete: boolean) => {
setIsOnboardingComplete(complete);
}, []);
// Listen for ingestion failures and set chat error flag // Listen for ingestion failures and set chat error flag
useEffect(() => { useEffect(() => {
const handleIngestionFailed = () => { const handleIngestionFailed = () => {
@ -375,6 +409,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
setConversationFilter, setConversationFilter,
hasChatError, hasChatError,
setChatError, setChatError,
isOnboardingComplete,
setOnboardingComplete,
}), }),
[ [
endpoint, endpoint,
@ -396,6 +432,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
conversationFilter, conversationFilter,
setConversationFilter, setConversationFilter,
hasChatError, hasChatError,
isOnboardingComplete,
setOnboardingComplete,
], ],
); );

View file

@ -12,6 +12,7 @@ export interface UploadFileResult {
raw: unknown; raw: unknown;
createFilter?: boolean; createFilter?: boolean;
filename?: string; filename?: string;
taskId?: string;
} }
export async function duplicateCheck( export async function duplicateCheck(
@ -158,6 +159,7 @@ export async function uploadFile(
(uploadIngestJson as { upload?: { id?: string } }).upload?.id || (uploadIngestJson as { upload?: { id?: string } }).upload?.id ||
(uploadIngestJson as { id?: string }).id || (uploadIngestJson as { id?: string }).id ||
(uploadIngestJson as { task_id?: string }).task_id; (uploadIngestJson as { task_id?: string }).task_id;
const taskId = (uploadIngestJson as { task_id?: string }).task_id;
const filePath = const filePath =
(uploadIngestJson as { upload?: { path?: string } }).upload?.path || (uploadIngestJson as { upload?: { path?: string } }).upload?.path ||
(uploadIngestJson as { path?: string }).path || (uploadIngestJson as { path?: string }).path ||
@ -197,6 +199,7 @@ export async function uploadFile(
raw: uploadIngestJson, raw: uploadIngestJson,
createFilter: shouldCreateFilter, createFilter: shouldCreateFilter,
filename, filename,
taskId,
}; };
return result; return result;

View file

@ -1,5 +1,6 @@
"""Provider validation utilities for testing API keys and models during onboarding.""" """Provider validation utilities for testing API keys and models during onboarding."""
import json
import httpx import httpx
from utils.container_utils import transform_localhost_url from utils.container_utils import transform_localhost_url
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -7,6 +8,106 @@ from utils.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
def _parse_json_error_message(error_text: str) -> str:
"""Parse JSON error message and extract just the message field."""
try:
# Try to parse as JSON
error_data = json.loads(error_text)
if isinstance(error_data, dict):
# WatsonX format: {"errors": [{"code": "...", "message": "..."}], ...}
if "errors" in error_data and isinstance(error_data["errors"], list):
errors = error_data["errors"]
if len(errors) > 0 and isinstance(errors[0], dict):
message = errors[0].get("message", "")
if message:
return message
code = errors[0].get("code", "")
if code:
return f"Error: {code}"
# OpenAI format: {"error": {"message": "...", "type": "...", "code": "..."}}
if "error" in error_data:
error_obj = error_data["error"]
if isinstance(error_obj, dict):
message = error_obj.get("message", "")
if message:
return message
# Direct message field
if "message" in error_data:
return error_data["message"]
# Generic format: {"detail": "..."}
if "detail" in error_data:
return error_data["detail"]
except (json.JSONDecodeError, ValueError, TypeError):
pass
# Return original text if not JSON or can't parse
return error_text
def _extract_error_details(response: httpx.Response) -> str:
"""Extract detailed error message from API response."""
try:
# Try to parse JSON error response
error_data = response.json()
# Common error response formats
if isinstance(error_data, dict):
# WatsonX format: {"errors": [{"code": "...", "message": "..."}], ...}
if "errors" in error_data and isinstance(error_data["errors"], list):
errors = error_data["errors"]
if len(errors) > 0 and isinstance(errors[0], dict):
# Extract just the message from the first error
message = errors[0].get("message", "")
if message:
return message
# Fallback to code if no message
code = errors[0].get("code", "")
if code:
return f"Error: {code}"
# OpenAI format: {"error": {"message": "...", "type": "...", "code": "..."}}
if "error" in error_data:
error_obj = error_data["error"]
if isinstance(error_obj, dict):
message = error_obj.get("message", "")
error_type = error_obj.get("type", "")
code = error_obj.get("code", "")
if message:
details = message
if error_type:
details += f" (type: {error_type})"
if code:
details += f" (code: {code})"
return details
# Anthropic format: {"error": {"message": "...", "type": "..."}}
if "message" in error_data:
return error_data["message"]
# Generic format: {"message": "..."}
if "detail" in error_data:
return error_data["detail"]
# If JSON parsing worked but no structured error found, try parsing text
response_text = response.text[:500]
parsed = _parse_json_error_message(response_text)
if parsed != response_text:
return parsed
return response_text
except (json.JSONDecodeError, ValueError):
# If JSON parsing fails, try parsing the text as JSON string
response_text = response.text[:500] if response.text else f"HTTP {response.status_code}"
parsed = _parse_json_error_message(response_text)
if parsed != response_text:
return parsed
return response_text
async def validate_provider_setup( async def validate_provider_setup(
provider: str, provider: str,
api_key: str = None, api_key: str = None,
@ -30,7 +131,7 @@ async def validate_provider_setup(
If False, performs lightweight validation (no credits consumed). Default: False. If False, performs lightweight validation (no credits consumed). Default: False.
Raises: Raises:
Exception: If validation fails with message "Setup failed, please try again or select a different provider." Exception: If validation fails, raises the original exception with the actual error message.
""" """
provider_lower = provider.lower() provider_lower = provider.lower()
@ -70,7 +171,8 @@ async def validate_provider_setup(
except Exception as e: except Exception as e:
logger.error(f"Validation failed for provider {provider_lower}: {str(e)}") logger.error(f"Validation failed for provider {provider_lower}: {str(e)}")
raise Exception("Setup failed, please try again or select a different provider.") # Preserve the original error message instead of replacing it with a generic one
raise
async def test_lightweight_health( async def test_lightweight_health(
@ -155,8 +257,9 @@ async def _test_openai_lightweight_health(api_key: str) -> None:
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"OpenAI lightweight health check failed: {response.status_code}") error_details = _extract_error_details(response)
raise Exception(f"OpenAI API key validation failed: {response.status_code}") logger.error(f"OpenAI lightweight health check failed: {response.status_code} - {error_details}")
raise Exception(f"OpenAI API key validation failed: {error_details}")
logger.info("OpenAI lightweight health check passed") logger.info("OpenAI lightweight health check passed")
@ -225,8 +328,9 @@ async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> No
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"OpenAI completion test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"OpenAI API error: {response.status_code}") logger.error(f"OpenAI completion test failed: {response.status_code} - {error_details}")
raise Exception(f"OpenAI API error: {error_details}")
logger.info("OpenAI completion with tool calling test passed") logger.info("OpenAI completion with tool calling test passed")
@ -260,8 +364,9 @@ async def _test_openai_embedding(api_key: str, embedding_model: str) -> None:
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"OpenAI embedding test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"OpenAI API error: {response.status_code}") logger.error(f"OpenAI embedding test failed: {response.status_code} - {error_details}")
raise Exception(f"OpenAI API error: {error_details}")
data = response.json() data = response.json()
if not data.get("data") or len(data["data"]) == 0: if not data.get("data") or len(data["data"]) == 0:
@ -300,8 +405,9 @@ async def _test_watsonx_lightweight_health(
) )
if token_response.status_code != 200: if token_response.status_code != 200:
logger.error(f"IBM IAM token request failed: {token_response.status_code}") error_details = _extract_error_details(token_response)
raise Exception("Failed to authenticate with IBM Watson - invalid API key") logger.error(f"IBM IAM token request failed: {token_response.status_code} - {error_details}")
raise Exception(f"Failed to authenticate with IBM Watson: {error_details}")
bearer_token = token_response.json().get("access_token") bearer_token = token_response.json().get("access_token")
if not bearer_token: if not bearer_token:
@ -335,8 +441,9 @@ async def _test_watsonx_completion_with_tools(
) )
if token_response.status_code != 200: if token_response.status_code != 200:
logger.error(f"IBM IAM token request failed: {token_response.status_code}") error_details = _extract_error_details(token_response)
raise Exception("Failed to authenticate with IBM Watson") logger.error(f"IBM IAM token request failed: {token_response.status_code} - {error_details}")
raise Exception(f"Failed to authenticate with IBM Watson: {error_details}")
bearer_token = token_response.json().get("access_token") bearer_token = token_response.json().get("access_token")
if not bearer_token: if not bearer_token:
@ -388,8 +495,11 @@ async def _test_watsonx_completion_with_tools(
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"IBM Watson completion test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"IBM Watson API error: {response.status_code}") logger.error(f"IBM Watson completion test failed: {response.status_code} - {error_details}")
# If error_details is still JSON, parse it to extract just the message
parsed_details = _parse_json_error_message(error_details)
raise Exception(f"IBM Watson API error: {parsed_details}")
logger.info("IBM Watson completion with tool calling test passed") logger.info("IBM Watson completion with tool calling test passed")
@ -398,6 +508,13 @@ async def _test_watsonx_completion_with_tools(
raise Exception("Request timed out") raise Exception("Request timed out")
except Exception as e: except Exception as e:
logger.error(f"IBM Watson completion test failed: {str(e)}") logger.error(f"IBM Watson completion test failed: {str(e)}")
# If the error message contains JSON, parse it to extract just the message
error_str = str(e)
if "IBM Watson API error: " in error_str:
json_part = error_str.split("IBM Watson API error: ", 1)[1]
parsed_message = _parse_json_error_message(json_part)
if parsed_message != json_part:
raise Exception(f"IBM Watson API error: {parsed_message}")
raise raise
@ -419,8 +536,9 @@ async def _test_watsonx_embedding(
) )
if token_response.status_code != 200: if token_response.status_code != 200:
logger.error(f"IBM IAM token request failed: {token_response.status_code}") error_details = _extract_error_details(token_response)
raise Exception("Failed to authenticate with IBM Watson") logger.error(f"IBM IAM token request failed: {token_response.status_code} - {error_details}")
raise Exception(f"Failed to authenticate with IBM Watson: {error_details}")
bearer_token = token_response.json().get("access_token") bearer_token = token_response.json().get("access_token")
if not bearer_token: if not bearer_token:
@ -450,8 +568,11 @@ async def _test_watsonx_embedding(
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"IBM Watson embedding test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"IBM Watson API error: {response.status_code}") logger.error(f"IBM Watson embedding test failed: {response.status_code} - {error_details}")
# If error_details is still JSON, parse it to extract just the message
parsed_details = _parse_json_error_message(error_details)
raise Exception(f"IBM Watson API error: {parsed_details}")
data = response.json() data = response.json()
if not data.get("results") or len(data["results"]) == 0: if not data.get("results") or len(data["results"]) == 0:
@ -464,6 +585,13 @@ async def _test_watsonx_embedding(
raise Exception("Request timed out") raise Exception("Request timed out")
except Exception as e: except Exception as e:
logger.error(f"IBM Watson embedding test failed: {str(e)}") logger.error(f"IBM Watson embedding test failed: {str(e)}")
# If the error message contains JSON, parse it to extract just the message
error_str = str(e)
if "IBM Watson API error: " in error_str:
json_part = error_str.split("IBM Watson API error: ", 1)[1]
parsed_message = _parse_json_error_message(json_part)
if parsed_message != json_part:
raise Exception(f"IBM Watson API error: {parsed_message}")
raise raise
@ -483,8 +611,9 @@ async def _test_ollama_lightweight_health(endpoint: str) -> None:
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Ollama lightweight health check failed: {response.status_code}") error_details = _extract_error_details(response)
raise Exception(f"Ollama endpoint not responding: {response.status_code}") logger.error(f"Ollama lightweight health check failed: {response.status_code} - {error_details}")
raise Exception(f"Ollama endpoint not responding: {error_details}")
logger.info("Ollama lightweight health check passed") logger.info("Ollama lightweight health check passed")
@ -537,8 +666,9 @@ async def _test_ollama_completion_with_tools(llm_model: str, endpoint: str) -> N
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Ollama completion test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"Ollama API error: {response.status_code}") logger.error(f"Ollama completion test failed: {response.status_code} - {error_details}")
raise Exception(f"Ollama API error: {error_details}")
logger.info("Ollama completion with tool calling test passed") logger.info("Ollama completion with tool calling test passed")
@ -569,8 +699,9 @@ async def _test_ollama_embedding(embedding_model: str, endpoint: str) -> None:
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Ollama embedding test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"Ollama API error: {response.status_code}") logger.error(f"Ollama embedding test failed: {response.status_code} - {error_details}")
raise Exception(f"Ollama API error: {error_details}")
data = response.json() data = response.json()
if not data.get("embedding"): if not data.get("embedding"):
@ -616,8 +747,9 @@ async def _test_anthropic_lightweight_health(api_key: str) -> None:
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Anthropic lightweight health check failed: {response.status_code}") error_details = _extract_error_details(response)
raise Exception(f"Anthropic API key validation failed: {response.status_code}") logger.error(f"Anthropic lightweight health check failed: {response.status_code} - {error_details}")
raise Exception(f"Anthropic API key validation failed: {error_details}")
logger.info("Anthropic lightweight health check passed") logger.info("Anthropic lightweight health check passed")
@ -672,8 +804,9 @@ async def _test_anthropic_completion_with_tools(api_key: str, llm_model: str) ->
) )
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Anthropic completion test failed: {response.status_code} - {response.text}") error_details = _extract_error_details(response)
raise Exception(f"Anthropic API error: {response.status_code}") logger.error(f"Anthropic completion test failed: {response.status_code} - {error_details}")
raise Exception(f"Anthropic API error: {error_details}")
logger.info("Anthropic completion with tool calling test passed") logger.info("Anthropic completion with tool calling test passed")