Merge branch 'main' into issue-454-install-docs

This commit is contained in:
April M 2025-12-05 16:02:26 -08:00
commit d8221cd2e4
28 changed files with 4338 additions and 2021 deletions

View file

@ -865,58 +865,99 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
metadatas.append(data_copy) metadatas.append(data_copy)
self.log(metadatas) self.log(metadatas)
# Generate embeddings (threaded for concurrency) with retries # Generate embeddings with rate-limit-aware retry logic using tenacity
def embed_chunk(chunk_text: str) -> list[float]: from tenacity import (
return selected_embedding.embed_documents([chunk_text])[0] retry,
retry_if_exception,
stop_after_attempt,
wait_exponential,
)
vectors: list[list[float]] | None = None def is_rate_limit_error(exception: Exception) -> bool:
last_exception: Exception | None = None """Check if exception is a rate limit error (429)."""
delay = 1.0 error_str = str(exception).lower()
attempts = 0 return "429" in error_str or "rate_limit" in error_str or "rate limit" in error_str
max_attempts = 3
def is_other_retryable_error(exception: Exception) -> bool:
"""Check if exception is retryable but not a rate limit error."""
# Retry on most exceptions except for specific non-retryable ones
# Add other non-retryable exceptions here if needed
return not is_rate_limit_error(exception)
# Create retry decorator for rate limit errors (longer backoff)
retry_on_rate_limit = retry(
retry=retry_if_exception(is_rate_limit_error),
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=2, min=2, max=30),
reraise=True,
before_sleep=lambda retry_state: logger.warning(
f"Rate limit hit for chunk (attempt {retry_state.attempt_number}/5), "
f"backing off for {retry_state.next_action.sleep:.1f}s"
),
)
# Create retry decorator for other errors (shorter backoff)
retry_on_other_errors = retry(
retry=retry_if_exception(is_other_retryable_error),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
reraise=True,
before_sleep=lambda retry_state: logger.warning(
f"Error embedding chunk (attempt {retry_state.attempt_number}/3), "
f"retrying in {retry_state.next_action.sleep:.1f}s: {retry_state.outcome.exception()}"
),
)
def embed_chunk_with_retry(chunk_text: str, chunk_idx: int) -> list[float]:
"""Embed a single chunk with rate-limit-aware retry logic."""
@retry_on_rate_limit
@retry_on_other_errors
def _embed(text: str) -> list[float]:
return selected_embedding.embed_documents([text])[0]
while attempts < max_attempts:
attempts += 1
try: try:
# Restrict concurrency for IBM/Watsonx models to avoid rate limits return _embed(chunk_text)
is_ibm = (embedding_model and "ibm" in str(embedding_model).lower()) or ( except Exception as e:
selected_embedding and "watsonx" in type(selected_embedding).__name__.lower() logger.error(
f"Failed to embed chunk {chunk_idx} after all retries: {e}",
error=str(e),
) )
logger.debug(f"Is IBM: {is_ibm}") raise
max_workers = 1 if is_ibm else min(max(len(texts), 1), 8)
with ThreadPoolExecutor(max_workers=max_workers) as executor: # Restrict concurrency for IBM/Watsonx models to avoid rate limits
futures = {executor.submit(embed_chunk, chunk): idx for idx, chunk in enumerate(texts)} is_ibm = (embedding_model and "ibm" in str(embedding_model).lower()) or (
vectors = [None] * len(texts) selected_embedding and "watsonx" in type(selected_embedding).__name__.lower()
for future in as_completed(futures): )
idx = futures[future] logger.debug(f"Is IBM: {is_ibm}")
vectors[idx] = future.result()
break
except Exception as exc:
last_exception = exc
if attempts >= max_attempts:
logger.error(
f"Embedding generation failed for model {embedding_model} after retries",
error=str(exc),
)
raise
logger.warning(
"Threaded embedding generation failed for model %s (attempt %s/%s), retrying in %.1fs",
embedding_model,
attempts,
max_attempts,
delay,
)
time.sleep(delay)
delay = min(delay * 2, 8.0)
if vectors is None: # For IBM models, use sequential processing with rate limiting
raise RuntimeError( # For other models, use parallel processing
f"Embedding generation failed for {embedding_model}: {last_exception}" vectors: list[list[float]] = [None] * len(texts)
if last_exception
else f"Embedding generation failed for {embedding_model}" if is_ibm:
# Sequential processing with inter-request delay for IBM models
inter_request_delay = 0.6 # ~1.67 req/s, safely under 2 req/s limit
logger.info(
f"Using sequential processing for IBM model with {inter_request_delay}s delay between requests"
) )
for idx, chunk in enumerate(texts):
if idx > 0:
# Add delay between requests (but not before the first one)
time.sleep(inter_request_delay)
vectors[idx] = embed_chunk_with_retry(chunk, idx)
else:
# Parallel processing for non-IBM models
max_workers = min(max(len(texts), 1), 8)
logger.debug(f"Using parallel processing with {max_workers} workers")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(embed_chunk_with_retry, chunk, idx): idx for idx, chunk in enumerate(texts)}
for future in as_completed(futures):
idx = futures[future]
vectors[idx] = future.result()
if not vectors: if not vectors:
self.log(f"No vectors generated from documents for model {embedding_model}.") self.log(f"No vectors generated from documents for model {embedding_model}.")
return return

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,44 @@
import {
type UseMutationOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
interface OnboardingRollbackResponse {
message: string;
}
export const useOnboardingRollbackMutation = (
options?: Omit<
UseMutationOptions<OnboardingRollbackResponse, Error, void>,
"mutationFn"
>,
) => {
const queryClient = useQueryClient();
async function rollbackOnboarding(): Promise<OnboardingRollbackResponse> {
const response = await fetch("/api/onboarding/rollback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to rollback onboarding");
}
return response.json();
}
return useMutation({
mutationFn: rollbackOnboarding,
onSettled: () => {
// Invalidate settings query to refetch updated data
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
...options,
});
};

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,

File diff suppressed because it is too large Load diff

View file

@ -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}
/> />
</> </>
); );
} }

View file

@ -3,12 +3,13 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
type OnboardingVariables, type OnboardingVariables,
useOnboardingMutation, useOnboardingMutation,
} from "@/app/api/mutations/useOnboardingMutation"; } from "@/app/api/mutations/useOnboardingMutation";
import { useOnboardingRollbackMutation } from "@/app/api/mutations/useOnboardingRollbackMutation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery"; import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
@ -170,12 +171,32 @@ const OnboardingCard = ({
const [error, setError] = useState<string | null>(null); 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());
// Query tasks to track completion // Query tasks to track completion
const { data: tasks } = useGetTasksQuery({ const { data: tasks } = useGetTasksQuery({
enabled: currentStep !== null, // Only poll when onboarding has started enabled: currentStep !== null, // Only poll when onboarding has started
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding
}); });
// Rollback mutation
const rollbackMutation = useOnboardingRollbackMutation({
onSuccess: () => {
console.log("Onboarding rolled back successfully");
// Reset to provider selection step
// Error message is already set before calling mutate
setCurrentStep(null);
},
onError: (error) => {
console.error("Failed to rollback onboarding", error);
// Preserve existing error message if set, otherwise show rollback error
setError((prevError) => prevError || `Failed to rollback: ${error.message}`);
// Still reset to provider selection even if rollback fails
setCurrentStep(null);
},
});
// Monitor tasks and call onComplete when all tasks are done // Monitor tasks and call onComplete when all tasks are done
useEffect(() => { useEffect(() => {
if (currentStep === null || !tasks || !isEmbedding) { if (currentStep === null || !tasks || !isEmbedding) {
@ -190,11 +211,86 @@ const OnboardingCard = ({
task.status === "processing", task.status === "processing",
); );
// Check if any file failed in completed tasks
const completedTasks = tasks.filter(
(task) => task.status === "completed"
);
// Check if any completed task has at least one failed file
const taskWithFailedFile = completedTasks.find((task) => {
// Must have files object
if (!task.files || typeof task.files !== "object") {
return false;
}
const fileEntries = Object.values(task.files);
// Must have at least one file
if (fileEntries.length === 0) {
return false;
}
// Check if any file has failed status
const hasFailedFile = fileEntries.some(
(file) => file.status === "failed" || file.status === "error"
);
return hasFailedFile;
});
// If any file failed, show error and jump back one step (like onboardingMutation.onError)
// Only handle if we haven't already handled this task
if (
taskWithFailedFile &&
!rollbackMutation.isPending &&
!isCompleted &&
!handledFailedTasksRef.current.has(taskWithFailedFile.task_id)
) {
console.error("File failed in task, jumping back one step", taskWithFailedFile);
// Mark this task as handled to prevent infinite loops
handledFailedTasksRef.current.add(taskWithFailedFile.task_id);
// Extract error messages from failed files
const errorMessages: string[] = [];
if (taskWithFailedFile.files) {
Object.values(taskWithFailedFile.files).forEach((file) => {
if ((file.status === "failed" || file.status === "error") && file.error) {
errorMessages.push(file.error);
}
});
}
// Also check task-level error
if (taskWithFailedFile.error) {
errorMessages.push(taskWithFailedFile.error);
}
// Use the first error message, or a generic message if no errors found
const errorMessage = errorMessages.length > 0
? errorMessages[0]
: "Sample data file failed to ingest. Please try again with a different configuration.";
// Set error message and jump back one step (exactly like onboardingMutation.onError)
setError(errorMessage);
setCurrentStep(totalSteps);
// Jump back one step after 1 second (go back to the step before ingestion)
// For embedding: totalSteps is 4, ingestion is step 3, so go back to step 2
// For LLM: totalSteps is 3, ingestion is step 2, so go back to step 1
setTimeout(() => {
// Go back to the step before the last step (which is ingestion)
const previousStep = totalSteps > 1 ? totalSteps - 2 : 0;
setCurrentStep(previousStep);
}, 1000);
return;
}
// If no active tasks and we've started onboarding, complete it // If no active tasks and we've started onboarding, complete it
if ( if (
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) && (!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
tasks.length > 0 && tasks.length > 0 &&
!isCompleted !isCompleted &&
!taskWithFailedFile
) { ) {
// Set to final step to show "Done" // Set to final step to show "Done"
setCurrentStep(totalSteps); setCurrentStep(totalSteps);
@ -203,7 +299,7 @@ const OnboardingCard = ({
onComplete(); onComplete();
}, 1000); }, 1000);
} }
}, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps]); }, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps, rollbackMutation]);
// Mutations // Mutations
const onboardingMutation = useOnboardingMutation({ const onboardingMutation = useOnboardingMutation({
@ -507,7 +603,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 +613,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 +626,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";
@ -7,13 +8,13 @@ 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) => {
@ -21,6 +22,7 @@ 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);
@ -43,23 +45,26 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
// 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 }
) {
// 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 or has processed files, complete the onboarding step
if (!isTaskActive || (matchingTask.processed_files ?? 0) > 0) {
// Set to final step to show "Done" // Set to final step to show "Done"
setCurrentStep(STEP_LIST.length); setCurrentStep(STEP_LIST.length);
@ -91,6 +96,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 +120,36 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
}) })
.finally(() => { .finally(() => {
setIsCreatingFilter(false); setIsCreatingFilter(false);
// Refetch nudges to get new ones
refetchNudges();
// Wait a bit before completing (after filter is created)
setTimeout(() => {
onComplete();
}, 1000);
}); });
} else {
// No filter to create, just complete
// Refetch nudges to get new ones
refetchNudges();
// Wait a bit before completing
setTimeout(() => {
onComplete();
}, 1000);
} }
// Refetch nudges to get new ones
refetchNudges();
// Wait a bit before completing
setTimeout(() => {
onComplete();
}, 1000);
} }
}, [tasks, currentStep, onComplete, refetchNudges, shouldCreateFilter, uploadedFilename]); }, [
tasks,
currentStep,
onComplete,
refetchNudges,
shouldCreateFilter,
uploadedFilename,
uploadedTaskId,
createFilterMutation,
isCreatingFilter,
]);
const resetFileInput = () => { const resetFileInput = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -144,6 +168,11 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
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);
@ -176,6 +205,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
// Reset on error // Reset on error
setCurrentStep(null); setCurrentStep(null);
setUploadedTaskId(null);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }

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

View file

@ -10,6 +10,8 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { ONBOARDING_STEP_KEY } from "@/lib/constants";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
export type EndpointType = "chat" | "langflow"; export type EndpointType = "chat" | "langflow";
@ -81,6 +83,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);
@ -111,6 +115,46 @@ export function ChatProvider({ children }: ChatProviderProps) {
const [conversationFilter, setConversationFilterState] = const [conversationFilter, setConversationFilterState] =
useState<KnowledgeFilter | null>(null); useState<KnowledgeFilter | null>(null);
const [hasChatError, setChatError] = useState(false); const [hasChatError, setChatError] = useState(false);
// Get settings to check if onboarding was completed (settings.edited)
const { data: settings } = useGetSettingsQuery();
// Check if onboarding is complete
// Onboarding is complete if:
// 1. settings.edited is true (backend confirms onboarding was completed)
// 2. AND onboarding step key is null (local onboarding flow is done)
const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => {
if (typeof window === "undefined") return false;
// Default to false if settings not loaded yet
return false;
});
// Sync onboarding completion state with settings.edited and localStorage
useEffect(() => {
const checkOnboarding = () => {
if (typeof window !== "undefined") {
// Onboarding is complete if settings.edited is true AND step key is null
const stepKeyExists = localStorage.getItem(ONBOARDING_STEP_KEY) !== null;
const isEdited = settings?.edited === true;
// Complete if edited is true and step key doesn't exist (onboarding flow finished)
setIsOnboardingComplete(isEdited && !stepKeyExists);
}
};
// Check on mount and when settings change
checkOnboarding();
// Listen for storage events (for cross-tab sync)
window.addEventListener("storage", checkOnboarding);
return () => {
window.removeEventListener("storage", checkOnboarding);
};
}, [settings?.edited]);
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(() => {
@ -228,6 +272,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
const startNewConversation = useCallback(async () => { const startNewConversation = useCallback(async () => {
console.log("[CONVERSATION] Starting new conversation"); console.log("[CONVERSATION] Starting new conversation");
// Check if there's existing conversation data - if so, this is a manual "new conversation" action
// Check state values before clearing them
const hasExistingConversation = conversationData !== null || placeholderConversation !== null;
// Clear current conversation data and reset state // Clear current conversation data and reset state
setCurrentConversationId(null); setCurrentConversationId(null);
setPreviousResponseIds({ chat: null, langflow: null }); setPreviousResponseIds({ chat: null, langflow: null });
@ -261,15 +309,22 @@ export function ChatProvider({ children }: ChatProviderProps) {
setConversationFilterState(null); setConversationFilterState(null);
} }
} else { } else {
console.log("[CONVERSATION] No default filter set"); // No default filter in localStorage
setConversationFilterState(null); if (hasExistingConversation) {
// User is manually starting a new conversation - clear the filter
console.log("[CONVERSATION] Manual new conversation - clearing filter");
setConversationFilterState(null);
} else {
// First time after onboarding - preserve existing filter if set
// This prevents clearing the filter when startNewConversation is called multiple times during onboarding
console.log("[CONVERSATION] No default filter set, preserving existing filter if any");
// Don't clear the filter - it may have been set by storeDefaultFilterForNewConversations
}
} }
} else {
setConversationFilterState(null);
} }
// Create a temporary placeholder conversation to show in sidebar // Create a temporary placeholder conversation to show in sidebar
const placeholderConversation: ConversationData = { const newPlaceholderConversation: ConversationData = {
response_id: "new-conversation-" + Date.now(), response_id: "new-conversation-" + Date.now(),
title: "New conversation", title: "New conversation",
endpoint: endpoint, endpoint: endpoint,
@ -284,10 +339,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
last_activity: new Date().toISOString(), last_activity: new Date().toISOString(),
}; };
setPlaceholderConversation(placeholderConversation); setPlaceholderConversation(newPlaceholderConversation);
// Force immediate refresh to ensure sidebar shows correct state // Force immediate refresh to ensure sidebar shows correct state
refreshConversations(true); refreshConversations(true);
}, [endpoint, refreshConversations]); }, [endpoint, refreshConversations, conversationData, placeholderConversation]);
const addConversationDoc = useCallback((filename: string) => { const addConversationDoc = useCallback((filename: string) => {
setConversationDocs((prev) => [ setConversationDocs((prev) => [
@ -375,6 +430,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
setConversationFilter, setConversationFilter,
hasChatError, hasChatError,
setChatError, setChatError,
isOnboardingComplete,
setOnboardingComplete,
}), }),
[ [
endpoint, endpoint,
@ -396,6 +453,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

@ -38,7 +38,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.3.5", "next": "15.5.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -1169,9 +1169,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==", "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@ -1185,9 +1185,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
"integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==", "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1201,9 +1201,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
"integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==", "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1217,9 +1217,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
"integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==", "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1233,9 +1233,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
"integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==", "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1249,9 +1249,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
"integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==", "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1265,9 +1265,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
"integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==", "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1281,9 +1281,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
"integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==", "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1297,9 +1297,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
"integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==", "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2568,12 +2568,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3821,17 +3815,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@ -5448,9 +5431,9 @@
} }
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "10.4.5", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"foreground-child": "^3.1.0", "foreground-child": "^3.1.0",
@ -6584,9 +6567,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7194,9 +7177,10 @@
} }
}, },
"node_modules/mdast-util-to-hast": { "node_modules/mdast-util-to-hast": {
"version": "13.2.0", "version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/hast": "^3.0.0", "@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0", "@types/mdast": "^4.0.0",
@ -7973,15 +7957,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.3.5", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.3.5.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==", "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.3.5", "@next/env": "15.5.7",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
"styled-jsx": "5.1.6" "styled-jsx": "5.1.6"
@ -7993,19 +7975,19 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-arm64": "15.5.7",
"@next/swc-darwin-x64": "15.3.5", "@next/swc-darwin-x64": "15.5.7",
"@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-gnu": "15.5.7",
"@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-arm64-musl": "15.5.7",
"@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-gnu": "15.5.7",
"@next/swc-linux-x64-musl": "15.3.5", "@next/swc-linux-x64-musl": "15.5.7",
"@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-arm64-msvc": "15.5.7",
"@next/swc-win32-x64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.5.7",
"sharp": "^0.34.1" "sharp": "^0.34.3"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2", "@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*", "babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
@ -9492,14 +9474,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",

View file

@ -41,7 +41,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.3.5", "next": "15.5.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View file

@ -47,8 +47,8 @@ def get_conversation_thread(user_id: str, previous_response_id: str = None):
return new_conversation return new_conversation
def store_conversation_thread(user_id: str, response_id: str, conversation_state: dict): async def store_conversation_thread(user_id: str, response_id: str, conversation_state: dict):
"""Store conversation both in memory (with function calls) and persist metadata to disk""" """Store conversation both in memory (with function calls) and persist metadata to disk (async, non-blocking)"""
# 1. Store full conversation in memory for function call preservation # 1. Store full conversation in memory for function call preservation
if user_id not in active_conversations: if user_id not in active_conversations:
active_conversations[user_id] = {} active_conversations[user_id] = {}
@ -76,7 +76,7 @@ def store_conversation_thread(user_id: str, response_id: str, conversation_state
# Don't store actual messages - Langflow has them # Don't store actual messages - Langflow has them
} }
conversation_persistence.store_conversation_thread( await conversation_persistence.store_conversation_thread(
user_id, response_id, metadata_only user_id, response_id, metadata_only
) )
@ -382,7 +382,7 @@ async def async_chat(
# Store the conversation thread with its response_id # Store the conversation thread with its response_id
if response_id: if response_id:
conversation_state["last_activity"] = datetime.now() conversation_state["last_activity"] = datetime.now()
store_conversation_thread(user_id, response_id, conversation_state) await store_conversation_thread(user_id, response_id, conversation_state)
logger.debug( logger.debug(
"Stored conversation thread", user_id=user_id, response_id=response_id "Stored conversation thread", user_id=user_id, response_id=response_id
) )
@ -461,7 +461,7 @@ async def async_chat_stream(
# Store the conversation thread with its response_id # Store the conversation thread with its response_id
if response_id: if response_id:
conversation_state["last_activity"] = datetime.now() conversation_state["last_activity"] = datetime.now()
store_conversation_thread(user_id, response_id, conversation_state) await store_conversation_thread(user_id, response_id, conversation_state)
logger.debug( logger.debug(
f"Stored conversation thread for user {user_id} with response_id: {response_id}" f"Stored conversation thread for user {user_id} with response_id: {response_id}"
) )
@ -549,7 +549,7 @@ async def async_langflow_chat(
# Store the conversation thread with its response_id # Store the conversation thread with its response_id
if response_id: if response_id:
conversation_state["last_activity"] = datetime.now() conversation_state["last_activity"] = datetime.now()
store_conversation_thread(user_id, response_id, conversation_state) await store_conversation_thread(user_id, response_id, conversation_state)
# Claim session ownership for this user # Claim session ownership for this user
try: try:
@ -656,7 +656,7 @@ async def async_langflow_chat_stream(
# Store the conversation thread with its response_id # Store the conversation thread with its response_id
if response_id: if response_id:
conversation_state["last_activity"] = datetime.now() conversation_state["last_activity"] = datetime.now()
store_conversation_thread(user_id, response_id, conversation_state) await store_conversation_thread(user_id, response_id, conversation_state)
# Claim session ownership for this user # Claim session ownership for this user
try: try:
@ -672,8 +672,8 @@ async def async_langflow_chat_stream(
) )
def delete_user_conversation(user_id: str, response_id: str) -> bool: async def delete_user_conversation(user_id: str, response_id: str) -> bool:
"""Delete a conversation for a user from both memory and persistent storage""" """Delete a conversation for a user from both memory and persistent storage (async, non-blocking)"""
deleted = False deleted = False
try: try:
@ -684,7 +684,7 @@ def delete_user_conversation(user_id: str, response_id: str) -> bool:
deleted = True deleted = True
# Delete from persistent storage # Delete from persistent storage
conversation_deleted = conversation_persistence.delete_conversation_thread(user_id, response_id) conversation_deleted = await conversation_persistence.delete_conversation_thread(user_id, response_id)
if conversation_deleted: if conversation_deleted:
logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}") logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}")
deleted = True deleted = True

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")

View file

@ -897,7 +897,7 @@ async def onboarding(request, flows_service, session_manager=None):
) )
# Validate provider setup before initializing OpenSearch index # Validate provider setup before initializing OpenSearch index
# Use lightweight validation (test_completion=False) to avoid consuming credits during onboarding # Use full validation with completion tests (test_completion=True) to ensure provider health during onboarding
try: try:
from api.provider_validation import validate_provider_setup from api.provider_validation import validate_provider_setup
@ -906,14 +906,14 @@ async def onboarding(request, flows_service, session_manager=None):
llm_provider = current_config.agent.llm_provider.lower() llm_provider = current_config.agent.llm_provider.lower()
llm_provider_config = current_config.get_llm_provider_config() llm_provider_config = current_config.get_llm_provider_config()
logger.info(f"Validating LLM provider setup for {llm_provider} (lightweight)") logger.info(f"Validating LLM provider setup for {llm_provider} (full validation with completion test)")
await validate_provider_setup( await validate_provider_setup(
provider=llm_provider, provider=llm_provider,
api_key=getattr(llm_provider_config, "api_key", None), api_key=getattr(llm_provider_config, "api_key", None),
llm_model=current_config.agent.llm_model, llm_model=current_config.agent.llm_model,
endpoint=getattr(llm_provider_config, "endpoint", None), endpoint=getattr(llm_provider_config, "endpoint", None),
project_id=getattr(llm_provider_config, "project_id", None), project_id=getattr(llm_provider_config, "project_id", None),
test_completion=False, # Lightweight validation - no credits consumed test_completion=True, # Full validation with completion test - ensures provider health
) )
logger.info(f"LLM provider setup validation completed successfully for {llm_provider}") logger.info(f"LLM provider setup validation completed successfully for {llm_provider}")
@ -922,14 +922,14 @@ async def onboarding(request, flows_service, session_manager=None):
embedding_provider = current_config.knowledge.embedding_provider.lower() embedding_provider = current_config.knowledge.embedding_provider.lower()
embedding_provider_config = current_config.get_embedding_provider_config() embedding_provider_config = current_config.get_embedding_provider_config()
logger.info(f"Validating embedding provider setup for {embedding_provider} (lightweight)") logger.info(f"Validating embedding provider setup for {embedding_provider} (full validation with completion test)")
await validate_provider_setup( await validate_provider_setup(
provider=embedding_provider, provider=embedding_provider,
api_key=getattr(embedding_provider_config, "api_key", None), api_key=getattr(embedding_provider_config, "api_key", None),
embedding_model=current_config.knowledge.embedding_model, embedding_model=current_config.knowledge.embedding_model,
endpoint=getattr(embedding_provider_config, "endpoint", None), endpoint=getattr(embedding_provider_config, "endpoint", None),
project_id=getattr(embedding_provider_config, "project_id", None), project_id=getattr(embedding_provider_config, "project_id", None),
test_completion=False, # Lightweight validation - no credits consumed test_completion=True, # Full validation with completion test - ensures provider health
) )
logger.info(f"Embedding provider setup validation completed successfully for {embedding_provider}") logger.info(f"Embedding provider setup validation completed successfully for {embedding_provider}")
except Exception as e: except Exception as e:
@ -1403,6 +1403,139 @@ async def reapply_all_settings(session_manager = None):
raise raise
async def rollback_onboarding(request, session_manager, task_service):
"""Rollback onboarding configuration when sample data files fail.
This will:
1. Cancel all active tasks
2. Delete successfully ingested knowledge documents
3. Reset configuration to allow re-onboarding
"""
try:
# Get current configuration
current_config = get_openrag_config()
# Only allow rollback if config was marked as edited (onboarding completed)
if not current_config.edited:
return JSONResponse(
{"error": "No onboarding configuration to rollback"}, status_code=400
)
user = request.state.user
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
logger.info("Rolling back onboarding configuration due to file failures")
# Get all tasks for the user
all_tasks = task_service.get_all_tasks(user.user_id)
cancelled_tasks = []
deleted_files = []
# Cancel all active tasks and collect successfully ingested files
for task_data in all_tasks:
task_id = task_data.get("task_id")
task_status = task_data.get("status")
# Cancel active tasks (pending, running, processing)
if task_status in ["pending", "running", "processing"]:
try:
success = await task_service.cancel_task(user.user_id, task_id)
if success:
cancelled_tasks.append(task_id)
logger.info(f"Cancelled task {task_id}")
except Exception as e:
logger.error(f"Failed to cancel task {task_id}: {str(e)}")
# For completed tasks, find successfully ingested files and delete them
elif task_status == "completed":
files = task_data.get("files", {})
if isinstance(files, dict):
for file_path, file_info in files.items():
# Check if file was successfully ingested
if isinstance(file_info, dict):
file_status = file_info.get("status")
filename = file_info.get("filename") or file_path.split("/")[-1]
if file_status == "completed" and filename:
try:
# Get user's OpenSearch client
opensearch_client = session_manager.get_user_opensearch_client(
user.user_id, jwt_token
)
# Delete documents by filename
from utils.opensearch_queries import build_filename_delete_body
from config.settings import INDEX_NAME
delete_query = build_filename_delete_body(filename)
result = await opensearch_client.delete_by_query(
index=INDEX_NAME,
body=delete_query,
conflicts="proceed"
)
deleted_count = result.get("deleted", 0)
if deleted_count > 0:
deleted_files.append(filename)
logger.info(f"Deleted {deleted_count} chunks for filename {filename}")
except Exception as e:
logger.error(f"Failed to delete documents for {filename}: {str(e)}")
# Clear embedding provider and model settings
current_config.knowledge.embedding_provider = "openai" # Reset to default
current_config.knowledge.embedding_model = ""
# Mark config as not edited so user can go through onboarding again
current_config.edited = False
# Save the rolled back configuration manually to avoid save_config_file setting edited=True
try:
import yaml
config_file = config_manager.config_file
# Ensure directory exists
config_file.parent.mkdir(parents=True, exist_ok=True)
# Save config with edited=False
with open(config_file, "w") as f:
yaml.dump(current_config.to_dict(), f, default_flow_style=False, indent=2)
# Update cached config
config_manager._config = current_config
logger.info("Successfully saved rolled back configuration with edited=False")
except Exception as e:
logger.error(f"Failed to save rolled back configuration: {e}")
return JSONResponse(
{"error": "Failed to save rolled back configuration"}, status_code=500
)
logger.info(
f"Successfully rolled back onboarding configuration. "
f"Cancelled {len(cancelled_tasks)} tasks, deleted {len(deleted_files)} files"
)
await TelemetryClient.send_event(
Category.ONBOARDING,
MessageId.ORB_ONBOARD_ROLLBACK
)
return JSONResponse(
{
"message": "Onboarding configuration rolled back successfully",
"cancelled_tasks": len(cancelled_tasks),
"deleted_files": len(deleted_files),
}
)
except Exception as e:
logger.error("Failed to rollback onboarding configuration", error=str(e))
return JSONResponse(
{"error": f"Failed to rollback onboarding: {str(e)}"}, status_code=500
)
async def update_docling_preset(request, session_manager): async def update_docling_preset(request, session_manager):
"""Update docling settings in the ingest flow - deprecated endpoint, use /settings instead""" """Update docling settings in the ingest flow - deprecated endpoint, use /settings instead"""
try: try:

View file

@ -1,3 +1,4 @@
import asyncio
import os import os
import time import time
@ -140,61 +141,29 @@ INDEX_BODY = {
LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1" LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1"
async def generate_langflow_api_key(modify: bool = False): async def get_langflow_api_key(force_regenerate: bool = False):
"""Generate Langflow API key using superuser credentials at startup""" """Get the Langflow API key, generating one if needed.
Args:
force_regenerate: If True, generates a new key even if one is cached.
Used when a request fails with 401/403 to get a fresh key.
"""
global LANGFLOW_KEY global LANGFLOW_KEY
logger.debug( logger.debug(
"generate_langflow_api_key called", current_key_present=bool(LANGFLOW_KEY) "get_langflow_api_key called",
current_key_present=bool(LANGFLOW_KEY),
force_regenerate=force_regenerate,
) )
# If key already provided via env, do not attempt generation # If we have a cached key and not forcing regeneration, return it
if LANGFLOW_KEY: if LANGFLOW_KEY and not force_regenerate:
if os.getenv("LANGFLOW_KEY"): return LANGFLOW_KEY
logger.info("Using LANGFLOW_KEY from environment; skipping generation")
return LANGFLOW_KEY # If forcing regeneration, clear the cached key
else: if force_regenerate and LANGFLOW_KEY:
# We have a cached key, but let's validate it first logger.info("Forcing Langflow API key regeneration due to auth failure")
logger.debug("Validating cached LANGFLOW_KEY", key_prefix=LANGFLOW_KEY[:8]) LANGFLOW_KEY = None
try:
validation_response = requests.get(
f"{LANGFLOW_URL}/api/v1/users/whoami",
headers={"x-api-key": LANGFLOW_KEY},
timeout=5,
)
if validation_response.status_code == 200:
logger.debug("Cached API key is valid", key_prefix=LANGFLOW_KEY[:8])
return LANGFLOW_KEY
elif validation_response.status_code in (401, 403):
logger.warning(
"Cached API key is unauthorized, generating fresh key",
status_code=validation_response.status_code,
)
LANGFLOW_KEY = None # Clear invalid key
else:
logger.warning(
"Cached API key validation returned non-access error; keeping existing key",
status_code=validation_response.status_code,
)
return LANGFLOW_KEY
except requests.exceptions.Timeout as e:
logger.warning(
"Cached API key validation timed out; keeping existing key",
error=str(e),
)
return LANGFLOW_KEY
except requests.exceptions.RequestException as e:
logger.warning(
"Cached API key validation failed due to request error; keeping existing key",
error=str(e),
)
return LANGFLOW_KEY
except Exception as e:
logger.warning(
"Unexpected error during cached API key validation; keeping existing key",
error=str(e),
)
return LANGFLOW_KEY
# Use default langflow/langflow credentials if auto-login is enabled and credentials not set # Use default langflow/langflow credentials if auto-login is enabled and credentials not set
username = LANGFLOW_SUPERUSER username = LANGFLOW_SUPERUSER
@ -216,72 +185,70 @@ async def generate_langflow_api_key(modify: bool = False):
max_attempts = int(os.getenv("LANGFLOW_KEY_RETRIES", "15")) max_attempts = int(os.getenv("LANGFLOW_KEY_RETRIES", "15"))
delay_seconds = float(os.getenv("LANGFLOW_KEY_RETRY_DELAY", "2.0")) delay_seconds = float(os.getenv("LANGFLOW_KEY_RETRY_DELAY", "2.0"))
for attempt in range(1, max_attempts + 1): async with httpx.AsyncClient(timeout=10.0) as client:
try: for attempt in range(1, max_attempts + 1):
# Login to get access token try:
login_response = requests.post( # Login to get access token
f"{LANGFLOW_URL}/api/v1/login", login_response = await client.post(
headers={"Content-Type": "application/x-www-form-urlencoded"}, f"{LANGFLOW_URL}/api/v1/login",
data={ headers={"Content-Type": "application/x-www-form-urlencoded"},
"username": username, data={
"password": password, "username": username,
}, "password": password,
timeout=10, },
)
login_response.raise_for_status()
access_token = login_response.json().get("access_token")
if not access_token:
raise KeyError("access_token")
# Create API key
api_key_response = requests.post(
f"{LANGFLOW_URL}/api/v1/api_key/",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
},
json={"name": "openrag-auto-generated"},
timeout=10,
)
api_key_response.raise_for_status()
api_key = api_key_response.json().get("api_key")
if not api_key:
raise KeyError("api_key")
# Validate the API key works
validation_response = requests.get(
f"{LANGFLOW_URL}/api/v1/users/whoami",
headers={"x-api-key": api_key},
timeout=10,
)
if validation_response.status_code == 200:
LANGFLOW_KEY = api_key
logger.info(
"Successfully generated and validated Langflow API key",
key_prefix=api_key[:8],
) )
return api_key login_response.raise_for_status()
else: access_token = login_response.json().get("access_token")
logger.error( if not access_token:
"Generated API key validation failed", raise KeyError("access_token")
status_code=validation_response.status_code,
)
raise ValueError(
f"API key validation failed: {validation_response.status_code}"
)
except (requests.exceptions.RequestException, KeyError) as e:
logger.warning(
"Attempt to generate Langflow API key failed",
attempt=attempt,
max_attempts=max_attempts,
error=str(e),
)
if attempt < max_attempts:
time.sleep(delay_seconds)
else:
raise
except requests.exceptions.RequestException as e: # Create API key
api_key_response = await client.post(
f"{LANGFLOW_URL}/api/v1/api_key/",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
},
json={"name": "openrag-auto-generated"},
)
api_key_response.raise_for_status()
api_key = api_key_response.json().get("api_key")
if not api_key:
raise KeyError("api_key")
# Validate the API key works
validation_response = await client.get(
f"{LANGFLOW_URL}/api/v1/users/whoami",
headers={"x-api-key": api_key},
)
if validation_response.status_code == 200:
LANGFLOW_KEY = api_key
logger.info(
"Successfully generated and validated Langflow API key",
key_prefix=api_key[:8],
)
return api_key
else:
logger.error(
"Generated API key validation failed",
status_code=validation_response.status_code,
)
raise ValueError(
f"API key validation failed: {validation_response.status_code}"
)
except (httpx.HTTPStatusError, httpx.RequestError, KeyError) as e:
logger.warning(
"Attempt to generate Langflow API key failed",
attempt=attempt,
max_attempts=max_attempts,
error=str(e),
)
if attempt < max_attempts:
await asyncio.sleep(delay_seconds)
else:
raise
except (httpx.HTTPStatusError, httpx.RequestError) as e:
logger.error("Failed to generate Langflow API key", error=str(e)) logger.error("Failed to generate Langflow API key", error=str(e))
return None return None
except KeyError as e: except KeyError as e:
@ -303,7 +270,7 @@ class AppClients:
async def initialize(self): async def initialize(self):
# Generate Langflow API key first # Generate Langflow API key first
await generate_langflow_api_key() await get_langflow_api_key()
# Initialize OpenSearch client # Initialize OpenSearch client
self.opensearch = AsyncOpenSearch( self.opensearch = AsyncOpenSearch(
@ -362,7 +329,7 @@ class AppClients:
if self.langflow_client is not None: if self.langflow_client is not None:
return self.langflow_client return self.langflow_client
# Try generating key again (with retries) # Try generating key again (with retries)
await generate_langflow_api_key() await get_langflow_api_key()
if LANGFLOW_KEY and self.langflow_client is None: if LANGFLOW_KEY and self.langflow_client is None:
try: try:
self.langflow_client = AsyncOpenAI( self.langflow_client = AsyncOpenAI(
@ -559,8 +526,11 @@ class AppClients:
self.langflow_client = None self.langflow_client = None
async def langflow_request(self, method: str, endpoint: str, **kwargs): async def langflow_request(self, method: str, endpoint: str, **kwargs):
"""Central method for all Langflow API requests""" """Central method for all Langflow API requests.
api_key = await generate_langflow_api_key()
Retries once with a fresh API key on auth failures (401/403).
"""
api_key = await get_langflow_api_key()
if not api_key: if not api_key:
raise ValueError("No Langflow API key available") raise ValueError("No Langflow API key available")
@ -575,57 +545,65 @@ class AppClients:
url = f"{LANGFLOW_URL}{endpoint}" url = f"{LANGFLOW_URL}{endpoint}"
return await self.langflow_http_client.request( response = await self.langflow_http_client.request(
method=method, url=url, headers=headers, **kwargs method=method, url=url, headers=headers, **kwargs
) )
# Retry once with a fresh API key on auth failure
if response.status_code in (401, 403):
logger.warning(
"Langflow request auth failed, regenerating API key and retrying",
status_code=response.status_code,
endpoint=endpoint,
)
api_key = await get_langflow_api_key(force_regenerate=True)
if api_key:
headers["x-api-key"] = api_key
response = await self.langflow_http_client.request(
method=method, url=url, headers=headers, **kwargs
)
return response
async def _create_langflow_global_variable( async def _create_langflow_global_variable(
self, name: str, value: str, modify: bool = False self, name: str, value: str, modify: bool = False
): ):
"""Create a global variable in Langflow via API""" """Create a global variable in Langflow via API"""
api_key = await generate_langflow_api_key()
if not api_key:
logger.warning(
"Cannot create Langflow global variable: No API key", variable_name=name
)
return
url = f"{LANGFLOW_URL}/api/v1/variables/"
payload = { payload = {
"name": name, "name": name,
"value": value, "value": value,
"default_fields": [], "default_fields": [],
"type": "Credential", "type": "Credential",
} }
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
try: try:
async with httpx.AsyncClient() as client: response = await self.langflow_request(
response = await client.post(url, headers=headers, json=payload) "POST", "/api/v1/variables/", json=payload
)
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
logger.info(
"Successfully created Langflow global variable",
variable_name=name,
)
elif response.status_code == 400 and "already exists" in response.text:
if modify:
logger.info( logger.info(
"Successfully created Langflow global variable", "Langflow global variable already exists, attempting to update",
variable_name=name, variable_name=name,
) )
elif response.status_code == 400 and "already exists" in response.text: await self._update_langflow_global_variable(name, value)
if modify:
logger.info(
"Langflow global variable already exists, attempting to update",
variable_name=name,
)
await self._update_langflow_global_variable(name, value)
else:
logger.info(
"Langflow global variable already exists",
variable_name=name,
)
else: else:
logger.warning( logger.info(
"Failed to create Langflow global variable", "Langflow global variable already exists",
variable_name=name, variable_name=name,
status_code=response.status_code,
) )
else:
logger.warning(
"Failed to create Langflow global variable",
variable_name=name,
status_code=response.status_code,
)
except Exception as e: except Exception as e:
logger.error( logger.error(
"Exception creating Langflow global variable", "Exception creating Langflow global variable",
@ -635,76 +613,62 @@ class AppClients:
async def _update_langflow_global_variable(self, name: str, value: str): async def _update_langflow_global_variable(self, name: str, value: str):
"""Update an existing global variable in Langflow via API""" """Update an existing global variable in Langflow via API"""
api_key = await generate_langflow_api_key()
if not api_key:
logger.warning(
"Cannot update Langflow global variable: No API key", variable_name=name
)
return
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
try: try:
async with httpx.AsyncClient() as client: # First, get all variables to find the one with the matching name
# First, get all variables to find the one with the matching name get_response = await self.langflow_request("GET", "/api/v1/variables/")
get_response = await client.get(
f"{LANGFLOW_URL}/api/v1/variables/", headers=headers if get_response.status_code != 200:
logger.error(
"Failed to retrieve variables for update",
variable_name=name,
status_code=get_response.status_code,
) )
return
if get_response.status_code != 200: variables = get_response.json()
logger.error( target_variable = None
"Failed to retrieve variables for update",
variable_name=name,
status_code=get_response.status_code,
)
return
variables = get_response.json() # Find the variable with matching name
target_variable = None for variable in variables:
if variable.get("name") == name:
target_variable = variable
break
# Find the variable with matching name if not target_variable:
for variable in variables: logger.error("Variable not found for update", variable_name=name)
if variable.get("name") == name: return
target_variable = variable
break
if not target_variable: variable_id = target_variable.get("id")
logger.error("Variable not found for update", variable_name=name) if not variable_id:
return logger.error("Variable ID not found for update", variable_name=name)
return
variable_id = target_variable.get("id") # Update the variable using PATCH
if not variable_id: update_payload = {
logger.error("Variable ID not found for update", variable_name=name) "id": variable_id,
return "name": name,
"value": value,
"default_fields": target_variable.get("default_fields", []),
}
# Update the variable using PATCH patch_response = await self.langflow_request(
update_payload = { "PATCH", f"/api/v1/variables/{variable_id}", json=update_payload
"id": variable_id, )
"name": name,
"value": value,
"default_fields": target_variable.get("default_fields", []),
}
patch_response = await client.patch( if patch_response.status_code == 200:
f"{LANGFLOW_URL}/api/v1/variables/{variable_id}", logger.info(
headers=headers, "Successfully updated Langflow global variable",
json=update_payload, variable_name=name,
variable_id=variable_id,
)
else:
logger.warning(
"Failed to update Langflow global variable",
variable_name=name,
variable_id=variable_id,
status_code=patch_response.status_code,
response_text=patch_response.text,
) )
if patch_response.status_code == 200:
logger.info(
"Successfully updated Langflow global variable",
variable_name=name,
variable_id=variable_id,
)
else:
logger.warning(
"Failed to update Langflow global variable",
variable_name=name,
variable_id=variable_id,
status_code=patch_response.status_code,
response_text=patch_response.text,
)
except Exception as e: except Exception as e:
logger.error( logger.error(

View file

@ -1179,6 +1179,18 @@ async def create_app():
), ),
methods=["POST"], methods=["POST"],
), ),
# Onboarding rollback endpoint
Route(
"/onboarding/rollback",
require_auth(services["session_manager"])(
partial(
settings.rollback_onboarding,
session_manager=services["session_manager"],
task_service=services["task_service"],
)
),
methods=["POST"],
),
# Docling preset update endpoint # Docling preset update endpoint
Route( Route(
"/settings/docling-preset", "/settings/docling-preset",

View file

@ -595,7 +595,7 @@ class ChatService:
try: try:
# Delete from local conversation storage # Delete from local conversation storage
from agent import delete_user_conversation from agent import delete_user_conversation
local_deleted = delete_user_conversation(user_id, session_id) local_deleted = await delete_user_conversation(user_id, session_id)
# Delete from Langflow using the monitor API # Delete from Langflow using the monitor API
langflow_deleted = await self._delete_langflow_session(session_id) langflow_deleted = await self._delete_langflow_session(session_id)

View file

@ -5,6 +5,7 @@ Simple service to persist chat conversations to disk so they survive server rest
import json import json
import os import os
import asyncio
from typing import Dict, Any from typing import Dict, Any
from datetime import datetime from datetime import datetime
import threading import threading
@ -33,8 +34,8 @@ class ConversationPersistenceService:
return {} return {}
return {} return {}
def _save_conversations(self): def _save_conversations_sync(self):
"""Save conversations to disk""" """Synchronous save conversations to disk (runs in executor)"""
try: try:
with self.lock: with self.lock:
with open(self.storage_file, 'w', encoding='utf-8') as f: with open(self.storage_file, 'w', encoding='utf-8') as f:
@ -43,6 +44,12 @@ class ConversationPersistenceService:
except Exception as e: except Exception as e:
logger.error(f"Error saving conversations to {self.storage_file}: {e}") logger.error(f"Error saving conversations to {self.storage_file}: {e}")
async def _save_conversations(self):
"""Async save conversations to disk (non-blocking)"""
# Run the synchronous file I/O in a thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._save_conversations_sync)
def _count_total_conversations(self, data: Dict[str, Any]) -> int: def _count_total_conversations(self, data: Dict[str, Any]) -> int:
"""Count total conversations across all users""" """Count total conversations across all users"""
total = 0 total = 0
@ -68,8 +75,8 @@ class ConversationPersistenceService:
else: else:
return obj return obj
def store_conversation_thread(self, user_id: str, response_id: str, conversation_state: Dict[str, Any]): async def store_conversation_thread(self, user_id: str, response_id: str, conversation_state: Dict[str, Any]):
"""Store a conversation thread and persist to disk""" """Store a conversation thread and persist to disk (async, non-blocking)"""
if user_id not in self._conversations: if user_id not in self._conversations:
self._conversations[user_id] = {} self._conversations[user_id] = {}
@ -78,28 +85,28 @@ class ConversationPersistenceService:
self._conversations[user_id][response_id] = serialized_conversation self._conversations[user_id][response_id] = serialized_conversation
# Save to disk (we could optimize this with batching if needed) # Save to disk asynchronously (non-blocking)
self._save_conversations() await self._save_conversations()
def get_conversation_thread(self, user_id: str, response_id: str) -> Dict[str, Any]: def get_conversation_thread(self, user_id: str, response_id: str) -> Dict[str, Any]:
"""Get a specific conversation thread""" """Get a specific conversation thread"""
user_conversations = self.get_user_conversations(user_id) user_conversations = self.get_user_conversations(user_id)
return user_conversations.get(response_id, {}) return user_conversations.get(response_id, {})
def delete_conversation_thread(self, user_id: str, response_id: str) -> bool: async def delete_conversation_thread(self, user_id: str, response_id: str) -> bool:
"""Delete a specific conversation thread""" """Delete a specific conversation thread (async, non-blocking)"""
if user_id in self._conversations and response_id in self._conversations[user_id]: if user_id in self._conversations and response_id in self._conversations[user_id]:
del self._conversations[user_id][response_id] del self._conversations[user_id][response_id]
self._save_conversations() await self._save_conversations()
logger.debug(f"Deleted conversation {response_id} for user {user_id}") logger.debug(f"Deleted conversation {response_id} for user {user_id}")
return True return True
return False return False
def clear_user_conversations(self, user_id: str): async def clear_user_conversations(self, user_id: str):
"""Clear all conversations for a user""" """Clear all conversations for a user (async, non-blocking)"""
if user_id in self._conversations: if user_id in self._conversations:
del self._conversations[user_id] del self._conversations[user_id]
self._save_conversations() await self._save_conversations()
logger.debug(f"Cleared all conversations for user {user_id}") logger.debug(f"Cleared all conversations for user {user_id}")
def get_storage_stats(self) -> Dict[str, Any]: def get_storage_stats(self) -> Dict[str, Any]:

View file

@ -199,3 +199,5 @@ class MessageId:
ORB_ONBOARD_SAMPLE_DATA = "ORB_ONBOARD_SAMPLE_DATA" ORB_ONBOARD_SAMPLE_DATA = "ORB_ONBOARD_SAMPLE_DATA"
# Message: Configuration marked as edited # Message: Configuration marked as edited
ORB_ONBOARD_CONFIG_EDITED = "ORB_ONBOARD_CONFIG_EDITED" ORB_ONBOARD_CONFIG_EDITED = "ORB_ONBOARD_CONFIG_EDITED"
# Message: Onboarding rolled back due to all files failing
ORB_ONBOARD_ROLLBACK = "ORB_ONBOARD_ROLLBACK"