Merge branch 'main' into issue-454-install-docs
This commit is contained in:
commit
d8221cd2e4
28 changed files with 4338 additions and 2021 deletions
|
|
@ -865,58 +865,99 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
|
|||
metadatas.append(data_copy)
|
||||
self.log(metadatas)
|
||||
|
||||
# Generate embeddings (threaded for concurrency) with retries
|
||||
def embed_chunk(chunk_text: str) -> list[float]:
|
||||
return selected_embedding.embed_documents([chunk_text])[0]
|
||||
# Generate embeddings with rate-limit-aware retry logic using tenacity
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
vectors: list[list[float]] | None = None
|
||||
last_exception: Exception | None = None
|
||||
delay = 1.0
|
||||
attempts = 0
|
||||
max_attempts = 3
|
||||
def is_rate_limit_error(exception: Exception) -> bool:
|
||||
"""Check if exception is a rate limit error (429)."""
|
||||
error_str = str(exception).lower()
|
||||
return "429" in error_str or "rate_limit" in error_str or "rate limit" in error_str
|
||||
|
||||
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:
|
||||
# Restrict concurrency for IBM/Watsonx models to avoid rate limits
|
||||
is_ibm = (embedding_model and "ibm" in str(embedding_model).lower()) or (
|
||||
selected_embedding and "watsonx" in type(selected_embedding).__name__.lower()
|
||||
return _embed(chunk_text)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to embed chunk {chunk_idx} after all retries: {e}",
|
||||
error=str(e),
|
||||
)
|
||||
logger.debug(f"Is IBM: {is_ibm}")
|
||||
max_workers = 1 if is_ibm else min(max(len(texts), 1), 8)
|
||||
raise
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(embed_chunk, chunk): idx for idx, chunk in enumerate(texts)}
|
||||
vectors = [None] * len(texts)
|
||||
for future in as_completed(futures):
|
||||
idx = futures[future]
|
||||
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)
|
||||
# Restrict concurrency for IBM/Watsonx models to avoid rate limits
|
||||
is_ibm = (embedding_model and "ibm" in str(embedding_model).lower()) or (
|
||||
selected_embedding and "watsonx" in type(selected_embedding).__name__.lower()
|
||||
)
|
||||
logger.debug(f"Is IBM: {is_ibm}")
|
||||
|
||||
if vectors is None:
|
||||
raise RuntimeError(
|
||||
f"Embedding generation failed for {embedding_model}: {last_exception}"
|
||||
if last_exception
|
||||
else f"Embedding generation failed for {embedding_model}"
|
||||
# For IBM models, use sequential processing with rate limiting
|
||||
# For other models, use parallel processing
|
||||
vectors: list[list[float]] = [None] * len(texts)
|
||||
|
||||
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:
|
||||
self.log(f"No vectors generated from documents for model {embedding_model}.")
|
||||
return
|
||||
|
|
|
|||
1735
flows/components/opensearch_multimodel.py
Normal file
1735
flows/components/opensearch_multimodel.py
Normal file
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
44
frontend/app/api/mutations/useOnboardingRollbackMutation.ts
Normal file
44
frontend/app/api/mutations/useOnboardingRollbackMutation.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import type { EndpointType } from "@/contexts/chat-context";
|
||||
import { useChat } from "@/contexts/chat-context";
|
||||
|
||||
export interface RawConversation {
|
||||
response_id: string;
|
||||
|
|
@ -50,6 +51,7 @@ export const useGetConversationsQuery = (
|
|||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { isOnboardingComplete } = useChat();
|
||||
|
||||
async function getConversations(context: { signal?: AbortSignal }): Promise<ChatConversation[]> {
|
||||
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(
|
||||
{
|
||||
queryKey: ["conversations", endpoint, refreshTrigger],
|
||||
|
|
@ -106,6 +113,7 @@ export const useGetConversationsQuery = (
|
|||
refetchOnMount: false, // Don't refetch on every mount
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
...options,
|
||||
enabled, // Override enabled after spreading options to ensure onboarding check is applied
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useChat } from "@/contexts/chat-context";
|
||||
import { useProviderHealthQuery } from "./useProviderHealthQuery";
|
||||
|
||||
type Nudge = string;
|
||||
|
||||
|
|
@ -27,6 +29,13 @@ export const useGetNudgesQuery = (
|
|||
) => {
|
||||
const { chatId, filters, limit, scoreThreshold } = params ?? {};
|
||||
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() {
|
||||
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(
|
||||
{
|
||||
queryKey: ["nudges", chatId, filters, limit, scoreThreshold],
|
||||
|
|
@ -91,6 +105,7 @@ export const useGetNudgesQuery = (
|
|||
return Array.isArray(data) && data.length === 0 ? 5000 : false;
|
||||
},
|
||||
...options,
|
||||
enabled, // Override enabled after spreading options to ensure onboarding check is applied
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from "@tanstack/react-query";
|
||||
import { useChat } from "@/contexts/chat-context";
|
||||
import { useGetSettingsQuery } from "./useGetSettingsQuery";
|
||||
import { useGetTasksQuery } from "./useGetTasksQuery";
|
||||
|
||||
export interface ProviderHealthDetails {
|
||||
llm_model: string;
|
||||
|
|
@ -40,11 +41,20 @@ export const useProviderHealthQuery = (
|
|||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get chat error state from context (ChatProvider wraps the entire app in layout.tsx)
|
||||
const { hasChatError, setChatError } = useChat();
|
||||
// Get chat error state and onboarding completion from context (ChatProvider wraps the entire app in layout.tsx)
|
||||
const { hasChatError, setChatError, isOnboardingComplete } = useChat();
|
||||
|
||||
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> {
|
||||
try {
|
||||
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
|
||||
// Use the same testCompletion value that's in the queryKey
|
||||
const testCompletion = params?.test_completion ?? hasChatError;
|
||||
if (testCompletion) {
|
||||
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 queryResult = useQuery(
|
||||
|
|
@ -143,7 +157,11 @@ export const useProviderHealthQuery = (
|
|||
refetchOnWindowFocus: false, // Disabled to reduce unnecessary calls on tab switches
|
||||
refetchOnMount: true,
|
||||
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,
|
||||
},
|
||||
queryClient,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import IBMLogo from "@/components/icons/ibm-logo";
|
||||
import { LabelInput } from "@/components/label-input";
|
||||
import { LabelWrapper } from "@/components/label-wrapper";
|
||||
import IBMLogo from "@/components/icons/ibm-logo";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/lib/debounce";
|
||||
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
|
||||
|
|
@ -18,273 +18,273 @@ import { AdvancedOnboarding } from "./advanced";
|
|||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
export function IBMOnboarding({
|
||||
isEmbedding = false,
|
||||
setSettings,
|
||||
sampleDataset,
|
||||
setSampleDataset,
|
||||
setIsLoadingModels,
|
||||
alreadyConfigured = false,
|
||||
existingEndpoint,
|
||||
existingProjectId,
|
||||
hasEnvApiKey = false,
|
||||
isEmbedding = false,
|
||||
setSettings,
|
||||
sampleDataset,
|
||||
setSampleDataset,
|
||||
setIsLoadingModels,
|
||||
alreadyConfigured = false,
|
||||
existingEndpoint,
|
||||
existingProjectId,
|
||||
hasEnvApiKey = false,
|
||||
}: {
|
||||
isEmbedding?: boolean;
|
||||
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
|
||||
sampleDataset: boolean;
|
||||
setSampleDataset: (dataset: boolean) => void;
|
||||
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||
alreadyConfigured?: boolean;
|
||||
existingEndpoint?: string;
|
||||
existingProjectId?: string;
|
||||
hasEnvApiKey?: boolean;
|
||||
isEmbedding?: boolean;
|
||||
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
|
||||
sampleDataset: boolean;
|
||||
setSampleDataset: (dataset: boolean) => void;
|
||||
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||
alreadyConfigured?: boolean;
|
||||
existingEndpoint?: string;
|
||||
existingProjectId?: string;
|
||||
hasEnvApiKey?: boolean;
|
||||
}) {
|
||||
const [endpoint, setEndpoint] = useState(
|
||||
alreadyConfigured ? "" : (existingEndpoint || "https://us-south.ml.cloud.ibm.com"),
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [getFromEnv, setGetFromEnv] = useState(
|
||||
hasEnvApiKey && !alreadyConfigured,
|
||||
);
|
||||
const [projectId, setProjectId] = useState(
|
||||
alreadyConfigured ? "" : (existingProjectId || ""),
|
||||
);
|
||||
const [endpoint, setEndpoint] = useState(
|
||||
alreadyConfigured
|
||||
? ""
|
||||
: existingEndpoint || "https://us-south.ml.cloud.ibm.com",
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [getFromEnv, setGetFromEnv] = useState(
|
||||
hasEnvApiKey && !alreadyConfigured,
|
||||
);
|
||||
const [projectId, setProjectId] = useState(
|
||||
alreadyConfigured ? "" : existingProjectId || "",
|
||||
);
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "https://us-south.ml.cloud.ibm.com",
|
||||
label: "https://us-south.ml.cloud.ibm.com",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
value: "https://eu-de.ml.cloud.ibm.com",
|
||||
label: "https://eu-de.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://eu-gb.ml.cloud.ibm.com",
|
||||
label: "https://eu-gb.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://au-syd.ml.cloud.ibm.com",
|
||||
label: "https://au-syd.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://jp-tok.ml.cloud.ibm.com",
|
||||
label: "https://jp-tok.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://ca-tor.ml.cloud.ibm.com",
|
||||
label: "https://ca-tor.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
|
||||
const debouncedApiKey = useDebouncedValue(apiKey, 500);
|
||||
const debouncedProjectId = useDebouncedValue(projectId, 500);
|
||||
const options = [
|
||||
{
|
||||
value: "https://us-south.ml.cloud.ibm.com",
|
||||
label: "https://us-south.ml.cloud.ibm.com",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
value: "https://eu-de.ml.cloud.ibm.com",
|
||||
label: "https://eu-de.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://eu-gb.ml.cloud.ibm.com",
|
||||
label: "https://eu-gb.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://au-syd.ml.cloud.ibm.com",
|
||||
label: "https://au-syd.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://jp-tok.ml.cloud.ibm.com",
|
||||
label: "https://jp-tok.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
value: "https://ca-tor.ml.cloud.ibm.com",
|
||||
label: "https://ca-tor.ml.cloud.ibm.com",
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
|
||||
const debouncedApiKey = useDebouncedValue(apiKey, 500);
|
||||
const debouncedProjectId = useDebouncedValue(projectId, 500);
|
||||
|
||||
// Fetch models from API when all credentials are provided
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useGetIBMModelsQuery(
|
||||
{
|
||||
endpoint: debouncedEndpoint ? debouncedEndpoint : undefined,
|
||||
apiKey: getFromEnv ? "" : (debouncedApiKey ? debouncedApiKey : undefined),
|
||||
projectId: debouncedProjectId ? debouncedProjectId : undefined,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!debouncedEndpoint ||
|
||||
!!debouncedApiKey ||
|
||||
!!debouncedProjectId ||
|
||||
getFromEnv ||
|
||||
alreadyConfigured,
|
||||
},
|
||||
);
|
||||
// Fetch models from API when all credentials are provided
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useGetIBMModelsQuery(
|
||||
{
|
||||
endpoint: debouncedEndpoint ? debouncedEndpoint : undefined,
|
||||
apiKey: getFromEnv ? "" : debouncedApiKey ? debouncedApiKey : undefined,
|
||||
projectId: debouncedProjectId ? debouncedProjectId : undefined,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
(!!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId) ||
|
||||
getFromEnv ||
|
||||
alreadyConfigured,
|
||||
},
|
||||
);
|
||||
|
||||
// Use custom hook for model selection logic
|
||||
const {
|
||||
languageModel,
|
||||
embeddingModel,
|
||||
setLanguageModel,
|
||||
setEmbeddingModel,
|
||||
languageModels,
|
||||
embeddingModels,
|
||||
} = useModelSelection(modelsData, isEmbedding);
|
||||
// Use custom hook for model selection logic
|
||||
const {
|
||||
languageModel,
|
||||
embeddingModel,
|
||||
setLanguageModel,
|
||||
setEmbeddingModel,
|
||||
languageModels,
|
||||
embeddingModels,
|
||||
} = useModelSelection(modelsData, isEmbedding);
|
||||
|
||||
const handleGetFromEnvChange = (fromEnv: boolean) => {
|
||||
setGetFromEnv(fromEnv);
|
||||
if (fromEnv) {
|
||||
setApiKey("");
|
||||
}
|
||||
setEmbeddingModel?.("");
|
||||
setLanguageModel?.("");
|
||||
};
|
||||
const handleGetFromEnvChange = (fromEnv: boolean) => {
|
||||
setGetFromEnv(fromEnv);
|
||||
if (fromEnv) {
|
||||
setApiKey("");
|
||||
}
|
||||
setEmbeddingModel?.("");
|
||||
setLanguageModel?.("");
|
||||
};
|
||||
|
||||
const handleSampleDatasetChange = (dataset: boolean) => {
|
||||
setSampleDataset(dataset);
|
||||
};
|
||||
const handleSampleDatasetChange = (dataset: boolean) => {
|
||||
setSampleDataset(dataset);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingModels?.(isLoadingModels);
|
||||
}, [isLoadingModels, setIsLoadingModels]);
|
||||
useEffect(() => {
|
||||
setIsLoadingModels?.(isLoadingModels);
|
||||
}, [isLoadingModels, setIsLoadingModels]);
|
||||
|
||||
// Update settings when values change
|
||||
useUpdateSettings(
|
||||
"watsonx",
|
||||
{
|
||||
endpoint,
|
||||
apiKey,
|
||||
projectId,
|
||||
languageModel,
|
||||
embeddingModel,
|
||||
},
|
||||
setSettings,
|
||||
isEmbedding,
|
||||
);
|
||||
// Update settings when values change
|
||||
useUpdateSettings(
|
||||
"watsonx",
|
||||
{
|
||||
endpoint,
|
||||
apiKey,
|
||||
projectId,
|
||||
languageModel,
|
||||
embeddingModel,
|
||||
},
|
||||
setSettings,
|
||||
isEmbedding,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<LabelWrapper
|
||||
label="watsonx.ai API Endpoint"
|
||||
helperText="Base URL of the API"
|
||||
id="api-endpoint"
|
||||
required
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<ModelSelector
|
||||
options={alreadyConfigured ? [] : options}
|
||||
value={endpoint}
|
||||
custom
|
||||
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
|
||||
searchPlaceholder="Search endpoint..."
|
||||
noOptionsPlaceholder={
|
||||
alreadyConfigured
|
||||
? "https://•••••••••••••••••••••••••••••••••••••••••"
|
||||
: "No endpoints available"
|
||||
}
|
||||
placeholder="Select endpoint..."
|
||||
/>
|
||||
{alreadyConfigured && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Reusing endpoint from model provider selection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</LabelWrapper>
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<LabelWrapper
|
||||
label="watsonx.ai API Endpoint"
|
||||
helperText="Base URL of the API"
|
||||
id="api-endpoint"
|
||||
required
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<ModelSelector
|
||||
options={alreadyConfigured ? [] : options}
|
||||
value={endpoint}
|
||||
custom
|
||||
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
|
||||
searchPlaceholder="Search endpoint..."
|
||||
noOptionsPlaceholder={
|
||||
alreadyConfigured
|
||||
? "https://•••••••••••••••••••••••••••••••••••••••••"
|
||||
: "No endpoints available"
|
||||
}
|
||||
placeholder="Select endpoint..."
|
||||
/>
|
||||
{alreadyConfigured && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Reusing endpoint from model provider selection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</LabelWrapper>
|
||||
|
||||
<div className="space-y-1">
|
||||
<LabelInput
|
||||
label="watsonx Project ID"
|
||||
helperText="Project ID for the model"
|
||||
id="project-id"
|
||||
required
|
||||
placeholder={
|
||||
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
|
||||
}
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
disabled={alreadyConfigured}
|
||||
/>
|
||||
{alreadyConfigured && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Reusing project ID from model provider selection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<LabelWrapper
|
||||
label="Use environment watsonx API key"
|
||||
id="get-api-key"
|
||||
description="Reuse the key from your environment config. Turn off to enter a different key."
|
||||
flex
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={getFromEnv}
|
||||
onCheckedChange={handleGetFromEnvChange}
|
||||
disabled={!hasEnvApiKey || alreadyConfigured}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!hasEnvApiKey && !alreadyConfigured && (
|
||||
<TooltipContent>
|
||||
watsonx API key not detected in the environment.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</LabelWrapper>
|
||||
{!getFromEnv && !alreadyConfigured && (
|
||||
<div className="space-y-1">
|
||||
<LabelInput
|
||||
label="watsonx API key"
|
||||
helperText="API key to access watsonx.ai"
|
||||
className={modelsError ? "!border-destructive" : ""}
|
||||
id="api-key"
|
||||
type="password"
|
||||
required
|
||||
placeholder="your-api-key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{isLoadingModels && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Validating API key...
|
||||
</p>
|
||||
)}
|
||||
{modelsError && (
|
||||
<p className="text-mmd text-destructive">
|
||||
Invalid watsonx API key. Verify or replace the key.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{alreadyConfigured && (
|
||||
<div className="space-y-1">
|
||||
<LabelInput
|
||||
label="watsonx API key"
|
||||
helperText="API key to access watsonx.ai"
|
||||
id="api-key"
|
||||
type="password"
|
||||
required
|
||||
placeholder="•••••••••••••••••••••••••••••••••••••••••"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={true}
|
||||
/>
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Reusing API key from model provider selection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{getFromEnv && isLoadingModels && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Validating configuration...
|
||||
</p>
|
||||
)}
|
||||
{getFromEnv && modelsError && (
|
||||
<p className="text-mmd text-accent-amber-foreground">
|
||||
Connection failed. Check your configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AdvancedOnboarding
|
||||
icon={<IBMLogo className="w-4 h-4" />}
|
||||
languageModels={languageModels}
|
||||
embeddingModels={embeddingModels}
|
||||
languageModel={languageModel}
|
||||
embeddingModel={embeddingModel}
|
||||
sampleDataset={sampleDataset}
|
||||
setLanguageModel={setLanguageModel}
|
||||
setEmbeddingModel={setEmbeddingModel}
|
||||
setSampleDataset={handleSampleDatasetChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<div className="space-y-1">
|
||||
<LabelInput
|
||||
label="watsonx Project ID"
|
||||
helperText="Project ID for the model"
|
||||
id="project-id"
|
||||
required
|
||||
placeholder={
|
||||
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
|
||||
}
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
disabled={alreadyConfigured}
|
||||
/>
|
||||
{alreadyConfigured && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Reusing project ID from model provider selection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<LabelWrapper
|
||||
label="Use environment watsonx API key"
|
||||
id="get-api-key"
|
||||
description="Reuse the key from your environment config. Turn off to enter a different key."
|
||||
flex
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={getFromEnv}
|
||||
onCheckedChange={handleGetFromEnvChange}
|
||||
disabled={!hasEnvApiKey || alreadyConfigured}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!hasEnvApiKey && !alreadyConfigured && (
|
||||
<TooltipContent>
|
||||
watsonx API key not detected in the environment.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</LabelWrapper>
|
||||
{!getFromEnv && !alreadyConfigured && (
|
||||
<div className="space-y-1">
|
||||
<LabelInput
|
||||
label="watsonx API key"
|
||||
helperText="API key to access watsonx.ai"
|
||||
className={modelsError ? "!border-destructive" : ""}
|
||||
id="api-key"
|
||||
type="password"
|
||||
required
|
||||
placeholder="your-api-key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{isLoadingModels && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Validating API key...
|
||||
</p>
|
||||
)}
|
||||
{modelsError && (
|
||||
<p className="text-mmd text-destructive">
|
||||
Invalid watsonx API key. Verify or replace the key.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{alreadyConfigured && (
|
||||
<div className="space-y-1">
|
||||
<LabelInput
|
||||
label="watsonx API key"
|
||||
helperText="API key to access watsonx.ai"
|
||||
id="api-key"
|
||||
type="password"
|
||||
required
|
||||
placeholder="•••••••••••••••••••••••••••••••••••••••••"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={true}
|
||||
/>
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Reusing API key from model provider selection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{getFromEnv && isLoadingModels && (
|
||||
<p className="text-mmd text-muted-foreground">
|
||||
Validating configuration...
|
||||
</p>
|
||||
)}
|
||||
{getFromEnv && modelsError && (
|
||||
<p className="text-mmd text-accent-amber-foreground">
|
||||
Connection failed. Check your configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AdvancedOnboarding
|
||||
icon={<IBMLogo className="w-4 h-4" />}
|
||||
languageModels={languageModels}
|
||||
embeddingModels={embeddingModels}
|
||||
languageModel={languageModel}
|
||||
embeddingModel={embeddingModel}
|
||||
sampleDataset={sampleDataset}
|
||||
setLanguageModel={setLanguageModel}
|
||||
setEmbeddingModel={setEmbeddingModel}
|
||||
setSampleDataset={handleSampleDatasetChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type OnboardingVariables,
|
||||
useOnboardingMutation,
|
||||
} from "@/app/api/mutations/useOnboardingMutation";
|
||||
import { useOnboardingRollbackMutation } from "@/app/api/mutations/useOnboardingRollbackMutation";
|
||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
|
||||
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
|
||||
|
|
@ -170,12 +171,32 @@ const OnboardingCard = ({
|
|||
|
||||
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
|
||||
const { data: tasks } = useGetTasksQuery({
|
||||
enabled: currentStep !== null, // Only poll when onboarding has started
|
||||
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
|
||||
useEffect(() => {
|
||||
if (currentStep === null || !tasks || !isEmbedding) {
|
||||
|
|
@ -190,11 +211,86 @@ const OnboardingCard = ({
|
|||
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 (
|
||||
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
||||
tasks.length > 0 &&
|
||||
!isCompleted
|
||||
!isCompleted &&
|
||||
!taskWithFailedFile
|
||||
) {
|
||||
// Set to final step to show "Done"
|
||||
setCurrentStep(totalSteps);
|
||||
|
|
@ -203,7 +299,7 @@ const OnboardingCard = ({
|
|||
onComplete();
|
||||
}, 1000);
|
||||
}
|
||||
}, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps]);
|
||||
}, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps, rollbackMutation]);
|
||||
|
||||
// Mutations
|
||||
const onboardingMutation = useOnboardingMutation({
|
||||
|
|
@ -507,7 +603,7 @@ const OnboardingCard = ({
|
|||
hasEnvApiKey={
|
||||
currentSettings?.providers?.openai?.has_api_key === true
|
||||
}
|
||||
alreadyConfigured={providerAlreadyConfigured}
|
||||
alreadyConfigured={providerAlreadyConfigured && modelProvider === "openai"}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="watsonx">
|
||||
|
|
@ -517,7 +613,7 @@ const OnboardingCard = ({
|
|||
setSampleDataset={setSampleDataset}
|
||||
setIsLoadingModels={setIsLoadingModels}
|
||||
isEmbedding={isEmbedding}
|
||||
alreadyConfigured={providerAlreadyConfigured}
|
||||
alreadyConfigured={providerAlreadyConfigured && modelProvider === "watsonx"}
|
||||
existingEndpoint={currentSettings?.providers?.watsonx?.endpoint}
|
||||
existingProjectId={currentSettings?.providers?.watsonx?.project_id}
|
||||
hasEnvApiKey={currentSettings?.providers?.watsonx?.has_api_key === true}
|
||||
|
|
@ -530,7 +626,7 @@ const OnboardingCard = ({
|
|||
setSampleDataset={setSampleDataset}
|
||||
setIsLoadingModels={setIsLoadingModels}
|
||||
isEmbedding={isEmbedding}
|
||||
alreadyConfigured={providerAlreadyConfigured}
|
||||
alreadyConfigured={providerAlreadyConfigured && modelProvider === "ollama"}
|
||||
existingEndpoint={currentSettings?.providers?.ollama?.endpoint}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ONBOARDING_UPLOAD_STEPS_KEY,
|
||||
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
||||
ONBOARDING_UPLOAD_STEPS_KEY,
|
||||
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
||||
} from "@/lib/constants";
|
||||
import { uploadFile } from "@/lib/upload-utils";
|
||||
|
||||
interface OnboardingUploadProps {
|
||||
onComplete: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||
|
|
@ -21,6 +22,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
|||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState<number | null>(null);
|
||||
const [uploadedFilename, setUploadedFilename] = useState<string | null>(null);
|
||||
const [uploadedTaskId, setUploadedTaskId] = useState<string | null>(null);
|
||||
const [shouldCreateFilter, setShouldCreateFilter] = 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
|
||||
useEffect(() => {
|
||||
if (currentStep === null || !tasks) {
|
||||
if (currentStep === null || !tasks || !uploadedTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there are any active tasks (pending, running, or processing)
|
||||
const activeTasks = tasks.find(
|
||||
(task) =>
|
||||
task.status === "pending" ||
|
||||
task.status === "running" ||
|
||||
task.status === "processing",
|
||||
);
|
||||
// Find the task by task ID from the upload response
|
||||
const matchingTask = tasks.find((task) => task.task_id === uploadedTaskId);
|
||||
|
||||
// If no active tasks and we have more than 1 task (initial + new upload), complete it
|
||||
if (
|
||||
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
||||
tasks.length > 1
|
||||
) {
|
||||
// If no matching task found, wait for it to appear
|
||||
if (!matchingTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the matching task is still active (pending, running, or processing)
|
||||
const isTaskActive =
|
||||
matchingTask.status === "pending" ||
|
||||
matchingTask.status === "running" ||
|
||||
matchingTask.status === "processing";
|
||||
|
||||
// If task is completed or has processed files, complete the onboarding step
|
||||
if (!isTaskActive || (matchingTask.processed_files ?? 0) > 0) {
|
||||
// Set to final step to show "Done"
|
||||
setCurrentStep(STEP_LIST.length);
|
||||
|
||||
|
|
@ -91,6 +96,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
|||
icon: "file",
|
||||
});
|
||||
|
||||
// Wait for filter creation to complete before proceeding
|
||||
createFilterMutation
|
||||
.mutateAsync({
|
||||
name: displayName,
|
||||
|
|
@ -114,18 +120,36 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
|||
})
|
||||
.finally(() => {
|
||||
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 = () => {
|
||||
if (fileInputRef.current) {
|
||||
|
|
@ -144,6 +168,11 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
|||
const result = await uploadFile(file, true, true); // Pass createFilter=true
|
||||
console.log("Document upload task started successfully");
|
||||
|
||||
// Store task ID to track the specific upload task
|
||||
if (result.taskId) {
|
||||
setUploadedTaskId(result.taskId);
|
||||
}
|
||||
|
||||
// Store filename and createFilter flag in state to create filter after ingestion succeeds
|
||||
if (result.createFilter && result.filename) {
|
||||
setUploadedFilename(result.filename);
|
||||
|
|
@ -176,6 +205,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
|||
|
||||
// Reset on error
|
||||
setCurrentStep(null);
|
||||
setUploadedTaskId(null);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,7 @@ export function ChatRenderer({
|
|||
refreshConversations,
|
||||
startNewConversation,
|
||||
setConversationFilter,
|
||||
setCurrentConversationId,
|
||||
setPreviousResponseIds,
|
||||
setOnboardingComplete,
|
||||
} = useChat();
|
||||
|
||||
// Initialize onboarding state based on local storage and settings
|
||||
|
|
@ -170,12 +169,17 @@ export function ChatRenderer({
|
|||
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
|
||||
}
|
||||
|
||||
// Clear ALL conversation state so next message starts fresh
|
||||
await startNewConversation();
|
||||
// Mark onboarding as complete in context
|
||||
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);
|
||||
|
||||
// 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
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(ONBOARDING_OPENRAG_DOCS_FILTER_ID_KEY);
|
||||
|
|
@ -202,6 +206,8 @@ export function ChatRenderer({
|
|||
localStorage.removeItem(ONBOARDING_CARD_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
|
||||
storeDefaultFilterForNewConversations(false);
|
||||
setShowLayout(true);
|
||||
|
|
|
|||
|
|
@ -5,125 +5,131 @@ import { useRouter } from "next/navigation";
|
|||
import { useProviderHealthQuery } from "@/app/api/queries/useProviderHealthQuery";
|
||||
import type { ModelProvider } from "@/app/settings/_helpers/model-helpers";
|
||||
import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useChat } from "@/contexts/chat-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface ProviderHealthBannerProps {
|
||||
className?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Custom hook to check provider health status
|
||||
export function useProviderHealth() {
|
||||
const { hasChatError } = useChat();
|
||||
const {
|
||||
data: health,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
isError,
|
||||
} = useProviderHealthQuery({
|
||||
test_completion: hasChatError, // Use test_completion=true when chat errors occur
|
||||
});
|
||||
const { hasChatError } = useChat();
|
||||
const {
|
||||
data: health,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
isError,
|
||||
} = useProviderHealthQuery({
|
||||
test_completion: hasChatError, // Use test_completion=true when chat errors occur
|
||||
});
|
||||
|
||||
const isHealthy = health?.status === "healthy" && !isError;
|
||||
// Only consider unhealthy if backend is up but provider validation failed
|
||||
// Don't show banner if backend is unavailable
|
||||
const isUnhealthy =
|
||||
health?.status === "unhealthy" || health?.status === "error";
|
||||
const isBackendUnavailable =
|
||||
health?.status === "backend-unavailable" || isError;
|
||||
const isHealthy = health?.status === "healthy" && !isError;
|
||||
// Only consider unhealthy if backend is up but provider validation failed
|
||||
// Don't show banner if backend is unavailable
|
||||
const isUnhealthy =
|
||||
health?.status === "unhealthy" || health?.status === "error";
|
||||
const isBackendUnavailable =
|
||||
health?.status === "backend-unavailable" || isError;
|
||||
|
||||
return {
|
||||
health,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
isError,
|
||||
isHealthy,
|
||||
isUnhealthy,
|
||||
isBackendUnavailable,
|
||||
};
|
||||
return {
|
||||
health,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
isError,
|
||||
isHealthy,
|
||||
isUnhealthy,
|
||||
isBackendUnavailable,
|
||||
};
|
||||
}
|
||||
|
||||
const providerTitleMap: Record<ModelProvider, string> = {
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
ollama: "Ollama",
|
||||
watsonx: "IBM watsonx.ai",
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
ollama: "Ollama",
|
||||
watsonx: "IBM watsonx.ai",
|
||||
};
|
||||
|
||||
export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
|
||||
const { isLoading, isHealthy, isUnhealthy, health } = useProviderHealth();
|
||||
const router = useRouter();
|
||||
const { isLoading, isHealthy, isUnhealthy, health } = useProviderHealth();
|
||||
const router = useRouter();
|
||||
|
||||
// Only show banner when provider is unhealthy (not when backend is unavailable)
|
||||
if (isLoading || isHealthy) {
|
||||
return null;
|
||||
}
|
||||
// Only show banner when provider is unhealthy (not when backend is unavailable)
|
||||
if (isLoading || isHealthy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isUnhealthy) {
|
||||
const llmProvider = health?.llm_provider || health?.provider;
|
||||
const embeddingProvider = health?.embedding_provider;
|
||||
const llmError = health?.llm_error;
|
||||
const embeddingError = health?.embedding_error;
|
||||
if (isUnhealthy) {
|
||||
const llmProvider = health?.llm_provider || health?.provider;
|
||||
const embeddingProvider = health?.embedding_provider;
|
||||
const llmError = health?.llm_error;
|
||||
const embeddingError = health?.embedding_error;
|
||||
|
||||
// Determine which provider has the error
|
||||
let errorProvider: string | undefined;
|
||||
let errorMessage: string;
|
||||
// Determine which provider has the error
|
||||
let errorProvider: string | undefined;
|
||||
let errorMessage: string;
|
||||
|
||||
if (llmError && embeddingError) {
|
||||
// Both have errors - show combined message
|
||||
errorMessage = health?.message || "Provider validation failed";
|
||||
errorProvider = undefined; // Don't link to a specific provider
|
||||
} else if (llmError) {
|
||||
// Only LLM has error
|
||||
errorProvider = llmProvider;
|
||||
errorMessage = llmError;
|
||||
} else if (embeddingError) {
|
||||
// Only embedding has error
|
||||
errorProvider = embeddingProvider;
|
||||
errorMessage = embeddingError;
|
||||
} else {
|
||||
// Fallback to original message
|
||||
errorMessage = health?.message || "Provider validation failed";
|
||||
errorProvider = llmProvider;
|
||||
}
|
||||
if (llmError && embeddingError) {
|
||||
// Both have errors - check if they're the same
|
||||
if (llmError === embeddingError) {
|
||||
// Same error for both - show once
|
||||
errorMessage = llmError;
|
||||
} else {
|
||||
// Different errors - show both
|
||||
errorMessage = `${llmError}; ${embeddingError}`;
|
||||
}
|
||||
errorProvider = undefined; // Don't link to a specific provider
|
||||
} else if (llmError) {
|
||||
// Only LLM has error
|
||||
errorProvider = llmProvider;
|
||||
errorMessage = llmError;
|
||||
} else if (embeddingError) {
|
||||
// Only embedding has error
|
||||
errorProvider = embeddingProvider;
|
||||
errorMessage = embeddingError;
|
||||
} else {
|
||||
// Fallback to original message
|
||||
errorMessage = health?.message || "Provider validation failed";
|
||||
errorProvider = llmProvider;
|
||||
}
|
||||
|
||||
const providerTitle = errorProvider
|
||||
? providerTitleMap[errorProvider as ModelProvider] || errorProvider
|
||||
: "Provider";
|
||||
const providerTitle = errorProvider
|
||||
? providerTitleMap[errorProvider as ModelProvider] || errorProvider
|
||||
: "Provider";
|
||||
|
||||
const settingsUrl = errorProvider
|
||||
? `/settings?setup=${errorProvider}`
|
||||
: "/settings";
|
||||
const settingsUrl = errorProvider
|
||||
? `/settings?setup=${errorProvider}`
|
||||
: "/settings";
|
||||
|
||||
return (
|
||||
<Banner
|
||||
className={cn(
|
||||
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BannerIcon
|
||||
className="text-accent-red-foreground"
|
||||
icon={AlertTriangle}
|
||||
/>
|
||||
<BannerTitle className="font-medium flex items-center gap-2">
|
||||
{llmError && embeddingError ? (
|
||||
<>Provider errors - {errorMessage}</>
|
||||
) : (
|
||||
<>
|
||||
{providerTitle} error - {errorMessage}
|
||||
</>
|
||||
)}
|
||||
</BannerTitle>
|
||||
<Button size="sm" onClick={() => router.push(settingsUrl)}>
|
||||
Fix Setup
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Banner
|
||||
className={cn(
|
||||
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BannerIcon
|
||||
className="text-accent-red-foreground"
|
||||
icon={AlertTriangle}
|
||||
/>
|
||||
<BannerTitle className="font-medium flex items-center gap-2">
|
||||
{llmError && embeddingError ? (
|
||||
<>Provider errors - {errorMessage}</>
|
||||
) : (
|
||||
<>
|
||||
{providerTitle} error - {errorMessage}
|
||||
</>
|
||||
)}
|
||||
</BannerTitle>
|
||||
<Button size="sm" onClick={() => router.push(settingsUrl)}>
|
||||
Fix Setup
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ONBOARDING_STEP_KEY } from "@/lib/constants";
|
||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||
|
||||
export type EndpointType = "chat" | "langflow";
|
||||
|
||||
|
|
@ -81,6 +83,8 @@ interface ChatContextType {
|
|||
setConversationFilter: (filter: KnowledgeFilter | null, responseId?: string | null) => void;
|
||||
hasChatError: boolean;
|
||||
setChatError: (hasError: boolean) => void;
|
||||
isOnboardingComplete: boolean;
|
||||
setOnboardingComplete: (complete: boolean) => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
|
@ -111,6 +115,46 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||
const [conversationFilter, setConversationFilterState] =
|
||||
useState<KnowledgeFilter | null>(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -228,6 +272,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||
const startNewConversation = useCallback(async () => {
|
||||
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
|
||||
setCurrentConversationId(null);
|
||||
setPreviousResponseIds({ chat: null, langflow: null });
|
||||
|
|
@ -261,15 +309,22 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||
setConversationFilterState(null);
|
||||
}
|
||||
} else {
|
||||
console.log("[CONVERSATION] No default filter set");
|
||||
setConversationFilterState(null);
|
||||
// No default filter in localStorage
|
||||
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
|
||||
const placeholderConversation: ConversationData = {
|
||||
const newPlaceholderConversation: ConversationData = {
|
||||
response_id: "new-conversation-" + Date.now(),
|
||||
title: "New conversation",
|
||||
endpoint: endpoint,
|
||||
|
|
@ -284,10 +339,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||
last_activity: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setPlaceholderConversation(placeholderConversation);
|
||||
setPlaceholderConversation(newPlaceholderConversation);
|
||||
// Force immediate refresh to ensure sidebar shows correct state
|
||||
refreshConversations(true);
|
||||
}, [endpoint, refreshConversations]);
|
||||
}, [endpoint, refreshConversations, conversationData, placeholderConversation]);
|
||||
|
||||
const addConversationDoc = useCallback((filename: string) => {
|
||||
setConversationDocs((prev) => [
|
||||
|
|
@ -375,6 +430,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||
setConversationFilter,
|
||||
hasChatError,
|
||||
setChatError,
|
||||
isOnboardingComplete,
|
||||
setOnboardingComplete,
|
||||
}),
|
||||
[
|
||||
endpoint,
|
||||
|
|
@ -396,6 +453,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||
conversationFilter,
|
||||
setConversationFilter,
|
||||
hasChatError,
|
||||
isOnboardingComplete,
|
||||
setOnboardingComplete,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface UploadFileResult {
|
|||
raw: unknown;
|
||||
createFilter?: boolean;
|
||||
filename?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export async function duplicateCheck(
|
||||
|
|
@ -158,6 +159,7 @@ export async function uploadFile(
|
|||
(uploadIngestJson as { upload?: { id?: string } }).upload?.id ||
|
||||
(uploadIngestJson as { id?: string }).id ||
|
||||
(uploadIngestJson as { task_id?: string }).task_id;
|
||||
const taskId = (uploadIngestJson as { task_id?: string }).task_id;
|
||||
const filePath =
|
||||
(uploadIngestJson as { upload?: { path?: string } }).upload?.path ||
|
||||
(uploadIngestJson as { path?: string }).path ||
|
||||
|
|
@ -197,6 +199,7 @@ export async function uploadFile(
|
|||
raw: uploadIngestJson,
|
||||
createFilter: shouldCreateFilter,
|
||||
filename,
|
||||
taskId,
|
||||
};
|
||||
|
||||
return result;
|
||||
|
|
|
|||
130
frontend/package-lock.json
generated
130
frontend/package-lock.json
generated
|
|
@ -38,7 +38,7 @@
|
|||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.525.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.3.5",
|
||||
"next": "15.5.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -1169,9 +1169,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz",
|
||||
"integrity": "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
|
|
@ -1185,9 +1185,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
|
||||
"integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
|
||||
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1201,9 +1201,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
|
||||
"integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
|
||||
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1217,9 +1217,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
|
||||
"integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
|
||||
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1233,9 +1233,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
|
||||
"integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
|
||||
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1249,9 +1249,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
|
||||
"integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
|
||||
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1265,9 +1265,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
|
||||
"integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
|
||||
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1281,9 +1281,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
|
||||
"integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
|
||||
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1297,9 +1297,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
|
||||
"integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
|
||||
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2568,12 +2568,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.5.15",
|
||||
"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_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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
|
|
@ -5448,9 +5431,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
|
|
@ -6584,9 +6567,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -7194,9 +7177,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mdast-util-to-hast": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
||||
"version": "13.2.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/mdast": "^4.0.0",
|
||||
|
|
@ -7973,15 +7957,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.3.5.tgz",
|
||||
"integrity": "sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.3.5",
|
||||
"@swc/counter": "0.1.3",
|
||||
"@next/env": "15.5.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
|
|
@ -7993,19 +7975,19 @@
|
|||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.3.5",
|
||||
"@next/swc-darwin-x64": "15.3.5",
|
||||
"@next/swc-linux-arm64-gnu": "15.3.5",
|
||||
"@next/swc-linux-arm64-musl": "15.3.5",
|
||||
"@next/swc-linux-x64-gnu": "15.3.5",
|
||||
"@next/swc-linux-x64-musl": "15.3.5",
|
||||
"@next/swc-win32-arm64-msvc": "15.3.5",
|
||||
"@next/swc-win32-x64-msvc": "15.3.5",
|
||||
"sharp": "^0.34.1"
|
||||
"@next/swc-darwin-arm64": "15.5.7",
|
||||
"@next/swc-darwin-x64": "15.5.7",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.7",
|
||||
"@next/swc-linux-arm64-musl": "15.5.7",
|
||||
"@next/swc-linux-x64-gnu": "15.5.7",
|
||||
"@next/swc-linux-x64-musl": "15.5.7",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.7",
|
||||
"@next/swc-win32-x64-msvc": "15.5.7",
|
||||
"sharp": "^0.34.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"babel-plugin-react-compiler": "*",
|
||||
"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",
|
||||
|
|
@ -9492,14 +9474,6 @@
|
|||
"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": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.525.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.3.5",
|
||||
"next": "15.5.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
|
|||
20
src/agent.py
20
src/agent.py
|
|
@ -47,8 +47,8 @@ def get_conversation_thread(user_id: str, previous_response_id: str = None):
|
|||
return new_conversation
|
||||
|
||||
|
||||
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"""
|
||||
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 (async, non-blocking)"""
|
||||
# 1. Store full conversation in memory for function call preservation
|
||||
if user_id not in active_conversations:
|
||||
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
|
||||
}
|
||||
|
||||
conversation_persistence.store_conversation_thread(
|
||||
await conversation_persistence.store_conversation_thread(
|
||||
user_id, response_id, metadata_only
|
||||
)
|
||||
|
||||
|
|
@ -382,7 +382,7 @@ async def async_chat(
|
|||
# Store the conversation thread with its response_id
|
||||
if response_id:
|
||||
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(
|
||||
"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
|
||||
if response_id:
|
||||
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(
|
||||
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
|
||||
if response_id:
|
||||
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
|
||||
try:
|
||||
|
|
@ -656,7 +656,7 @@ async def async_langflow_chat_stream(
|
|||
# Store the conversation thread with its response_id
|
||||
if response_id:
|
||||
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
|
||||
try:
|
||||
|
|
@ -672,8 +672,8 @@ async def async_langflow_chat_stream(
|
|||
)
|
||||
|
||||
|
||||
def delete_user_conversation(user_id: str, response_id: str) -> bool:
|
||||
"""Delete a conversation for a user from both memory and persistent storage"""
|
||||
async def delete_user_conversation(user_id: str, response_id: str) -> bool:
|
||||
"""Delete a conversation for a user from both memory and persistent storage (async, non-blocking)"""
|
||||
deleted = False
|
||||
|
||||
try:
|
||||
|
|
@ -684,7 +684,7 @@ def delete_user_conversation(user_id: str, response_id: str) -> bool:
|
|||
deleted = True
|
||||
|
||||
# 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:
|
||||
logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}")
|
||||
deleted = True
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Provider validation utilities for testing API keys and models during onboarding."""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from utils.container_utils import transform_localhost_url
|
||||
from utils.logging_config import get_logger
|
||||
|
|
@ -7,6 +8,106 @@ from utils.logging_config import get_logger
|
|||
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(
|
||||
provider: str,
|
||||
api_key: str = None,
|
||||
|
|
@ -30,7 +131,7 @@ async def validate_provider_setup(
|
|||
If False, performs lightweight validation (no credits consumed). Default: False.
|
||||
|
||||
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()
|
||||
|
||||
|
|
@ -70,7 +171,8 @@ async def validate_provider_setup(
|
|||
|
||||
except Exception as 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(
|
||||
|
|
@ -155,8 +257,9 @@ async def _test_openai_lightweight_health(api_key: str) -> None:
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"OpenAI lightweight health check failed: {response.status_code}")
|
||||
raise Exception(f"OpenAI API key validation failed: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
@ -225,8 +328,9 @@ async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> No
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"OpenAI completion test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"OpenAI API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
@ -260,8 +364,9 @@ async def _test_openai_embedding(api_key: str, embedding_model: str) -> None:
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"OpenAI embedding test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"OpenAI API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
logger.error(f"OpenAI embedding test failed: {response.status_code} - {error_details}")
|
||||
raise Exception(f"OpenAI API error: {error_details}")
|
||||
|
||||
data = response.json()
|
||||
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:
|
||||
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
|
||||
raise Exception("Failed to authenticate with IBM Watson - invalid API key")
|
||||
error_details = _extract_error_details(token_response)
|
||||
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")
|
||||
if not bearer_token:
|
||||
|
|
@ -335,8 +441,9 @@ async def _test_watsonx_completion_with_tools(
|
|||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
|
||||
raise Exception("Failed to authenticate with IBM Watson")
|
||||
error_details = _extract_error_details(token_response)
|
||||
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")
|
||||
if not bearer_token:
|
||||
|
|
@ -388,8 +495,11 @@ async def _test_watsonx_completion_with_tools(
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"IBM Watson completion test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"IBM Watson API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
@ -398,6 +508,13 @@ async def _test_watsonx_completion_with_tools(
|
|||
raise Exception("Request timed out")
|
||||
except Exception as 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
|
||||
|
||||
|
||||
|
|
@ -419,8 +536,9 @@ async def _test_watsonx_embedding(
|
|||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
|
||||
raise Exception("Failed to authenticate with IBM Watson")
|
||||
error_details = _extract_error_details(token_response)
|
||||
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")
|
||||
if not bearer_token:
|
||||
|
|
@ -450,8 +568,11 @@ async def _test_watsonx_embedding(
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"IBM Watson embedding test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"IBM Watson API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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()
|
||||
if not data.get("results") or len(data["results"]) == 0:
|
||||
|
|
@ -464,6 +585,13 @@ async def _test_watsonx_embedding(
|
|||
raise Exception("Request timed out")
|
||||
except Exception as 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
|
||||
|
||||
|
||||
|
|
@ -483,8 +611,9 @@ async def _test_ollama_lightweight_health(endpoint: str) -> None:
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Ollama lightweight health check failed: {response.status_code}")
|
||||
raise Exception(f"Ollama endpoint not responding: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
@ -537,8 +666,9 @@ async def _test_ollama_completion_with_tools(llm_model: str, endpoint: str) -> N
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Ollama completion test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"Ollama API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
@ -569,8 +699,9 @@ async def _test_ollama_embedding(embedding_model: str, endpoint: str) -> None:
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Ollama embedding test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"Ollama API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
logger.error(f"Ollama embedding test failed: {response.status_code} - {error_details}")
|
||||
raise Exception(f"Ollama API error: {error_details}")
|
||||
|
||||
data = response.json()
|
||||
if not data.get("embedding"):
|
||||
|
|
@ -616,8 +747,9 @@ async def _test_anthropic_lightweight_health(api_key: str) -> None:
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Anthropic lightweight health check failed: {response.status_code}")
|
||||
raise Exception(f"Anthropic API key validation failed: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
@ -672,8 +804,9 @@ async def _test_anthropic_completion_with_tools(api_key: str, llm_model: str) ->
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Anthropic completion test failed: {response.status_code} - {response.text}")
|
||||
raise Exception(f"Anthropic API error: {response.status_code}")
|
||||
error_details = _extract_error_details(response)
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -897,7 +897,7 @@ async def onboarding(request, flows_service, session_manager=None):
|
|||
)
|
||||
|
||||
# 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:
|
||||
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_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(
|
||||
provider=llm_provider,
|
||||
api_key=getattr(llm_provider_config, "api_key", None),
|
||||
llm_model=current_config.agent.llm_model,
|
||||
endpoint=getattr(llm_provider_config, "endpoint", 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}")
|
||||
|
||||
|
|
@ -922,14 +922,14 @@ async def onboarding(request, flows_service, session_manager=None):
|
|||
embedding_provider = current_config.knowledge.embedding_provider.lower()
|
||||
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(
|
||||
provider=embedding_provider,
|
||||
api_key=getattr(embedding_provider_config, "api_key", None),
|
||||
embedding_model=current_config.knowledge.embedding_model,
|
||||
endpoint=getattr(embedding_provider_config, "endpoint", 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}")
|
||||
except Exception as e:
|
||||
|
|
@ -1403,6 +1403,139 @@ async def reapply_all_settings(session_manager = None):
|
|||
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):
|
||||
"""Update docling settings in the ingest flow - deprecated endpoint, use /settings instead"""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
|
|
@ -140,61 +141,29 @@ INDEX_BODY = {
|
|||
LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1"
|
||||
|
||||
|
||||
async def generate_langflow_api_key(modify: bool = False):
|
||||
"""Generate Langflow API key using superuser credentials at startup"""
|
||||
async def get_langflow_api_key(force_regenerate: bool = False):
|
||||
"""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
|
||||
|
||||
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 LANGFLOW_KEY:
|
||||
if os.getenv("LANGFLOW_KEY"):
|
||||
logger.info("Using LANGFLOW_KEY from environment; skipping generation")
|
||||
return LANGFLOW_KEY
|
||||
else:
|
||||
# We have a cached key, but let's validate it first
|
||||
logger.debug("Validating cached LANGFLOW_KEY", key_prefix=LANGFLOW_KEY[:8])
|
||||
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
|
||||
# If we have a cached key and not forcing regeneration, return it
|
||||
if LANGFLOW_KEY and not force_regenerate:
|
||||
return LANGFLOW_KEY
|
||||
|
||||
# If forcing regeneration, clear the cached key
|
||||
if force_regenerate and LANGFLOW_KEY:
|
||||
logger.info("Forcing Langflow API key regeneration due to auth failure")
|
||||
LANGFLOW_KEY = None
|
||||
|
||||
# Use default langflow/langflow credentials if auto-login is enabled and credentials not set
|
||||
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"))
|
||||
delay_seconds = float(os.getenv("LANGFLOW_KEY_RETRY_DELAY", "2.0"))
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
# Login to get access token
|
||||
login_response = requests.post(
|
||||
f"{LANGFLOW_URL}/api/v1/login",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"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],
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
# Login to get access token
|
||||
login_response = await client.post(
|
||||
f"{LANGFLOW_URL}/api/v1/login",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"username": username,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
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 (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
|
||||
login_response.raise_for_status()
|
||||
access_token = login_response.json().get("access_token")
|
||||
if not access_token:
|
||||
raise KeyError("access_token")
|
||||
|
||||
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))
|
||||
return None
|
||||
except KeyError as e:
|
||||
|
|
@ -303,7 +270,7 @@ class AppClients:
|
|||
|
||||
async def initialize(self):
|
||||
# Generate Langflow API key first
|
||||
await generate_langflow_api_key()
|
||||
await get_langflow_api_key()
|
||||
|
||||
# Initialize OpenSearch client
|
||||
self.opensearch = AsyncOpenSearch(
|
||||
|
|
@ -362,7 +329,7 @@ class AppClients:
|
|||
if self.langflow_client is not None:
|
||||
return self.langflow_client
|
||||
# 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:
|
||||
try:
|
||||
self.langflow_client = AsyncOpenAI(
|
||||
|
|
@ -559,8 +526,11 @@ class AppClients:
|
|||
self.langflow_client = None
|
||||
|
||||
async def langflow_request(self, method: str, endpoint: str, **kwargs):
|
||||
"""Central method for all Langflow API requests"""
|
||||
api_key = await generate_langflow_api_key()
|
||||
"""Central method for all Langflow API requests.
|
||||
|
||||
Retries once with a fresh API key on auth failures (401/403).
|
||||
"""
|
||||
api_key = await get_langflow_api_key()
|
||||
if not api_key:
|
||||
raise ValueError("No Langflow API key available")
|
||||
|
||||
|
|
@ -575,57 +545,65 @@ class AppClients:
|
|||
|
||||
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
|
||||
)
|
||||
|
||||
# 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(
|
||||
self, name: str, value: str, modify: bool = False
|
||||
):
|
||||
"""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 = {
|
||||
"name": name,
|
||||
"value": value,
|
||||
"default_fields": [],
|
||||
"type": "Credential",
|
||||
}
|
||||
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response = await self.langflow_request(
|
||||
"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(
|
||||
"Successfully created Langflow global variable",
|
||||
"Langflow global variable already exists, attempting to update",
|
||||
variable_name=name,
|
||||
)
|
||||
elif response.status_code == 400 and "already exists" in response.text:
|
||||
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,
|
||||
)
|
||||
await self._update_langflow_global_variable(name, value)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to create Langflow global variable",
|
||||
logger.info(
|
||||
"Langflow global variable already exists",
|
||||
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:
|
||||
logger.error(
|
||||
"Exception creating Langflow global variable",
|
||||
|
|
@ -635,76 +613,62 @@ class AppClients:
|
|||
|
||||
async def _update_langflow_global_variable(self, name: str, value: str):
|
||||
"""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:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# First, get all variables to find the one with the matching name
|
||||
get_response = await client.get(
|
||||
f"{LANGFLOW_URL}/api/v1/variables/", headers=headers
|
||||
# First, get all variables to find the one with the matching name
|
||||
get_response = await self.langflow_request("GET", "/api/v1/variables/")
|
||||
|
||||
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:
|
||||
logger.error(
|
||||
"Failed to retrieve variables for update",
|
||||
variable_name=name,
|
||||
status_code=get_response.status_code,
|
||||
)
|
||||
return
|
||||
variables = get_response.json()
|
||||
target_variable = None
|
||||
|
||||
variables = get_response.json()
|
||||
target_variable = None
|
||||
# Find the variable with matching name
|
||||
for variable in variables:
|
||||
if variable.get("name") == name:
|
||||
target_variable = variable
|
||||
break
|
||||
|
||||
# Find the variable with matching name
|
||||
for variable in variables:
|
||||
if variable.get("name") == name:
|
||||
target_variable = variable
|
||||
break
|
||||
if not target_variable:
|
||||
logger.error("Variable not found for update", variable_name=name)
|
||||
return
|
||||
|
||||
if not target_variable:
|
||||
logger.error("Variable not found for update", variable_name=name)
|
||||
return
|
||||
variable_id = target_variable.get("id")
|
||||
if not variable_id:
|
||||
logger.error("Variable ID not found for update", variable_name=name)
|
||||
return
|
||||
|
||||
variable_id = target_variable.get("id")
|
||||
if not variable_id:
|
||||
logger.error("Variable ID not found for update", variable_name=name)
|
||||
return
|
||||
# Update the variable using PATCH
|
||||
update_payload = {
|
||||
"id": variable_id,
|
||||
"name": name,
|
||||
"value": value,
|
||||
"default_fields": target_variable.get("default_fields", []),
|
||||
}
|
||||
|
||||
# Update the variable using PATCH
|
||||
update_payload = {
|
||||
"id": variable_id,
|
||||
"name": name,
|
||||
"value": value,
|
||||
"default_fields": target_variable.get("default_fields", []),
|
||||
}
|
||||
patch_response = await self.langflow_request(
|
||||
"PATCH", f"/api/v1/variables/{variable_id}", json=update_payload
|
||||
)
|
||||
|
||||
patch_response = await client.patch(
|
||||
f"{LANGFLOW_URL}/api/v1/variables/{variable_id}",
|
||||
headers=headers,
|
||||
json=update_payload,
|
||||
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,
|
||||
)
|
||||
|
||||
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:
|
||||
logger.error(
|
||||
|
|
|
|||
12
src/main.py
12
src/main.py
|
|
@ -1179,6 +1179,18 @@ async def create_app():
|
|||
),
|
||||
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
|
||||
Route(
|
||||
"/settings/docling-preset",
|
||||
|
|
|
|||
|
|
@ -595,7 +595,7 @@ class ChatService:
|
|||
try:
|
||||
# Delete from local conversation storage
|
||||
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
|
||||
langflow_deleted = await self._delete_langflow_session(session_id)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Simple service to persist chat conversations to disk so they survive server rest
|
|||
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
import threading
|
||||
|
|
@ -33,8 +34,8 @@ class ConversationPersistenceService:
|
|||
return {}
|
||||
return {}
|
||||
|
||||
def _save_conversations(self):
|
||||
"""Save conversations to disk"""
|
||||
def _save_conversations_sync(self):
|
||||
"""Synchronous save conversations to disk (runs in executor)"""
|
||||
try:
|
||||
with self.lock:
|
||||
with open(self.storage_file, 'w', encoding='utf-8') as f:
|
||||
|
|
@ -43,6 +44,12 @@ class ConversationPersistenceService:
|
|||
except Exception as 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:
|
||||
"""Count total conversations across all users"""
|
||||
total = 0
|
||||
|
|
@ -68,8 +75,8 @@ class ConversationPersistenceService:
|
|||
else:
|
||||
return obj
|
||||
|
||||
def store_conversation_thread(self, user_id: str, response_id: str, conversation_state: Dict[str, Any]):
|
||||
"""Store a conversation thread and persist to disk"""
|
||||
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 (async, non-blocking)"""
|
||||
if user_id not in self._conversations:
|
||||
self._conversations[user_id] = {}
|
||||
|
||||
|
|
@ -78,28 +85,28 @@ class ConversationPersistenceService:
|
|||
|
||||
self._conversations[user_id][response_id] = serialized_conversation
|
||||
|
||||
# Save to disk (we could optimize this with batching if needed)
|
||||
self._save_conversations()
|
||||
# Save to disk asynchronously (non-blocking)
|
||||
await self._save_conversations()
|
||||
|
||||
def get_conversation_thread(self, user_id: str, response_id: str) -> Dict[str, Any]:
|
||||
"""Get a specific conversation thread"""
|
||||
user_conversations = self.get_user_conversations(user_id)
|
||||
return user_conversations.get(response_id, {})
|
||||
|
||||
def delete_conversation_thread(self, user_id: str, response_id: str) -> bool:
|
||||
"""Delete a specific conversation thread"""
|
||||
async def delete_conversation_thread(self, user_id: str, response_id: str) -> bool:
|
||||
"""Delete a specific conversation thread (async, non-blocking)"""
|
||||
if user_id in self._conversations and response_id in self._conversations[user_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}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear_user_conversations(self, user_id: str):
|
||||
"""Clear all conversations for a user"""
|
||||
async def clear_user_conversations(self, user_id: str):
|
||||
"""Clear all conversations for a user (async, non-blocking)"""
|
||||
if user_id in self._conversations:
|
||||
del self._conversations[user_id]
|
||||
self._save_conversations()
|
||||
await self._save_conversations()
|
||||
logger.debug(f"Cleared all conversations for user {user_id}")
|
||||
|
||||
def get_storage_stats(self) -> Dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -199,3 +199,5 @@ class MessageId:
|
|||
ORB_ONBOARD_SAMPLE_DATA = "ORB_ONBOARD_SAMPLE_DATA"
|
||||
# Message: Configuration marked as edited
|
||||
ORB_ONBOARD_CONFIG_EDITED = "ORB_ONBOARD_CONFIG_EDITED"
|
||||
# Message: Onboarding rolled back due to all files failing
|
||||
ORB_ONBOARD_ROLLBACK = "ORB_ONBOARD_ROLLBACK"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue