Merge pull request #616 from langflow-ai/fix/watsonx_fixes
This commit is contained in:
commit
ddee4679b9
12 changed files with 2240 additions and 1825 deletions
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
||||||
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,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/lib/debounce";
|
import { useDebouncedValue } from "@/lib/debounce";
|
||||||
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
|
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
|
||||||
|
|
@ -18,273 +18,273 @@ import { AdvancedOnboarding } from "./advanced";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
export function IBMOnboarding({
|
export function IBMOnboarding({
|
||||||
isEmbedding = false,
|
isEmbedding = false,
|
||||||
setSettings,
|
setSettings,
|
||||||
sampleDataset,
|
sampleDataset,
|
||||||
setSampleDataset,
|
setSampleDataset,
|
||||||
setIsLoadingModels,
|
setIsLoadingModels,
|
||||||
alreadyConfigured = false,
|
alreadyConfigured = false,
|
||||||
existingEndpoint,
|
existingEndpoint,
|
||||||
existingProjectId,
|
existingProjectId,
|
||||||
hasEnvApiKey = false,
|
hasEnvApiKey = false,
|
||||||
}: {
|
}: {
|
||||||
isEmbedding?: boolean;
|
isEmbedding?: boolean;
|
||||||
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
|
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
|
||||||
sampleDataset: boolean;
|
sampleDataset: boolean;
|
||||||
setSampleDataset: (dataset: boolean) => void;
|
setSampleDataset: (dataset: boolean) => void;
|
||||||
setIsLoadingModels?: (isLoading: boolean) => void;
|
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||||
alreadyConfigured?: boolean;
|
alreadyConfigured?: boolean;
|
||||||
existingEndpoint?: string;
|
existingEndpoint?: string;
|
||||||
existingProjectId?: string;
|
existingProjectId?: string;
|
||||||
hasEnvApiKey?: boolean;
|
hasEnvApiKey?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [endpoint, setEndpoint] = useState(
|
const [endpoint, setEndpoint] = useState(
|
||||||
alreadyConfigured ? "" : (existingEndpoint || "https://us-south.ml.cloud.ibm.com"),
|
alreadyConfigured
|
||||||
);
|
? ""
|
||||||
const [apiKey, setApiKey] = useState("");
|
: existingEndpoint || "https://us-south.ml.cloud.ibm.com",
|
||||||
const [getFromEnv, setGetFromEnv] = useState(
|
);
|
||||||
hasEnvApiKey && !alreadyConfigured,
|
const [apiKey, setApiKey] = useState("");
|
||||||
);
|
const [getFromEnv, setGetFromEnv] = useState(
|
||||||
const [projectId, setProjectId] = useState(
|
hasEnvApiKey && !alreadyConfigured,
|
||||||
alreadyConfigured ? "" : (existingProjectId || ""),
|
);
|
||||||
);
|
const [projectId, setProjectId] = useState(
|
||||||
|
alreadyConfigured ? "" : existingProjectId || "",
|
||||||
|
);
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
value: "https://us-south.ml.cloud.ibm.com",
|
value: "https://us-south.ml.cloud.ibm.com",
|
||||||
label: "https://us-south.ml.cloud.ibm.com",
|
label: "https://us-south.ml.cloud.ibm.com",
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://eu-de.ml.cloud.ibm.com",
|
value: "https://eu-de.ml.cloud.ibm.com",
|
||||||
label: "https://eu-de.ml.cloud.ibm.com",
|
label: "https://eu-de.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://eu-gb.ml.cloud.ibm.com",
|
value: "https://eu-gb.ml.cloud.ibm.com",
|
||||||
label: "https://eu-gb.ml.cloud.ibm.com",
|
label: "https://eu-gb.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://au-syd.ml.cloud.ibm.com",
|
value: "https://au-syd.ml.cloud.ibm.com",
|
||||||
label: "https://au-syd.ml.cloud.ibm.com",
|
label: "https://au-syd.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://jp-tok.ml.cloud.ibm.com",
|
value: "https://jp-tok.ml.cloud.ibm.com",
|
||||||
label: "https://jp-tok.ml.cloud.ibm.com",
|
label: "https://jp-tok.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://ca-tor.ml.cloud.ibm.com",
|
value: "https://ca-tor.ml.cloud.ibm.com",
|
||||||
label: "https://ca-tor.ml.cloud.ibm.com",
|
label: "https://ca-tor.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
|
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
|
||||||
const debouncedApiKey = useDebouncedValue(apiKey, 500);
|
const debouncedApiKey = useDebouncedValue(apiKey, 500);
|
||||||
const debouncedProjectId = useDebouncedValue(projectId, 500);
|
const debouncedProjectId = useDebouncedValue(projectId, 500);
|
||||||
|
|
||||||
// Fetch models from API when all credentials are provided
|
// Fetch models from API when all credentials are provided
|
||||||
const {
|
const {
|
||||||
data: modelsData,
|
data: modelsData,
|
||||||
isLoading: isLoadingModels,
|
isLoading: isLoadingModels,
|
||||||
error: modelsError,
|
error: modelsError,
|
||||||
} = 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 ||
|
getFromEnv ||
|
||||||
!!debouncedProjectId ||
|
alreadyConfigured,
|
||||||
getFromEnv ||
|
},
|
||||||
alreadyConfigured,
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hook for model selection logic
|
// Use custom hook for model selection logic
|
||||||
const {
|
const {
|
||||||
languageModel,
|
languageModel,
|
||||||
embeddingModel,
|
embeddingModel,
|
||||||
setLanguageModel,
|
setLanguageModel,
|
||||||
setEmbeddingModel,
|
setEmbeddingModel,
|
||||||
languageModels,
|
languageModels,
|
||||||
embeddingModels,
|
embeddingModels,
|
||||||
} = useModelSelection(modelsData, isEmbedding);
|
} = useModelSelection(modelsData, isEmbedding);
|
||||||
|
|
||||||
const handleGetFromEnvChange = (fromEnv: boolean) => {
|
const handleGetFromEnvChange = (fromEnv: boolean) => {
|
||||||
setGetFromEnv(fromEnv);
|
setGetFromEnv(fromEnv);
|
||||||
if (fromEnv) {
|
if (fromEnv) {
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
}
|
}
|
||||||
setEmbeddingModel?.("");
|
setEmbeddingModel?.("");
|
||||||
setLanguageModel?.("");
|
setLanguageModel?.("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSampleDatasetChange = (dataset: boolean) => {
|
const handleSampleDatasetChange = (dataset: boolean) => {
|
||||||
setSampleDataset(dataset);
|
setSampleDataset(dataset);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoadingModels?.(isLoadingModels);
|
setIsLoadingModels?.(isLoadingModels);
|
||||||
}, [isLoadingModels, setIsLoadingModels]);
|
}, [isLoadingModels, setIsLoadingModels]);
|
||||||
|
|
||||||
// Update settings when values change
|
// Update settings when values change
|
||||||
useUpdateSettings(
|
useUpdateSettings(
|
||||||
"watsonx",
|
"watsonx",
|
||||||
{
|
{
|
||||||
endpoint,
|
endpoint,
|
||||||
apiKey,
|
apiKey,
|
||||||
projectId,
|
projectId,
|
||||||
languageModel,
|
languageModel,
|
||||||
embeddingModel,
|
embeddingModel,
|
||||||
},
|
},
|
||||||
setSettings,
|
setSettings,
|
||||||
isEmbedding,
|
isEmbedding,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
label="watsonx.ai API Endpoint"
|
label="watsonx.ai API Endpoint"
|
||||||
helperText="Base URL of the API"
|
helperText="Base URL of the API"
|
||||||
id="api-endpoint"
|
id="api-endpoint"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
options={alreadyConfigured ? [] : options}
|
options={alreadyConfigured ? [] : options}
|
||||||
value={endpoint}
|
value={endpoint}
|
||||||
custom
|
custom
|
||||||
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
|
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
|
||||||
searchPlaceholder="Search endpoint..."
|
searchPlaceholder="Search endpoint..."
|
||||||
noOptionsPlaceholder={
|
noOptionsPlaceholder={
|
||||||
alreadyConfigured
|
alreadyConfigured
|
||||||
? "https://•••••••••••••••••••••••••••••••••••••••••"
|
? "https://•••••••••••••••••••••••••••••••••••••••••"
|
||||||
: "No endpoints available"
|
: "No endpoints available"
|
||||||
}
|
}
|
||||||
placeholder="Select endpoint..."
|
placeholder="Select endpoint..."
|
||||||
/>
|
/>
|
||||||
{alreadyConfigured && (
|
{alreadyConfigured && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Reusing endpoint from model provider selection.
|
Reusing endpoint from model provider selection.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
label="watsonx Project ID"
|
label="watsonx Project ID"
|
||||||
helperText="Project ID for the model"
|
helperText="Project ID for the model"
|
||||||
id="project-id"
|
id="project-id"
|
||||||
required
|
required
|
||||||
placeholder={
|
placeholder={
|
||||||
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
|
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
|
||||||
}
|
}
|
||||||
value={projectId}
|
value={projectId}
|
||||||
onChange={(e) => setProjectId(e.target.value)}
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
disabled={alreadyConfigured}
|
disabled={alreadyConfigured}
|
||||||
/>
|
/>
|
||||||
{alreadyConfigured && (
|
{alreadyConfigured && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Reusing project ID from model provider selection.
|
Reusing project ID from model provider selection.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
label="Use environment watsonx API key"
|
label="Use environment watsonx API key"
|
||||||
id="get-api-key"
|
id="get-api-key"
|
||||||
description="Reuse the key from your environment config. Turn off to enter a different key."
|
description="Reuse the key from your environment config. Turn off to enter a different key."
|
||||||
flex
|
flex
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={getFromEnv}
|
checked={getFromEnv}
|
||||||
onCheckedChange={handleGetFromEnvChange}
|
onCheckedChange={handleGetFromEnvChange}
|
||||||
disabled={!hasEnvApiKey || alreadyConfigured}
|
disabled={!hasEnvApiKey || alreadyConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{!hasEnvApiKey && !alreadyConfigured && (
|
{!hasEnvApiKey && !alreadyConfigured && (
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
watsonx API key not detected in the environment.
|
watsonx API key not detected in the environment.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
{!getFromEnv && !alreadyConfigured && (
|
{!getFromEnv && !alreadyConfigured && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
label="watsonx API key"
|
label="watsonx API key"
|
||||||
helperText="API key to access watsonx.ai"
|
helperText="API key to access watsonx.ai"
|
||||||
className={modelsError ? "!border-destructive" : ""}
|
className={modelsError ? "!border-destructive" : ""}
|
||||||
id="api-key"
|
id="api-key"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
placeholder="your-api-key"
|
placeholder="your-api-key"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{isLoadingModels && (
|
{isLoadingModels && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Validating API key...
|
Validating API key...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{modelsError && (
|
{modelsError && (
|
||||||
<p className="text-mmd text-destructive">
|
<p className="text-mmd text-destructive">
|
||||||
Invalid watsonx API key. Verify or replace the key.
|
Invalid watsonx API key. Verify or replace the key.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{alreadyConfigured && (
|
{alreadyConfigured && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
label="watsonx API key"
|
label="watsonx API key"
|
||||||
helperText="API key to access watsonx.ai"
|
helperText="API key to access watsonx.ai"
|
||||||
id="api-key"
|
id="api-key"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
placeholder="•••••••••••••••••••••••••••••••••••••••••"
|
placeholder="•••••••••••••••••••••••••••••••••••••••••"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
/>
|
/>
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Reusing API key from model provider selection.
|
Reusing API key from model provider selection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{getFromEnv && isLoadingModels && (
|
{getFromEnv && isLoadingModels && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Validating configuration...
|
Validating configuration...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{getFromEnv && modelsError && (
|
{getFromEnv && modelsError && (
|
||||||
<p className="text-mmd text-accent-amber-foreground">
|
<p className="text-mmd text-accent-amber-foreground">
|
||||||
Connection failed. Check your configuration.
|
Connection failed. Check your configuration.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AdvancedOnboarding
|
<AdvancedOnboarding
|
||||||
icon={<IBMLogo className="w-4 h-4" />}
|
icon={<IBMLogo className="w-4 h-4" />}
|
||||||
languageModels={languageModels}
|
languageModels={languageModels}
|
||||||
embeddingModels={embeddingModels}
|
embeddingModels={embeddingModels}
|
||||||
languageModel={languageModel}
|
languageModel={languageModel}
|
||||||
embeddingModel={embeddingModel}
|
embeddingModel={embeddingModel}
|
||||||
sampleDataset={sampleDataset}
|
sampleDataset={sampleDataset}
|
||||||
setLanguageModel={setLanguageModel}
|
setLanguageModel={setLanguageModel}
|
||||||
setEmbeddingModel={setEmbeddingModel}
|
setEmbeddingModel={setEmbeddingModel}
|
||||||
setSampleDataset={handleSampleDatasetChange}
|
setSampleDataset={handleSampleDatasetChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -7,242 +8,400 @@ import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
|
||||||
import { AnimatedProviderSteps } from "@/app/onboarding/_components/animated-provider-steps";
|
import { AnimatedProviderSteps } from "@/app/onboarding/_components/animated-provider-steps";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ONBOARDING_UPLOAD_STEPS_KEY,
|
ONBOARDING_UPLOAD_STEPS_KEY,
|
||||||
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { uploadFile } from "@/lib/upload-utils";
|
import { uploadFile } from "@/lib/upload-utils";
|
||||||
|
|
||||||
interface OnboardingUploadProps {
|
interface OnboardingUploadProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [shouldCreateFilter, setShouldCreateFilter] = useState(false);
|
const [uploadedTaskId, setUploadedTaskId] = useState<string | null>(null);
|
||||||
const [isCreatingFilter, setIsCreatingFilter] = useState(false);
|
const [shouldCreateFilter, setShouldCreateFilter] = useState(false);
|
||||||
|
const [isCreatingFilter, setIsCreatingFilter] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const createFilterMutation = useCreateFilter();
|
// Track which tasks we've already handled to prevent infinite loops
|
||||||
|
const handledFailedTasksRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const STEP_LIST = [
|
const createFilterMutation = useCreateFilter();
|
||||||
"Uploading your document",
|
|
||||||
"Generating embeddings",
|
|
||||||
"Ingesting document",
|
|
||||||
"Processing your document",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Query tasks to track completion
|
const STEP_LIST = [
|
||||||
const { data: tasks } = useGetTasksQuery({
|
"Uploading your document",
|
||||||
enabled: currentStep !== null, // Only poll when upload has started
|
"Generating embeddings",
|
||||||
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during upload
|
"Ingesting document",
|
||||||
});
|
"Processing your document",
|
||||||
|
];
|
||||||
|
|
||||||
const { refetch: refetchNudges } = useGetNudgesQuery(null);
|
// Query tasks to track completion
|
||||||
|
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) {
|
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 (
|
if (!matchingTask) {
|
||||||
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
return;
|
||||||
tasks.length > 1
|
}
|
||||||
) {
|
|
||||||
// Set to final step to show "Done"
|
|
||||||
setCurrentStep(STEP_LIST.length);
|
|
||||||
|
|
||||||
// Create knowledge filter for uploaded document if requested
|
// Skip if this task was already handled as a failed task (from a previous failed upload)
|
||||||
// Guard against race condition: only create if not already creating
|
// This prevents processing old failed tasks when a new upload starts
|
||||||
if (shouldCreateFilter && uploadedFilename && !isCreatingFilter) {
|
if (handledFailedTasksRef.current.has(matchingTask.task_id)) {
|
||||||
// Reset flags immediately (synchronously) to prevent duplicate creation
|
// Check if it's a failed task that we've already handled
|
||||||
setShouldCreateFilter(false);
|
const hasFailedFile =
|
||||||
const filename = uploadedFilename;
|
matchingTask.files &&
|
||||||
setUploadedFilename(null);
|
Object.values(matchingTask.files).some(
|
||||||
setIsCreatingFilter(true);
|
(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);
|
||||||
|
}
|
||||||
|
|
||||||
// Get display name from filename (remove extension for cleaner name)
|
// Check if any file failed in the matching task
|
||||||
const displayName = filename.includes(".")
|
const hasFailedFile = (() => {
|
||||||
? filename.substring(0, filename.lastIndexOf("."))
|
// Must have files object
|
||||||
: filename;
|
if (!matchingTask.files || typeof matchingTask.files !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const queryData = JSON.stringify({
|
const fileEntries = Object.values(matchingTask.files);
|
||||||
query: "",
|
|
||||||
filters: {
|
|
||||||
data_sources: [filename],
|
|
||||||
document_types: ["*"],
|
|
||||||
owners: ["*"],
|
|
||||||
connector_types: ["*"],
|
|
||||||
},
|
|
||||||
limit: 10,
|
|
||||||
scoreThreshold: 0,
|
|
||||||
color: "green",
|
|
||||||
icon: "file",
|
|
||||||
});
|
|
||||||
|
|
||||||
createFilterMutation
|
// Must have at least one file
|
||||||
.mutateAsync({
|
if (fileEntries.length === 0) {
|
||||||
name: displayName,
|
return false;
|
||||||
description: `Filter for ${filename}`,
|
}
|
||||||
queryData: queryData,
|
|
||||||
})
|
|
||||||
.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
|
// Check if any file has failed status
|
||||||
refetchNudges();
|
return fileEntries.some(
|
||||||
|
(file) => file.status === "failed" || file.status === "error",
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
// Wait a bit before completing
|
// If any file failed, show error and jump back one step (like onboarding-card.tsx)
|
||||||
setTimeout(() => {
|
// Only handle if we haven't already handled this task
|
||||||
onComplete();
|
if (
|
||||||
}, 1000);
|
hasFailedFile &&
|
||||||
}
|
!isCreatingFilter &&
|
||||||
}, [tasks, currentStep, onComplete, refetchNudges, shouldCreateFilter, uploadedFilename]);
|
!handledFailedTasksRef.current.has(matchingTask.task_id)
|
||||||
|
) {
|
||||||
|
console.error("File failed in task, jumping back one step", matchingTask);
|
||||||
|
|
||||||
const resetFileInput = () => {
|
// Mark this task as handled to prevent infinite loops
|
||||||
if (fileInputRef.current) {
|
handledFailedTasksRef.current.add(matchingTask.task_id);
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
// Extract error messages from failed files
|
||||||
fileInputRef.current?.click();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const performUpload = async (file: File) => {
|
// Also check task-level error
|
||||||
setIsUploading(true);
|
if (matchingTask.error) {
|
||||||
try {
|
errorMessages.push(matchingTask.error);
|
||||||
setCurrentStep(0);
|
}
|
||||||
const result = await uploadFile(file, true, true); // Pass createFilter=true
|
|
||||||
console.log("Document upload task started successfully");
|
|
||||||
|
|
||||||
// Store filename and createFilter flag in state to create filter after ingestion succeeds
|
// Use the first error message, or a generic message if no errors found
|
||||||
if (result.createFilter && result.filename) {
|
const errorMessage =
|
||||||
setUploadedFilename(result.filename);
|
errorMessages.length > 0
|
||||||
setShouldCreateFilter(true);
|
? errorMessages[0]
|
||||||
}
|
: "Document failed to ingest. Please try again with a different file.";
|
||||||
|
|
||||||
// Move to processing step - task monitoring will handle completion
|
// Set error message and jump back one step
|
||||||
setTimeout(() => {
|
setError(errorMessage);
|
||||||
setCurrentStep(1);
|
setCurrentStep(STEP_LIST.length);
|
||||||
}, 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
|
// Clear filter creation flags since ingestion failed
|
||||||
// This avoids circular dependency issues
|
setShouldCreateFilter(false);
|
||||||
if (typeof window !== "undefined") {
|
setUploadedFilename(null);
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("ingestionFailed", {
|
|
||||||
detail: { source: "onboarding" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error toast notification
|
// Jump back one step after 1 second (go back to upload step)
|
||||||
toast.error("Document upload failed", {
|
setTimeout(() => {
|
||||||
description: errorMessage,
|
setCurrentStep(null);
|
||||||
duration: 5000,
|
}, 1000);
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset on error
|
// Check if the matching task is still active (pending, running, or processing)
|
||||||
setCurrentStep(null);
|
const isTaskActive =
|
||||||
} finally {
|
matchingTask.status === "pending" ||
|
||||||
setIsUploading(false);
|
matchingTask.status === "running" ||
|
||||||
}
|
matchingTask.status === "processing";
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
// If task is completed successfully (no failures) and has processed files, complete the onboarding step
|
||||||
const selectedFile = event.target.files?.[0];
|
if (
|
||||||
if (!selectedFile) {
|
(!isTaskActive || (matchingTask.processed_files ?? 0) > 0) &&
|
||||||
resetFileInput();
|
!hasFailedFile
|
||||||
return;
|
) {
|
||||||
}
|
// Set to final step to show "Done"
|
||||||
|
setCurrentStep(STEP_LIST.length);
|
||||||
|
|
||||||
try {
|
// Create knowledge filter for uploaded document if requested
|
||||||
await performUpload(selectedFile);
|
// Guard against race condition: only create if not already creating
|
||||||
} catch (error) {
|
if (shouldCreateFilter && uploadedFilename && !isCreatingFilter) {
|
||||||
console.error(
|
// Reset flags immediately (synchronously) to prevent duplicate creation
|
||||||
"Unable to prepare file for upload",
|
setShouldCreateFilter(false);
|
||||||
(error as Error).message,
|
const filename = uploadedFilename;
|
||||||
);
|
setUploadedFilename(null);
|
||||||
} finally {
|
setIsCreatingFilter(true);
|
||||||
resetFileInput();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Get display name from filename (remove extension for cleaner name)
|
||||||
<AnimatePresence mode="wait">
|
const displayName = filename.includes(".")
|
||||||
{currentStep === null ? (
|
? filename.substring(0, filename.lastIndexOf("."))
|
||||||
<motion.div
|
: filename;
|
||||||
key="user-ingest"
|
|
||||||
initial={{ opacity: 1, y: 0 }}
|
const queryData = JSON.stringify({
|
||||||
exit={{ opacity: 0, y: -24 }}
|
query: "",
|
||||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
filters: {
|
||||||
>
|
data_sources: [filename],
|
||||||
<Button
|
document_types: ["*"],
|
||||||
size="sm"
|
owners: ["*"],
|
||||||
variant="outline"
|
connector_types: ["*"],
|
||||||
onClick={handleUploadClick}
|
},
|
||||||
disabled={isUploading}
|
limit: 10,
|
||||||
>
|
scoreThreshold: 0,
|
||||||
<div>{isUploading ? "Uploading..." : "Add a document"}</div>
|
color: "green",
|
||||||
</Button>
|
icon: "file",
|
||||||
<input
|
});
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
// Wait for filter creation to complete before proceeding
|
||||||
onChange={handleFileChange}
|
createFilterMutation
|
||||||
className="hidden"
|
.mutateAsync({
|
||||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
name: displayName,
|
||||||
/>
|
description: `Filter for ${filename}`,
|
||||||
</motion.div>
|
queryData: queryData,
|
||||||
) : (
|
})
|
||||||
<motion.div
|
.then((result) => {
|
||||||
key="ingest-steps"
|
if (result.filter?.id && typeof window !== "undefined") {
|
||||||
initial={{ opacity: 0, y: 24 }}
|
localStorage.setItem(
|
||||||
animate={{ opacity: 1, y: 0 }}
|
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
||||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
result.filter.id,
|
||||||
>
|
);
|
||||||
<AnimatedProviderSteps
|
console.log(
|
||||||
currentStep={currentStep}
|
"Created knowledge filter for uploaded document",
|
||||||
setCurrentStep={setCurrentStep}
|
result.filter.id,
|
||||||
isCompleted={false}
|
);
|
||||||
steps={STEP_LIST}
|
}
|
||||||
storageKey={ONBOARDING_UPLOAD_STEPS_KEY}
|
})
|
||||||
/>
|
.catch((error) => {
|
||||||
</motion.div>
|
console.error("Failed to create knowledge filter:", error);
|
||||||
)}
|
})
|
||||||
</AnimatePresence>
|
.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
|
||||||
|
setTimeout(() => {
|
||||||
|
onComplete();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
tasks,
|
||||||
|
currentStep,
|
||||||
|
onComplete,
|
||||||
|
shouldCreateFilter,
|
||||||
|
uploadedFilename,
|
||||||
|
uploadedTaskId,
|
||||||
|
createFilterMutation,
|
||||||
|
isCreatingFilter,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resetFileInput = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
// Clear any previous error when user clicks to upload again
|
||||||
|
setError(null);
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const performUpload = async (file: File) => {
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -5,125 +5,131 @@ 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 {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom hook to check provider health status
|
// Custom hook to check provider health status
|
||||||
export function useProviderHealth() {
|
export function useProviderHealth() {
|
||||||
const { hasChatError } = useChat();
|
const { hasChatError } = useChat();
|
||||||
const {
|
const {
|
||||||
data: health,
|
data: health,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
error,
|
error,
|
||||||
isError,
|
isError,
|
||||||
} = useProviderHealthQuery({
|
} = useProviderHealthQuery({
|
||||||
test_completion: hasChatError, // Use test_completion=true when chat errors occur
|
test_completion: hasChatError, // Use test_completion=true when chat errors occur
|
||||||
});
|
});
|
||||||
|
|
||||||
const isHealthy = health?.status === "healthy" && !isError;
|
const isHealthy = health?.status === "healthy" && !isError;
|
||||||
// Only consider unhealthy if backend is up but provider validation failed
|
// Only consider unhealthy if backend is up but provider validation failed
|
||||||
// Don't show banner if backend is unavailable
|
// Don't show banner if backend is unavailable
|
||||||
const isUnhealthy =
|
const isUnhealthy =
|
||||||
health?.status === "unhealthy" || health?.status === "error";
|
health?.status === "unhealthy" || health?.status === "error";
|
||||||
const isBackendUnavailable =
|
const isBackendUnavailable =
|
||||||
health?.status === "backend-unavailable" || isError;
|
health?.status === "backend-unavailable" || isError;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
health,
|
health,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
error,
|
error,
|
||||||
isError,
|
isError,
|
||||||
isHealthy,
|
isHealthy,
|
||||||
isUnhealthy,
|
isUnhealthy,
|
||||||
isBackendUnavailable,
|
isBackendUnavailable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerTitleMap: Record<ModelProvider, string> = {
|
const providerTitleMap: Record<ModelProvider, string> = {
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
watsonx: "IBM watsonx.ai",
|
watsonx: "IBM watsonx.ai",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
|
export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
|
||||||
const { isLoading, isHealthy, isUnhealthy, health } = useProviderHealth();
|
const { isLoading, isHealthy, isUnhealthy, health } = useProviderHealth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Only show banner when provider is unhealthy (not when backend is unavailable)
|
// Only show banner when provider is unhealthy (not when backend is unavailable)
|
||||||
if (isLoading || isHealthy) {
|
if (isLoading || isHealthy) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnhealthy) {
|
if (isUnhealthy) {
|
||||||
const llmProvider = health?.llm_provider || health?.provider;
|
const llmProvider = health?.llm_provider || health?.provider;
|
||||||
const embeddingProvider = health?.embedding_provider;
|
const embeddingProvider = health?.embedding_provider;
|
||||||
const llmError = health?.llm_error;
|
const llmError = health?.llm_error;
|
||||||
const embeddingError = health?.embedding_error;
|
const embeddingError = health?.embedding_error;
|
||||||
|
|
||||||
// Determine which provider has the error
|
// Determine which provider has the error
|
||||||
let errorProvider: string | undefined;
|
let errorProvider: string | undefined;
|
||||||
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) {
|
||||||
errorProvider = undefined; // Don't link to a specific provider
|
// Same error for both - show once
|
||||||
} else if (llmError) {
|
errorMessage = llmError;
|
||||||
// Only LLM has error
|
} else {
|
||||||
errorProvider = llmProvider;
|
// Different errors - show both
|
||||||
errorMessage = llmError;
|
errorMessage = `${llmError}; ${embeddingError}`;
|
||||||
} else if (embeddingError) {
|
}
|
||||||
// Only embedding has error
|
errorProvider = undefined; // Don't link to a specific provider
|
||||||
errorProvider = embeddingProvider;
|
} else if (llmError) {
|
||||||
errorMessage = embeddingError;
|
// Only LLM has error
|
||||||
} else {
|
errorProvider = llmProvider;
|
||||||
// Fallback to original message
|
errorMessage = llmError;
|
||||||
errorMessage = health?.message || "Provider validation failed";
|
} else if (embeddingError) {
|
||||||
errorProvider = llmProvider;
|
// Only embedding has error
|
||||||
}
|
errorProvider = embeddingProvider;
|
||||||
|
errorMessage = embeddingError;
|
||||||
|
} else {
|
||||||
|
// Fallback to original message
|
||||||
|
errorMessage = health?.message || "Provider validation failed";
|
||||||
|
errorProvider = llmProvider;
|
||||||
|
}
|
||||||
|
|
||||||
const providerTitle = errorProvider
|
const providerTitle = errorProvider
|
||||||
? providerTitleMap[errorProvider as ModelProvider] || errorProvider
|
? providerTitleMap[errorProvider as ModelProvider] || errorProvider
|
||||||
: "Provider";
|
: "Provider";
|
||||||
|
|
||||||
const settingsUrl = errorProvider
|
const settingsUrl = errorProvider
|
||||||
? `/settings?setup=${errorProvider}`
|
? `/settings?setup=${errorProvider}`
|
||||||
: "/settings";
|
: "/settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
|
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BannerIcon
|
<BannerIcon
|
||||||
className="text-accent-red-foreground"
|
className="text-accent-red-foreground"
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
/>
|
/>
|
||||||
<BannerTitle className="font-medium flex items-center gap-2">
|
<BannerTitle className="font-medium flex items-center gap-2">
|
||||||
{llmError && embeddingError ? (
|
{llmError && embeddingError ? (
|
||||||
<>Provider errors - {errorMessage}</>
|
<>Provider errors - {errorMessage}</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{providerTitle} error - {errorMessage}
|
{providerTitle} error - {errorMessage}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</BannerTitle>
|
</BannerTitle>
|
||||||
<Button size="sm" onClick={() => router.push(settingsUrl)}>
|
<Button size="sm" onClick={() => router.push(settingsUrl)}>
|
||||||
Fix Setup
|
Fix Setup
|
||||||
</Button>
|
</Button>
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue