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)
|
metadatas.append(data_copy)
|
||||||
self.log(metadatas)
|
self.log(metadatas)
|
||||||
|
|
||||||
# Generate embeddings (threaded for concurrency) with retries
|
# Generate embeddings with rate-limit-aware retry logic using tenacity
|
||||||
def embed_chunk(chunk_text: str) -> list[float]:
|
from tenacity import (
|
||||||
return selected_embedding.embed_documents([chunk_text])[0]
|
retry,
|
||||||
|
retry_if_exception,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
vectors: list[list[float]] | None = None
|
def is_rate_limit_error(exception: Exception) -> bool:
|
||||||
last_exception: Exception | None = None
|
"""Check if exception is a rate limit error (429)."""
|
||||||
delay = 1.0
|
error_str = str(exception).lower()
|
||||||
attempts = 0
|
return "429" in error_str or "rate_limit" in error_str or "rate limit" in error_str
|
||||||
max_attempts = 3
|
|
||||||
|
def is_other_retryable_error(exception: Exception) -> bool:
|
||||||
|
"""Check if exception is retryable but not a rate limit error."""
|
||||||
|
# Retry on most exceptions except for specific non-retryable ones
|
||||||
|
# Add other non-retryable exceptions here if needed
|
||||||
|
return not is_rate_limit_error(exception)
|
||||||
|
|
||||||
|
# Create retry decorator for rate limit errors (longer backoff)
|
||||||
|
retry_on_rate_limit = retry(
|
||||||
|
retry=retry_if_exception(is_rate_limit_error),
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||||
|
reraise=True,
|
||||||
|
before_sleep=lambda retry_state: logger.warning(
|
||||||
|
f"Rate limit hit for chunk (attempt {retry_state.attempt_number}/5), "
|
||||||
|
f"backing off for {retry_state.next_action.sleep:.1f}s"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create retry decorator for other errors (shorter backoff)
|
||||||
|
retry_on_other_errors = retry(
|
||||||
|
retry=retry_if_exception(is_other_retryable_error),
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=8),
|
||||||
|
reraise=True,
|
||||||
|
before_sleep=lambda retry_state: logger.warning(
|
||||||
|
f"Error embedding chunk (attempt {retry_state.attempt_number}/3), "
|
||||||
|
f"retrying in {retry_state.next_action.sleep:.1f}s: {retry_state.outcome.exception()}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def embed_chunk_with_retry(chunk_text: str, chunk_idx: int) -> list[float]:
|
||||||
|
"""Embed a single chunk with rate-limit-aware retry logic."""
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
|
@retry_on_other_errors
|
||||||
|
def _embed(text: str) -> list[float]:
|
||||||
|
return selected_embedding.embed_documents([text])[0]
|
||||||
|
|
||||||
while attempts < max_attempts:
|
|
||||||
attempts += 1
|
|
||||||
try:
|
try:
|
||||||
# Restrict concurrency for IBM/Watsonx models to avoid rate limits
|
return _embed(chunk_text)
|
||||||
is_ibm = (embedding_model and "ibm" in str(embedding_model).lower()) or (
|
except Exception as e:
|
||||||
selected_embedding and "watsonx" in type(selected_embedding).__name__.lower()
|
logger.error(
|
||||||
|
f"Failed to embed chunk {chunk_idx} after all retries: {e}",
|
||||||
|
error=str(e),
|
||||||
)
|
)
|
||||||
logger.debug(f"Is IBM: {is_ibm}")
|
raise
|
||||||
max_workers = 1 if is_ibm else min(max(len(texts), 1), 8)
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
# Restrict concurrency for IBM/Watsonx models to avoid rate limits
|
||||||
futures = {executor.submit(embed_chunk, chunk): idx for idx, chunk in enumerate(texts)}
|
is_ibm = (embedding_model and "ibm" in str(embedding_model).lower()) or (
|
||||||
vectors = [None] * len(texts)
|
selected_embedding and "watsonx" in type(selected_embedding).__name__.lower()
|
||||||
for future in as_completed(futures):
|
)
|
||||||
idx = futures[future]
|
logger.debug(f"Is IBM: {is_ibm}")
|
||||||
vectors[idx] = future.result()
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
last_exception = exc
|
|
||||||
if attempts >= max_attempts:
|
|
||||||
logger.error(
|
|
||||||
f"Embedding generation failed for model {embedding_model} after retries",
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"Threaded embedding generation failed for model %s (attempt %s/%s), retrying in %.1fs",
|
|
||||||
embedding_model,
|
|
||||||
attempts,
|
|
||||||
max_attempts,
|
|
||||||
delay,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
delay = min(delay * 2, 8.0)
|
|
||||||
|
|
||||||
if vectors is None:
|
# For IBM models, use sequential processing with rate limiting
|
||||||
raise RuntimeError(
|
# For other models, use parallel processing
|
||||||
f"Embedding generation failed for {embedding_model}: {last_exception}"
|
vectors: list[list[float]] = [None] * len(texts)
|
||||||
if last_exception
|
|
||||||
else f"Embedding generation failed for {embedding_model}"
|
if is_ibm:
|
||||||
|
# Sequential processing with inter-request delay for IBM models
|
||||||
|
inter_request_delay = 0.6 # ~1.67 req/s, safely under 2 req/s limit
|
||||||
|
logger.info(
|
||||||
|
f"Using sequential processing for IBM model with {inter_request_delay}s delay between requests"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for idx, chunk in enumerate(texts):
|
||||||
|
if idx > 0:
|
||||||
|
# Add delay between requests (but not before the first one)
|
||||||
|
time.sleep(inter_request_delay)
|
||||||
|
vectors[idx] = embed_chunk_with_retry(chunk, idx)
|
||||||
|
else:
|
||||||
|
# Parallel processing for non-IBM models
|
||||||
|
max_workers = min(max(len(texts), 1), 8)
|
||||||
|
logger.debug(f"Using parallel processing with {max_workers} workers")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
futures = {executor.submit(embed_chunk_with_retry, chunk, idx): idx for idx, chunk in enumerate(texts)}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
idx = futures[future]
|
||||||
|
vectors[idx] = future.result()
|
||||||
|
|
||||||
if not vectors:
|
if not vectors:
|
||||||
self.log(f"No vectors generated from documents for model {embedding_model}.")
|
self.log(f"No vectors generated from documents for model {embedding_model}.")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
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,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import type { EndpointType } from "@/contexts/chat-context";
|
import type { EndpointType } from "@/contexts/chat-context";
|
||||||
|
import { useChat } from "@/contexts/chat-context";
|
||||||
|
|
||||||
export interface RawConversation {
|
export interface RawConversation {
|
||||||
response_id: string;
|
response_id: string;
|
||||||
|
|
@ -50,6 +51,7 @@ export const useGetConversationsQuery = (
|
||||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
||||||
) => {
|
) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { isOnboardingComplete } = useChat();
|
||||||
|
|
||||||
async function getConversations(context: { signal?: AbortSignal }): Promise<ChatConversation[]> {
|
async function getConversations(context: { signal?: AbortSignal }): Promise<ChatConversation[]> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -95,6 +97,11 @@ export const useGetConversationsQuery = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract enabled from options and combine with onboarding completion check
|
||||||
|
// Query is only enabled if onboarding is complete AND the caller's enabled condition is met
|
||||||
|
const callerEnabled = options?.enabled ?? true;
|
||||||
|
const enabled = isOnboardingComplete && callerEnabled;
|
||||||
|
|
||||||
const queryResult = useQuery(
|
const queryResult = useQuery(
|
||||||
{
|
{
|
||||||
queryKey: ["conversations", endpoint, refreshTrigger],
|
queryKey: ["conversations", endpoint, refreshTrigger],
|
||||||
|
|
@ -106,6 +113,7 @@ export const useGetConversationsQuery = (
|
||||||
refetchOnMount: false, // Don't refetch on every mount
|
refetchOnMount: false, // Don't refetch on every mount
|
||||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||||
...options,
|
...options,
|
||||||
|
enabled, // Override enabled after spreading options to ensure onboarding check is applied
|
||||||
},
|
},
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import {
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { useChat } from "@/contexts/chat-context";
|
||||||
|
import { useProviderHealthQuery } from "./useProviderHealthQuery";
|
||||||
|
|
||||||
type Nudge = string;
|
type Nudge = string;
|
||||||
|
|
||||||
|
|
@ -27,6 +29,13 @@ export const useGetNudgesQuery = (
|
||||||
) => {
|
) => {
|
||||||
const { chatId, filters, limit, scoreThreshold } = params ?? {};
|
const { chatId, filters, limit, scoreThreshold } = params ?? {};
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { isOnboardingComplete } = useChat();
|
||||||
|
|
||||||
|
// Check if LLM provider is healthy
|
||||||
|
// If health data is not available yet, assume healthy (optimistic)
|
||||||
|
// Only disable if health data exists and shows LLM error
|
||||||
|
const { data: health } = useProviderHealthQuery();
|
||||||
|
const isLLMHealthy = health === undefined || (health?.status === "healthy" && !health?.llm_error);
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
|
|
@ -77,6 +86,11 @@ export const useGetNudgesQuery = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract enabled from options and combine with onboarding completion and LLM health checks
|
||||||
|
// Query is only enabled if onboarding is complete AND LLM provider is healthy AND the caller's enabled condition is met
|
||||||
|
const callerEnabled = options?.enabled ?? true;
|
||||||
|
const enabled = isOnboardingComplete && isLLMHealthy && callerEnabled;
|
||||||
|
|
||||||
const queryResult = useQuery(
|
const queryResult = useQuery(
|
||||||
{
|
{
|
||||||
queryKey: ["nudges", chatId, filters, limit, scoreThreshold],
|
queryKey: ["nudges", chatId, filters, limit, scoreThreshold],
|
||||||
|
|
@ -91,6 +105,7 @@ export const useGetNudgesQuery = (
|
||||||
return Array.isArray(data) && data.length === 0 ? 5000 : false;
|
return Array.isArray(data) && data.length === 0 ? 5000 : false;
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
|
enabled, // Override enabled after spreading options to ensure onboarding check is applied
|
||||||
},
|
},
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useChat } from "@/contexts/chat-context";
|
import { useChat } from "@/contexts/chat-context";
|
||||||
import { useGetSettingsQuery } from "./useGetSettingsQuery";
|
import { useGetSettingsQuery } from "./useGetSettingsQuery";
|
||||||
|
import { useGetTasksQuery } from "./useGetTasksQuery";
|
||||||
|
|
||||||
export interface ProviderHealthDetails {
|
export interface ProviderHealthDetails {
|
||||||
llm_model: string;
|
llm_model: string;
|
||||||
|
|
@ -40,11 +41,20 @@ export const useProviderHealthQuery = (
|
||||||
) => {
|
) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Get chat error state from context (ChatProvider wraps the entire app in layout.tsx)
|
// Get chat error state and onboarding completion from context (ChatProvider wraps the entire app in layout.tsx)
|
||||||
const { hasChatError, setChatError } = useChat();
|
const { hasChatError, setChatError, isOnboardingComplete } = useChat();
|
||||||
|
|
||||||
const { data: settings = {} } = useGetSettingsQuery();
|
const { data: settings = {} } = useGetSettingsQuery();
|
||||||
|
|
||||||
|
// Check if there are any active ingestion tasks
|
||||||
|
const { data: tasks = [] } = useGetTasksQuery();
|
||||||
|
const hasActiveIngestion = tasks.some(
|
||||||
|
(task) =>
|
||||||
|
task.status === "pending" ||
|
||||||
|
task.status === "running" ||
|
||||||
|
task.status === "processing",
|
||||||
|
);
|
||||||
|
|
||||||
async function checkProviderHealth(): Promise<ProviderHealthResponse> {
|
async function checkProviderHealth(): Promise<ProviderHealthResponse> {
|
||||||
try {
|
try {
|
||||||
const url = new URL("/api/provider/health", window.location.origin);
|
const url = new URL("/api/provider/health", window.location.origin);
|
||||||
|
|
@ -55,6 +65,7 @@ export const useProviderHealthQuery = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add test_completion query param if specified or if chat error exists
|
// Add test_completion query param if specified or if chat error exists
|
||||||
|
// Use the same testCompletion value that's in the queryKey
|
||||||
const testCompletion = params?.test_completion ?? hasChatError;
|
const testCompletion = params?.test_completion ?? hasChatError;
|
||||||
if (testCompletion) {
|
if (testCompletion) {
|
||||||
url.searchParams.set("test_completion", "true");
|
url.searchParams.set("test_completion", "true");
|
||||||
|
|
@ -101,7 +112,10 @@ export const useProviderHealthQuery = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryKey = ["provider", "health", params?.test_completion];
|
// Include hasChatError in queryKey so React Query refetches when it changes
|
||||||
|
// This ensures the health check runs with test_completion=true when chat errors occur
|
||||||
|
const testCompletion = params?.test_completion ?? hasChatError;
|
||||||
|
const queryKey = ["provider", "health", testCompletion, hasChatError];
|
||||||
const failureCountKey = queryKey.join("-");
|
const failureCountKey = queryKey.join("-");
|
||||||
|
|
||||||
const queryResult = useQuery(
|
const queryResult = useQuery(
|
||||||
|
|
@ -143,7 +157,11 @@ export const useProviderHealthQuery = (
|
||||||
refetchOnWindowFocus: false, // Disabled to reduce unnecessary calls on tab switches
|
refetchOnWindowFocus: false, // Disabled to reduce unnecessary calls on tab switches
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
staleTime: 30000, // Consider data stale after 30 seconds
|
staleTime: 30000, // Consider data stale after 30 seconds
|
||||||
enabled: !!settings?.edited && options?.enabled !== false, // Only run after onboarding is complete
|
enabled:
|
||||||
|
!!settings?.edited &&
|
||||||
|
isOnboardingComplete &&
|
||||||
|
!hasActiveIngestion && // Disable health checks when ingestion is happening
|
||||||
|
options?.enabled !== false, // Only run after onboarding is complete
|
||||||
...options,
|
...options,
|
||||||
},
|
},
|
||||||
queryClient,
|
queryClient,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import IBMLogo from "@/components/icons/ibm-logo";
|
||||||
import { LabelInput } from "@/components/label-input";
|
import { LabelInput } from "@/components/label-input";
|
||||||
import { LabelWrapper } from "@/components/label-wrapper";
|
import { LabelWrapper } from "@/components/label-wrapper";
|
||||||
import IBMLogo from "@/components/icons/ibm-logo";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/lib/debounce";
|
import { useDebouncedValue } from "@/lib/debounce";
|
||||||
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
|
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
|
||||||
|
|
@ -18,273 +18,273 @@ import { AdvancedOnboarding } from "./advanced";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
export function IBMOnboarding({
|
export function IBMOnboarding({
|
||||||
isEmbedding = false,
|
isEmbedding = false,
|
||||||
setSettings,
|
setSettings,
|
||||||
sampleDataset,
|
sampleDataset,
|
||||||
setSampleDataset,
|
setSampleDataset,
|
||||||
setIsLoadingModels,
|
setIsLoadingModels,
|
||||||
alreadyConfigured = false,
|
alreadyConfigured = false,
|
||||||
existingEndpoint,
|
existingEndpoint,
|
||||||
existingProjectId,
|
existingProjectId,
|
||||||
hasEnvApiKey = false,
|
hasEnvApiKey = false,
|
||||||
}: {
|
}: {
|
||||||
isEmbedding?: boolean;
|
isEmbedding?: boolean;
|
||||||
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
|
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
|
||||||
sampleDataset: boolean;
|
sampleDataset: boolean;
|
||||||
setSampleDataset: (dataset: boolean) => void;
|
setSampleDataset: (dataset: boolean) => void;
|
||||||
setIsLoadingModels?: (isLoading: boolean) => void;
|
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||||
alreadyConfigured?: boolean;
|
alreadyConfigured?: boolean;
|
||||||
existingEndpoint?: string;
|
existingEndpoint?: string;
|
||||||
existingProjectId?: string;
|
existingProjectId?: string;
|
||||||
hasEnvApiKey?: boolean;
|
hasEnvApiKey?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [endpoint, setEndpoint] = useState(
|
const [endpoint, setEndpoint] = useState(
|
||||||
alreadyConfigured ? "" : (existingEndpoint || "https://us-south.ml.cloud.ibm.com"),
|
alreadyConfigured
|
||||||
);
|
? ""
|
||||||
const [apiKey, setApiKey] = useState("");
|
: existingEndpoint || "https://us-south.ml.cloud.ibm.com",
|
||||||
const [getFromEnv, setGetFromEnv] = useState(
|
);
|
||||||
hasEnvApiKey && !alreadyConfigured,
|
const [apiKey, setApiKey] = useState("");
|
||||||
);
|
const [getFromEnv, setGetFromEnv] = useState(
|
||||||
const [projectId, setProjectId] = useState(
|
hasEnvApiKey && !alreadyConfigured,
|
||||||
alreadyConfigured ? "" : (existingProjectId || ""),
|
);
|
||||||
);
|
const [projectId, setProjectId] = useState(
|
||||||
|
alreadyConfigured ? "" : existingProjectId || "",
|
||||||
|
);
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
value: "https://us-south.ml.cloud.ibm.com",
|
value: "https://us-south.ml.cloud.ibm.com",
|
||||||
label: "https://us-south.ml.cloud.ibm.com",
|
label: "https://us-south.ml.cloud.ibm.com",
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://eu-de.ml.cloud.ibm.com",
|
value: "https://eu-de.ml.cloud.ibm.com",
|
||||||
label: "https://eu-de.ml.cloud.ibm.com",
|
label: "https://eu-de.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://eu-gb.ml.cloud.ibm.com",
|
value: "https://eu-gb.ml.cloud.ibm.com",
|
||||||
label: "https://eu-gb.ml.cloud.ibm.com",
|
label: "https://eu-gb.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://au-syd.ml.cloud.ibm.com",
|
value: "https://au-syd.ml.cloud.ibm.com",
|
||||||
label: "https://au-syd.ml.cloud.ibm.com",
|
label: "https://au-syd.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://jp-tok.ml.cloud.ibm.com",
|
value: "https://jp-tok.ml.cloud.ibm.com",
|
||||||
label: "https://jp-tok.ml.cloud.ibm.com",
|
label: "https://jp-tok.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "https://ca-tor.ml.cloud.ibm.com",
|
value: "https://ca-tor.ml.cloud.ibm.com",
|
||||||
label: "https://ca-tor.ml.cloud.ibm.com",
|
label: "https://ca-tor.ml.cloud.ibm.com",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
|
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
|
||||||
const debouncedApiKey = useDebouncedValue(apiKey, 500);
|
const debouncedApiKey = useDebouncedValue(apiKey, 500);
|
||||||
const debouncedProjectId = useDebouncedValue(projectId, 500);
|
const debouncedProjectId = useDebouncedValue(projectId, 500);
|
||||||
|
|
||||||
// Fetch models from API when all credentials are provided
|
// Fetch models from API when all credentials are provided
|
||||||
const {
|
const {
|
||||||
data: modelsData,
|
data: modelsData,
|
||||||
isLoading: isLoadingModels,
|
isLoading: isLoadingModels,
|
||||||
error: modelsError,
|
error: modelsError,
|
||||||
} = useGetIBMModelsQuery(
|
} = useGetIBMModelsQuery(
|
||||||
{
|
{
|
||||||
endpoint: debouncedEndpoint ? debouncedEndpoint : undefined,
|
endpoint: debouncedEndpoint ? debouncedEndpoint : undefined,
|
||||||
apiKey: getFromEnv ? "" : (debouncedApiKey ? debouncedApiKey : undefined),
|
apiKey: getFromEnv ? "" : debouncedApiKey ? debouncedApiKey : undefined,
|
||||||
projectId: debouncedProjectId ? debouncedProjectId : undefined,
|
projectId: debouncedProjectId ? debouncedProjectId : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled:
|
enabled:
|
||||||
!!debouncedEndpoint ||
|
(!!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId) ||
|
||||||
!!debouncedApiKey ||
|
getFromEnv ||
|
||||||
!!debouncedProjectId ||
|
alreadyConfigured,
|
||||||
getFromEnv ||
|
},
|
||||||
alreadyConfigured,
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hook for model selection logic
|
// Use custom hook for model selection logic
|
||||||
const {
|
const {
|
||||||
languageModel,
|
languageModel,
|
||||||
embeddingModel,
|
embeddingModel,
|
||||||
setLanguageModel,
|
setLanguageModel,
|
||||||
setEmbeddingModel,
|
setEmbeddingModel,
|
||||||
languageModels,
|
languageModels,
|
||||||
embeddingModels,
|
embeddingModels,
|
||||||
} = useModelSelection(modelsData, isEmbedding);
|
} = useModelSelection(modelsData, isEmbedding);
|
||||||
|
|
||||||
const handleGetFromEnvChange = (fromEnv: boolean) => {
|
const handleGetFromEnvChange = (fromEnv: boolean) => {
|
||||||
setGetFromEnv(fromEnv);
|
setGetFromEnv(fromEnv);
|
||||||
if (fromEnv) {
|
if (fromEnv) {
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
}
|
}
|
||||||
setEmbeddingModel?.("");
|
setEmbeddingModel?.("");
|
||||||
setLanguageModel?.("");
|
setLanguageModel?.("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSampleDatasetChange = (dataset: boolean) => {
|
const handleSampleDatasetChange = (dataset: boolean) => {
|
||||||
setSampleDataset(dataset);
|
setSampleDataset(dataset);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoadingModels?.(isLoadingModels);
|
setIsLoadingModels?.(isLoadingModels);
|
||||||
}, [isLoadingModels, setIsLoadingModels]);
|
}, [isLoadingModels, setIsLoadingModels]);
|
||||||
|
|
||||||
// Update settings when values change
|
// Update settings when values change
|
||||||
useUpdateSettings(
|
useUpdateSettings(
|
||||||
"watsonx",
|
"watsonx",
|
||||||
{
|
{
|
||||||
endpoint,
|
endpoint,
|
||||||
apiKey,
|
apiKey,
|
||||||
projectId,
|
projectId,
|
||||||
languageModel,
|
languageModel,
|
||||||
embeddingModel,
|
embeddingModel,
|
||||||
},
|
},
|
||||||
setSettings,
|
setSettings,
|
||||||
isEmbedding,
|
isEmbedding,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
label="watsonx.ai API Endpoint"
|
label="watsonx.ai API Endpoint"
|
||||||
helperText="Base URL of the API"
|
helperText="Base URL of the API"
|
||||||
id="api-endpoint"
|
id="api-endpoint"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
options={alreadyConfigured ? [] : options}
|
options={alreadyConfigured ? [] : options}
|
||||||
value={endpoint}
|
value={endpoint}
|
||||||
custom
|
custom
|
||||||
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
|
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
|
||||||
searchPlaceholder="Search endpoint..."
|
searchPlaceholder="Search endpoint..."
|
||||||
noOptionsPlaceholder={
|
noOptionsPlaceholder={
|
||||||
alreadyConfigured
|
alreadyConfigured
|
||||||
? "https://•••••••••••••••••••••••••••••••••••••••••"
|
? "https://•••••••••••••••••••••••••••••••••••••••••"
|
||||||
: "No endpoints available"
|
: "No endpoints available"
|
||||||
}
|
}
|
||||||
placeholder="Select endpoint..."
|
placeholder="Select endpoint..."
|
||||||
/>
|
/>
|
||||||
{alreadyConfigured && (
|
{alreadyConfigured && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Reusing endpoint from model provider selection.
|
Reusing endpoint from model provider selection.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
label="watsonx Project ID"
|
label="watsonx Project ID"
|
||||||
helperText="Project ID for the model"
|
helperText="Project ID for the model"
|
||||||
id="project-id"
|
id="project-id"
|
||||||
required
|
required
|
||||||
placeholder={
|
placeholder={
|
||||||
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
|
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
|
||||||
}
|
}
|
||||||
value={projectId}
|
value={projectId}
|
||||||
onChange={(e) => setProjectId(e.target.value)}
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
disabled={alreadyConfigured}
|
disabled={alreadyConfigured}
|
||||||
/>
|
/>
|
||||||
{alreadyConfigured && (
|
{alreadyConfigured && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Reusing project ID from model provider selection.
|
Reusing project ID from model provider selection.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
label="Use environment watsonx API key"
|
label="Use environment watsonx API key"
|
||||||
id="get-api-key"
|
id="get-api-key"
|
||||||
description="Reuse the key from your environment config. Turn off to enter a different key."
|
description="Reuse the key from your environment config. Turn off to enter a different key."
|
||||||
flex
|
flex
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={getFromEnv}
|
checked={getFromEnv}
|
||||||
onCheckedChange={handleGetFromEnvChange}
|
onCheckedChange={handleGetFromEnvChange}
|
||||||
disabled={!hasEnvApiKey || alreadyConfigured}
|
disabled={!hasEnvApiKey || alreadyConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{!hasEnvApiKey && !alreadyConfigured && (
|
{!hasEnvApiKey && !alreadyConfigured && (
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
watsonx API key not detected in the environment.
|
watsonx API key not detected in the environment.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
{!getFromEnv && !alreadyConfigured && (
|
{!getFromEnv && !alreadyConfigured && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
label="watsonx API key"
|
label="watsonx API key"
|
||||||
helperText="API key to access watsonx.ai"
|
helperText="API key to access watsonx.ai"
|
||||||
className={modelsError ? "!border-destructive" : ""}
|
className={modelsError ? "!border-destructive" : ""}
|
||||||
id="api-key"
|
id="api-key"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
placeholder="your-api-key"
|
placeholder="your-api-key"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{isLoadingModels && (
|
{isLoadingModels && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Validating API key...
|
Validating API key...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{modelsError && (
|
{modelsError && (
|
||||||
<p className="text-mmd text-destructive">
|
<p className="text-mmd text-destructive">
|
||||||
Invalid watsonx API key. Verify or replace the key.
|
Invalid watsonx API key. Verify or replace the key.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{alreadyConfigured && (
|
{alreadyConfigured && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
label="watsonx API key"
|
label="watsonx API key"
|
||||||
helperText="API key to access watsonx.ai"
|
helperText="API key to access watsonx.ai"
|
||||||
id="api-key"
|
id="api-key"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
placeholder="•••••••••••••••••••••••••••••••••••••••••"
|
placeholder="•••••••••••••••••••••••••••••••••••••••••"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
/>
|
/>
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Reusing API key from model provider selection.
|
Reusing API key from model provider selection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{getFromEnv && isLoadingModels && (
|
{getFromEnv && isLoadingModels && (
|
||||||
<p className="text-mmd text-muted-foreground">
|
<p className="text-mmd text-muted-foreground">
|
||||||
Validating configuration...
|
Validating configuration...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{getFromEnv && modelsError && (
|
{getFromEnv && modelsError && (
|
||||||
<p className="text-mmd text-accent-amber-foreground">
|
<p className="text-mmd text-accent-amber-foreground">
|
||||||
Connection failed. Check your configuration.
|
Connection failed. Check your configuration.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AdvancedOnboarding
|
<AdvancedOnboarding
|
||||||
icon={<IBMLogo className="w-4 h-4" />}
|
icon={<IBMLogo className="w-4 h-4" />}
|
||||||
languageModels={languageModels}
|
languageModels={languageModels}
|
||||||
embeddingModels={embeddingModels}
|
embeddingModels={embeddingModels}
|
||||||
languageModel={languageModel}
|
languageModel={languageModel}
|
||||||
embeddingModel={embeddingModel}
|
embeddingModel={embeddingModel}
|
||||||
sampleDataset={sampleDataset}
|
sampleDataset={sampleDataset}
|
||||||
setLanguageModel={setLanguageModel}
|
setLanguageModel={setLanguageModel}
|
||||||
setEmbeddingModel={setEmbeddingModel}
|
setEmbeddingModel={setEmbeddingModel}
|
||||||
setSampleDataset={handleSampleDatasetChange}
|
setSampleDataset={handleSampleDatasetChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type OnboardingVariables,
|
type OnboardingVariables,
|
||||||
useOnboardingMutation,
|
useOnboardingMutation,
|
||||||
} from "@/app/api/mutations/useOnboardingMutation";
|
} from "@/app/api/mutations/useOnboardingMutation";
|
||||||
|
import { useOnboardingRollbackMutation } from "@/app/api/mutations/useOnboardingRollbackMutation";
|
||||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||||
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
|
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
|
||||||
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
|
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
|
||||||
|
|
@ -170,12 +171,32 @@ const OnboardingCard = ({
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Track which tasks we've already handled to prevent infinite loops
|
||||||
|
const handledFailedTasksRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Query tasks to track completion
|
// Query tasks to track completion
|
||||||
const { data: tasks } = useGetTasksQuery({
|
const { data: tasks } = useGetTasksQuery({
|
||||||
enabled: currentStep !== null, // Only poll when onboarding has started
|
enabled: currentStep !== null, // Only poll when onboarding has started
|
||||||
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding
|
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rollback mutation
|
||||||
|
const rollbackMutation = useOnboardingRollbackMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
console.log("Onboarding rolled back successfully");
|
||||||
|
// Reset to provider selection step
|
||||||
|
// Error message is already set before calling mutate
|
||||||
|
setCurrentStep(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to rollback onboarding", error);
|
||||||
|
// Preserve existing error message if set, otherwise show rollback error
|
||||||
|
setError((prevError) => prevError || `Failed to rollback: ${error.message}`);
|
||||||
|
// Still reset to provider selection even if rollback fails
|
||||||
|
setCurrentStep(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Monitor tasks and call onComplete when all tasks are done
|
// Monitor tasks and call onComplete when all tasks are done
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep === null || !tasks || !isEmbedding) {
|
if (currentStep === null || !tasks || !isEmbedding) {
|
||||||
|
|
@ -190,11 +211,86 @@ const OnboardingCard = ({
|
||||||
task.status === "processing",
|
task.status === "processing",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if any file failed in completed tasks
|
||||||
|
const completedTasks = tasks.filter(
|
||||||
|
(task) => task.status === "completed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any completed task has at least one failed file
|
||||||
|
const taskWithFailedFile = completedTasks.find((task) => {
|
||||||
|
// Must have files object
|
||||||
|
if (!task.files || typeof task.files !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileEntries = Object.values(task.files);
|
||||||
|
|
||||||
|
// Must have at least one file
|
||||||
|
if (fileEntries.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any file has failed status
|
||||||
|
const hasFailedFile = fileEntries.some(
|
||||||
|
(file) => file.status === "failed" || file.status === "error"
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasFailedFile;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any file failed, show error and jump back one step (like onboardingMutation.onError)
|
||||||
|
// Only handle if we haven't already handled this task
|
||||||
|
if (
|
||||||
|
taskWithFailedFile &&
|
||||||
|
!rollbackMutation.isPending &&
|
||||||
|
!isCompleted &&
|
||||||
|
!handledFailedTasksRef.current.has(taskWithFailedFile.task_id)
|
||||||
|
) {
|
||||||
|
console.error("File failed in task, jumping back one step", taskWithFailedFile);
|
||||||
|
|
||||||
|
// Mark this task as handled to prevent infinite loops
|
||||||
|
handledFailedTasksRef.current.add(taskWithFailedFile.task_id);
|
||||||
|
|
||||||
|
// Extract error messages from failed files
|
||||||
|
const errorMessages: string[] = [];
|
||||||
|
if (taskWithFailedFile.files) {
|
||||||
|
Object.values(taskWithFailedFile.files).forEach((file) => {
|
||||||
|
if ((file.status === "failed" || file.status === "error") && file.error) {
|
||||||
|
errorMessages.push(file.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check task-level error
|
||||||
|
if (taskWithFailedFile.error) {
|
||||||
|
errorMessages.push(taskWithFailedFile.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first error message, or a generic message if no errors found
|
||||||
|
const errorMessage = errorMessages.length > 0
|
||||||
|
? errorMessages[0]
|
||||||
|
: "Sample data file failed to ingest. Please try again with a different configuration.";
|
||||||
|
|
||||||
|
// Set error message and jump back one step (exactly like onboardingMutation.onError)
|
||||||
|
setError(errorMessage);
|
||||||
|
setCurrentStep(totalSteps);
|
||||||
|
// Jump back one step after 1 second (go back to the step before ingestion)
|
||||||
|
// For embedding: totalSteps is 4, ingestion is step 3, so go back to step 2
|
||||||
|
// For LLM: totalSteps is 3, ingestion is step 2, so go back to step 1
|
||||||
|
setTimeout(() => {
|
||||||
|
// Go back to the step before the last step (which is ingestion)
|
||||||
|
const previousStep = totalSteps > 1 ? totalSteps - 2 : 0;
|
||||||
|
setCurrentStep(previousStep);
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If no active tasks and we've started onboarding, complete it
|
// If no active tasks and we've started onboarding, complete it
|
||||||
if (
|
if (
|
||||||
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
||||||
tasks.length > 0 &&
|
tasks.length > 0 &&
|
||||||
!isCompleted
|
!isCompleted &&
|
||||||
|
!taskWithFailedFile
|
||||||
) {
|
) {
|
||||||
// Set to final step to show "Done"
|
// Set to final step to show "Done"
|
||||||
setCurrentStep(totalSteps);
|
setCurrentStep(totalSteps);
|
||||||
|
|
@ -203,7 +299,7 @@ const OnboardingCard = ({
|
||||||
onComplete();
|
onComplete();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps]);
|
}, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps, rollbackMutation]);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const onboardingMutation = useOnboardingMutation({
|
const onboardingMutation = useOnboardingMutation({
|
||||||
|
|
@ -507,7 +603,7 @@ const OnboardingCard = ({
|
||||||
hasEnvApiKey={
|
hasEnvApiKey={
|
||||||
currentSettings?.providers?.openai?.has_api_key === true
|
currentSettings?.providers?.openai?.has_api_key === true
|
||||||
}
|
}
|
||||||
alreadyConfigured={providerAlreadyConfigured}
|
alreadyConfigured={providerAlreadyConfigured && modelProvider === "openai"}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="watsonx">
|
<TabsContent value="watsonx">
|
||||||
|
|
@ -517,7 +613,7 @@ const OnboardingCard = ({
|
||||||
setSampleDataset={setSampleDataset}
|
setSampleDataset={setSampleDataset}
|
||||||
setIsLoadingModels={setIsLoadingModels}
|
setIsLoadingModels={setIsLoadingModels}
|
||||||
isEmbedding={isEmbedding}
|
isEmbedding={isEmbedding}
|
||||||
alreadyConfigured={providerAlreadyConfigured}
|
alreadyConfigured={providerAlreadyConfigured && modelProvider === "watsonx"}
|
||||||
existingEndpoint={currentSettings?.providers?.watsonx?.endpoint}
|
existingEndpoint={currentSettings?.providers?.watsonx?.endpoint}
|
||||||
existingProjectId={currentSettings?.providers?.watsonx?.project_id}
|
existingProjectId={currentSettings?.providers?.watsonx?.project_id}
|
||||||
hasEnvApiKey={currentSettings?.providers?.watsonx?.has_api_key === true}
|
hasEnvApiKey={currentSettings?.providers?.watsonx?.has_api_key === true}
|
||||||
|
|
@ -530,7 +626,7 @@ const OnboardingCard = ({
|
||||||
setSampleDataset={setSampleDataset}
|
setSampleDataset={setSampleDataset}
|
||||||
setIsLoadingModels={setIsLoadingModels}
|
setIsLoadingModels={setIsLoadingModels}
|
||||||
isEmbedding={isEmbedding}
|
isEmbedding={isEmbedding}
|
||||||
alreadyConfigured={providerAlreadyConfigured}
|
alreadyConfigured={providerAlreadyConfigured && modelProvider === "ollama"}
|
||||||
existingEndpoint={currentSettings?.providers?.ollama?.endpoint}
|
existingEndpoint={currentSettings?.providers?.ollama?.endpoint}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -7,13 +8,13 @@ import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
|
||||||
import { AnimatedProviderSteps } from "@/app/onboarding/_components/animated-provider-steps";
|
import { AnimatedProviderSteps } from "@/app/onboarding/_components/animated-provider-steps";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ONBOARDING_UPLOAD_STEPS_KEY,
|
ONBOARDING_UPLOAD_STEPS_KEY,
|
||||||
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
ONBOARDING_USER_DOC_FILTER_ID_KEY,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { uploadFile } from "@/lib/upload-utils";
|
import { uploadFile } from "@/lib/upload-utils";
|
||||||
|
|
||||||
interface OnboardingUploadProps {
|
interface OnboardingUploadProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
|
|
@ -21,6 +22,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState<number | null>(null);
|
const [currentStep, setCurrentStep] = useState<number | null>(null);
|
||||||
const [uploadedFilename, setUploadedFilename] = useState<string | null>(null);
|
const [uploadedFilename, setUploadedFilename] = useState<string | null>(null);
|
||||||
|
const [uploadedTaskId, setUploadedTaskId] = useState<string | null>(null);
|
||||||
const [shouldCreateFilter, setShouldCreateFilter] = useState(false);
|
const [shouldCreateFilter, setShouldCreateFilter] = useState(false);
|
||||||
const [isCreatingFilter, setIsCreatingFilter] = useState(false);
|
const [isCreatingFilter, setIsCreatingFilter] = useState(false);
|
||||||
|
|
||||||
|
|
@ -43,23 +45,26 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
|
|
||||||
// Monitor tasks and call onComplete when file processing is done
|
// Monitor tasks and call onComplete when file processing is done
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep === null || !tasks) {
|
if (currentStep === null || !tasks || !uploadedTaskId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are any active tasks (pending, running, or processing)
|
// Find the task by task ID from the upload response
|
||||||
const activeTasks = tasks.find(
|
const matchingTask = tasks.find((task) => task.task_id === uploadedTaskId);
|
||||||
(task) =>
|
|
||||||
task.status === "pending" ||
|
|
||||||
task.status === "running" ||
|
|
||||||
task.status === "processing",
|
|
||||||
);
|
|
||||||
|
|
||||||
// If no active tasks and we have more than 1 task (initial + new upload), complete it
|
// If no matching task found, wait for it to appear
|
||||||
if (
|
if (!matchingTask) {
|
||||||
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
return;
|
||||||
tasks.length > 1
|
}
|
||||||
) {
|
|
||||||
|
// Check if the matching task is still active (pending, running, or processing)
|
||||||
|
const isTaskActive =
|
||||||
|
matchingTask.status === "pending" ||
|
||||||
|
matchingTask.status === "running" ||
|
||||||
|
matchingTask.status === "processing";
|
||||||
|
|
||||||
|
// If task is completed or has processed files, complete the onboarding step
|
||||||
|
if (!isTaskActive || (matchingTask.processed_files ?? 0) > 0) {
|
||||||
// Set to final step to show "Done"
|
// Set to final step to show "Done"
|
||||||
setCurrentStep(STEP_LIST.length);
|
setCurrentStep(STEP_LIST.length);
|
||||||
|
|
||||||
|
|
@ -91,6 +96,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
icon: "file",
|
icon: "file",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for filter creation to complete before proceeding
|
||||||
createFilterMutation
|
createFilterMutation
|
||||||
.mutateAsync({
|
.mutateAsync({
|
||||||
name: displayName,
|
name: displayName,
|
||||||
|
|
@ -114,18 +120,36 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsCreatingFilter(false);
|
setIsCreatingFilter(false);
|
||||||
|
// Refetch nudges to get new ones
|
||||||
|
refetchNudges();
|
||||||
|
|
||||||
|
// Wait a bit before completing (after filter is created)
|
||||||
|
setTimeout(() => {
|
||||||
|
onComplete();
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// No filter to create, just complete
|
||||||
|
// Refetch nudges to get new ones
|
||||||
|
refetchNudges();
|
||||||
|
|
||||||
|
// Wait a bit before completing
|
||||||
|
setTimeout(() => {
|
||||||
|
onComplete();
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refetch nudges to get new ones
|
|
||||||
refetchNudges();
|
|
||||||
|
|
||||||
// Wait a bit before completing
|
|
||||||
setTimeout(() => {
|
|
||||||
onComplete();
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
}, [tasks, currentStep, onComplete, refetchNudges, shouldCreateFilter, uploadedFilename]);
|
}, [
|
||||||
|
tasks,
|
||||||
|
currentStep,
|
||||||
|
onComplete,
|
||||||
|
refetchNudges,
|
||||||
|
shouldCreateFilter,
|
||||||
|
uploadedFilename,
|
||||||
|
uploadedTaskId,
|
||||||
|
createFilterMutation,
|
||||||
|
isCreatingFilter,
|
||||||
|
]);
|
||||||
|
|
||||||
const resetFileInput = () => {
|
const resetFileInput = () => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
|
|
@ -144,6 +168,11 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
const result = await uploadFile(file, true, true); // Pass createFilter=true
|
const result = await uploadFile(file, true, true); // Pass createFilter=true
|
||||||
console.log("Document upload task started successfully");
|
console.log("Document upload task started successfully");
|
||||||
|
|
||||||
|
// Store task ID to track the specific upload task
|
||||||
|
if (result.taskId) {
|
||||||
|
setUploadedTaskId(result.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
// Store filename and createFilter flag in state to create filter after ingestion succeeds
|
// Store filename and createFilter flag in state to create filter after ingestion succeeds
|
||||||
if (result.createFilter && result.filename) {
|
if (result.createFilter && result.filename) {
|
||||||
setUploadedFilename(result.filename);
|
setUploadedFilename(result.filename);
|
||||||
|
|
@ -176,6 +205,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
|
|
||||||
// Reset on error
|
// Reset on error
|
||||||
setCurrentStep(null);
|
setCurrentStep(null);
|
||||||
|
setUploadedTaskId(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,7 @@ export function ChatRenderer({
|
||||||
refreshConversations,
|
refreshConversations,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
setConversationFilter,
|
setConversationFilter,
|
||||||
setCurrentConversationId,
|
setOnboardingComplete,
|
||||||
setPreviousResponseIds,
|
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
||||||
// Initialize onboarding state based on local storage and settings
|
// Initialize onboarding state based on local storage and settings
|
||||||
|
|
@ -170,12 +169,17 @@ export function ChatRenderer({
|
||||||
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
|
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear ALL conversation state so next message starts fresh
|
// Mark onboarding as complete in context
|
||||||
await startNewConversation();
|
setOnboardingComplete(true);
|
||||||
|
|
||||||
// Store the user document filter as default for new conversations and load it
|
// Store the user document filter as default for new conversations FIRST
|
||||||
|
// This must happen before startNewConversation() so the filter is available
|
||||||
await storeDefaultFilterForNewConversations(true);
|
await storeDefaultFilterForNewConversations(true);
|
||||||
|
|
||||||
|
// Clear ALL conversation state so next message starts fresh
|
||||||
|
// This will pick up the default filter we just set
|
||||||
|
await startNewConversation();
|
||||||
|
|
||||||
// Clean up onboarding filter IDs now that we've set the default
|
// Clean up onboarding filter IDs now that we've set the default
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem(ONBOARDING_OPENRAG_DOCS_FILTER_ID_KEY);
|
localStorage.removeItem(ONBOARDING_OPENRAG_DOCS_FILTER_ID_KEY);
|
||||||
|
|
@ -202,6 +206,8 @@ export function ChatRenderer({
|
||||||
localStorage.removeItem(ONBOARDING_CARD_STEPS_KEY);
|
localStorage.removeItem(ONBOARDING_CARD_STEPS_KEY);
|
||||||
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
|
localStorage.removeItem(ONBOARDING_UPLOAD_STEPS_KEY);
|
||||||
}
|
}
|
||||||
|
// Mark onboarding as complete in context
|
||||||
|
setOnboardingComplete(true);
|
||||||
// Store the OpenRAG docs filter as default for new conversations
|
// Store the OpenRAG docs filter as default for new conversations
|
||||||
storeDefaultFilterForNewConversations(false);
|
storeDefaultFilterForNewConversations(false);
|
||||||
setShowLayout(true);
|
setShowLayout(true);
|
||||||
|
|
|
||||||
|
|
@ -5,125 +5,131 @@ import { useRouter } from "next/navigation";
|
||||||
import { useProviderHealthQuery } from "@/app/api/queries/useProviderHealthQuery";
|
import { useProviderHealthQuery } from "@/app/api/queries/useProviderHealthQuery";
|
||||||
import type { ModelProvider } from "@/app/settings/_helpers/model-helpers";
|
import type { ModelProvider } from "@/app/settings/_helpers/model-helpers";
|
||||||
import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner";
|
import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useChat } from "@/contexts/chat-context";
|
import { useChat } from "@/contexts/chat-context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface ProviderHealthBannerProps {
|
interface ProviderHealthBannerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom hook to check provider health status
|
// Custom hook to check provider health status
|
||||||
export function useProviderHealth() {
|
export function useProviderHealth() {
|
||||||
const { hasChatError } = useChat();
|
const { hasChatError } = useChat();
|
||||||
const {
|
const {
|
||||||
data: health,
|
data: health,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
error,
|
error,
|
||||||
isError,
|
isError,
|
||||||
} = useProviderHealthQuery({
|
} = useProviderHealthQuery({
|
||||||
test_completion: hasChatError, // Use test_completion=true when chat errors occur
|
test_completion: hasChatError, // Use test_completion=true when chat errors occur
|
||||||
});
|
});
|
||||||
|
|
||||||
const isHealthy = health?.status === "healthy" && !isError;
|
const isHealthy = health?.status === "healthy" && !isError;
|
||||||
// Only consider unhealthy if backend is up but provider validation failed
|
// Only consider unhealthy if backend is up but provider validation failed
|
||||||
// Don't show banner if backend is unavailable
|
// Don't show banner if backend is unavailable
|
||||||
const isUnhealthy =
|
const isUnhealthy =
|
||||||
health?.status === "unhealthy" || health?.status === "error";
|
health?.status === "unhealthy" || health?.status === "error";
|
||||||
const isBackendUnavailable =
|
const isBackendUnavailable =
|
||||||
health?.status === "backend-unavailable" || isError;
|
health?.status === "backend-unavailable" || isError;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
health,
|
health,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
error,
|
error,
|
||||||
isError,
|
isError,
|
||||||
isHealthy,
|
isHealthy,
|
||||||
isUnhealthy,
|
isUnhealthy,
|
||||||
isBackendUnavailable,
|
isBackendUnavailable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerTitleMap: Record<ModelProvider, string> = {
|
const providerTitleMap: Record<ModelProvider, string> = {
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
watsonx: "IBM watsonx.ai",
|
watsonx: "IBM watsonx.ai",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
|
export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) {
|
||||||
const { isLoading, isHealthy, isUnhealthy, health } = useProviderHealth();
|
const { isLoading, isHealthy, isUnhealthy, health } = useProviderHealth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Only show banner when provider is unhealthy (not when backend is unavailable)
|
// Only show banner when provider is unhealthy (not when backend is unavailable)
|
||||||
if (isLoading || isHealthy) {
|
if (isLoading || isHealthy) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnhealthy) {
|
if (isUnhealthy) {
|
||||||
const llmProvider = health?.llm_provider || health?.provider;
|
const llmProvider = health?.llm_provider || health?.provider;
|
||||||
const embeddingProvider = health?.embedding_provider;
|
const embeddingProvider = health?.embedding_provider;
|
||||||
const llmError = health?.llm_error;
|
const llmError = health?.llm_error;
|
||||||
const embeddingError = health?.embedding_error;
|
const embeddingError = health?.embedding_error;
|
||||||
|
|
||||||
// Determine which provider has the error
|
// Determine which provider has the error
|
||||||
let errorProvider: string | undefined;
|
let errorProvider: string | undefined;
|
||||||
let errorMessage: string;
|
let errorMessage: string;
|
||||||
|
|
||||||
if (llmError && embeddingError) {
|
if (llmError && embeddingError) {
|
||||||
// Both have errors - show combined message
|
// Both have errors - check if they're the same
|
||||||
errorMessage = health?.message || "Provider validation failed";
|
if (llmError === embeddingError) {
|
||||||
errorProvider = undefined; // Don't link to a specific provider
|
// Same error for both - show once
|
||||||
} else if (llmError) {
|
errorMessage = llmError;
|
||||||
// Only LLM has error
|
} else {
|
||||||
errorProvider = llmProvider;
|
// Different errors - show both
|
||||||
errorMessage = llmError;
|
errorMessage = `${llmError}; ${embeddingError}`;
|
||||||
} else if (embeddingError) {
|
}
|
||||||
// Only embedding has error
|
errorProvider = undefined; // Don't link to a specific provider
|
||||||
errorProvider = embeddingProvider;
|
} else if (llmError) {
|
||||||
errorMessage = embeddingError;
|
// Only LLM has error
|
||||||
} else {
|
errorProvider = llmProvider;
|
||||||
// Fallback to original message
|
errorMessage = llmError;
|
||||||
errorMessage = health?.message || "Provider validation failed";
|
} else if (embeddingError) {
|
||||||
errorProvider = llmProvider;
|
// Only embedding has error
|
||||||
}
|
errorProvider = embeddingProvider;
|
||||||
|
errorMessage = embeddingError;
|
||||||
|
} else {
|
||||||
|
// Fallback to original message
|
||||||
|
errorMessage = health?.message || "Provider validation failed";
|
||||||
|
errorProvider = llmProvider;
|
||||||
|
}
|
||||||
|
|
||||||
const providerTitle = errorProvider
|
const providerTitle = errorProvider
|
||||||
? providerTitleMap[errorProvider as ModelProvider] || errorProvider
|
? providerTitleMap[errorProvider as ModelProvider] || errorProvider
|
||||||
: "Provider";
|
: "Provider";
|
||||||
|
|
||||||
const settingsUrl = errorProvider
|
const settingsUrl = errorProvider
|
||||||
? `/settings?setup=${errorProvider}`
|
? `/settings?setup=${errorProvider}`
|
||||||
: "/settings";
|
: "/settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
|
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BannerIcon
|
<BannerIcon
|
||||||
className="text-accent-red-foreground"
|
className="text-accent-red-foreground"
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
/>
|
/>
|
||||||
<BannerTitle className="font-medium flex items-center gap-2">
|
<BannerTitle className="font-medium flex items-center gap-2">
|
||||||
{llmError && embeddingError ? (
|
{llmError && embeddingError ? (
|
||||||
<>Provider errors - {errorMessage}</>
|
<>Provider errors - {errorMessage}</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{providerTitle} error - {errorMessage}
|
{providerTitle} error - {errorMessage}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</BannerTitle>
|
</BannerTitle>
|
||||||
<Button size="sm" onClick={() => router.push(settingsUrl)}>
|
<Button size="sm" onClick={() => router.push(settingsUrl)}>
|
||||||
Fix Setup
|
Fix Setup
|
||||||
</Button>
|
</Button>
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { ONBOARDING_STEP_KEY } from "@/lib/constants";
|
||||||
|
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||||
|
|
||||||
export type EndpointType = "chat" | "langflow";
|
export type EndpointType = "chat" | "langflow";
|
||||||
|
|
||||||
|
|
@ -81,6 +83,8 @@ interface ChatContextType {
|
||||||
setConversationFilter: (filter: KnowledgeFilter | null, responseId?: string | null) => void;
|
setConversationFilter: (filter: KnowledgeFilter | null, responseId?: string | null) => void;
|
||||||
hasChatError: boolean;
|
hasChatError: boolean;
|
||||||
setChatError: (hasError: boolean) => void;
|
setChatError: (hasError: boolean) => void;
|
||||||
|
isOnboardingComplete: boolean;
|
||||||
|
setOnboardingComplete: (complete: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||||
|
|
@ -111,6 +115,46 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
const [conversationFilter, setConversationFilterState] =
|
const [conversationFilter, setConversationFilterState] =
|
||||||
useState<KnowledgeFilter | null>(null);
|
useState<KnowledgeFilter | null>(null);
|
||||||
const [hasChatError, setChatError] = useState(false);
|
const [hasChatError, setChatError] = useState(false);
|
||||||
|
|
||||||
|
// Get settings to check if onboarding was completed (settings.edited)
|
||||||
|
const { data: settings } = useGetSettingsQuery();
|
||||||
|
|
||||||
|
// Check if onboarding is complete
|
||||||
|
// Onboarding is complete if:
|
||||||
|
// 1. settings.edited is true (backend confirms onboarding was completed)
|
||||||
|
// 2. AND onboarding step key is null (local onboarding flow is done)
|
||||||
|
const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
// Default to false if settings not loaded yet
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync onboarding completion state with settings.edited and localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const checkOnboarding = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// Onboarding is complete if settings.edited is true AND step key is null
|
||||||
|
const stepKeyExists = localStorage.getItem(ONBOARDING_STEP_KEY) !== null;
|
||||||
|
const isEdited = settings?.edited === true;
|
||||||
|
// Complete if edited is true and step key doesn't exist (onboarding flow finished)
|
||||||
|
setIsOnboardingComplete(isEdited && !stepKeyExists);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check on mount and when settings change
|
||||||
|
checkOnboarding();
|
||||||
|
|
||||||
|
// Listen for storage events (for cross-tab sync)
|
||||||
|
window.addEventListener("storage", checkOnboarding);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", checkOnboarding);
|
||||||
|
};
|
||||||
|
}, [settings?.edited]);
|
||||||
|
|
||||||
|
const setOnboardingComplete = useCallback((complete: boolean) => {
|
||||||
|
setIsOnboardingComplete(complete);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Listen for ingestion failures and set chat error flag
|
// Listen for ingestion failures and set chat error flag
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -228,6 +272,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
const startNewConversation = useCallback(async () => {
|
const startNewConversation = useCallback(async () => {
|
||||||
console.log("[CONVERSATION] Starting new conversation");
|
console.log("[CONVERSATION] Starting new conversation");
|
||||||
|
|
||||||
|
// Check if there's existing conversation data - if so, this is a manual "new conversation" action
|
||||||
|
// Check state values before clearing them
|
||||||
|
const hasExistingConversation = conversationData !== null || placeholderConversation !== null;
|
||||||
|
|
||||||
// Clear current conversation data and reset state
|
// Clear current conversation data and reset state
|
||||||
setCurrentConversationId(null);
|
setCurrentConversationId(null);
|
||||||
setPreviousResponseIds({ chat: null, langflow: null });
|
setPreviousResponseIds({ chat: null, langflow: null });
|
||||||
|
|
@ -261,15 +309,22 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
setConversationFilterState(null);
|
setConversationFilterState(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("[CONVERSATION] No default filter set");
|
// No default filter in localStorage
|
||||||
setConversationFilterState(null);
|
if (hasExistingConversation) {
|
||||||
|
// User is manually starting a new conversation - clear the filter
|
||||||
|
console.log("[CONVERSATION] Manual new conversation - clearing filter");
|
||||||
|
setConversationFilterState(null);
|
||||||
|
} else {
|
||||||
|
// First time after onboarding - preserve existing filter if set
|
||||||
|
// This prevents clearing the filter when startNewConversation is called multiple times during onboarding
|
||||||
|
console.log("[CONVERSATION] No default filter set, preserving existing filter if any");
|
||||||
|
// Don't clear the filter - it may have been set by storeDefaultFilterForNewConversations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setConversationFilterState(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary placeholder conversation to show in sidebar
|
// Create a temporary placeholder conversation to show in sidebar
|
||||||
const placeholderConversation: ConversationData = {
|
const newPlaceholderConversation: ConversationData = {
|
||||||
response_id: "new-conversation-" + Date.now(),
|
response_id: "new-conversation-" + Date.now(),
|
||||||
title: "New conversation",
|
title: "New conversation",
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
|
|
@ -284,10 +339,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
last_activity: new Date().toISOString(),
|
last_activity: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setPlaceholderConversation(placeholderConversation);
|
setPlaceholderConversation(newPlaceholderConversation);
|
||||||
// Force immediate refresh to ensure sidebar shows correct state
|
// Force immediate refresh to ensure sidebar shows correct state
|
||||||
refreshConversations(true);
|
refreshConversations(true);
|
||||||
}, [endpoint, refreshConversations]);
|
}, [endpoint, refreshConversations, conversationData, placeholderConversation]);
|
||||||
|
|
||||||
const addConversationDoc = useCallback((filename: string) => {
|
const addConversationDoc = useCallback((filename: string) => {
|
||||||
setConversationDocs((prev) => [
|
setConversationDocs((prev) => [
|
||||||
|
|
@ -375,6 +430,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
setConversationFilter,
|
setConversationFilter,
|
||||||
hasChatError,
|
hasChatError,
|
||||||
setChatError,
|
setChatError,
|
||||||
|
isOnboardingComplete,
|
||||||
|
setOnboardingComplete,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|
@ -396,6 +453,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
conversationFilter,
|
conversationFilter,
|
||||||
setConversationFilter,
|
setConversationFilter,
|
||||||
hasChatError,
|
hasChatError,
|
||||||
|
isOnboardingComplete,
|
||||||
|
setOnboardingComplete,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface UploadFileResult {
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
createFilter?: boolean;
|
createFilter?: boolean;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
|
taskId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function duplicateCheck(
|
export async function duplicateCheck(
|
||||||
|
|
@ -158,6 +159,7 @@ export async function uploadFile(
|
||||||
(uploadIngestJson as { upload?: { id?: string } }).upload?.id ||
|
(uploadIngestJson as { upload?: { id?: string } }).upload?.id ||
|
||||||
(uploadIngestJson as { id?: string }).id ||
|
(uploadIngestJson as { id?: string }).id ||
|
||||||
(uploadIngestJson as { task_id?: string }).task_id;
|
(uploadIngestJson as { task_id?: string }).task_id;
|
||||||
|
const taskId = (uploadIngestJson as { task_id?: string }).task_id;
|
||||||
const filePath =
|
const filePath =
|
||||||
(uploadIngestJson as { upload?: { path?: string } }).upload?.path ||
|
(uploadIngestJson as { upload?: { path?: string } }).upload?.path ||
|
||||||
(uploadIngestJson as { path?: string }).path ||
|
(uploadIngestJson as { path?: string }).path ||
|
||||||
|
|
@ -197,6 +199,7 @@ export async function uploadFile(
|
||||||
raw: uploadIngestJson,
|
raw: uploadIngestJson,
|
||||||
createFilter: shouldCreateFilter,
|
createFilter: shouldCreateFilter,
|
||||||
filename,
|
filename,
|
||||||
|
taskId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
130
frontend/package-lock.json
generated
130
frontend/package-lock.json
generated
|
|
@ -38,7 +38,7 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
"next": "15.3.5",
|
"next": "15.5.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -1169,9 +1169,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||||
"integrity": "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==",
|
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
|
|
@ -1185,9 +1185,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
|
||||||
"integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
|
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1201,9 +1201,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
|
||||||
"integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
|
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1217,9 +1217,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
|
||||||
"integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
|
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1233,9 +1233,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
|
||||||
"integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
|
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1249,9 +1249,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
|
||||||
"integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
|
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1265,9 +1265,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
|
||||||
"integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
|
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1281,9 +1281,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
|
||||||
"integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
|
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1297,9 +1297,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
|
||||||
"integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
|
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2568,12 +2568,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/counter": {
|
|
||||||
"version": "0.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
|
||||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -3821,17 +3815,6 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/busboy": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
|
||||||
"dependencies": {
|
|
||||||
"streamsearch": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
|
|
@ -5448,9 +5431,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"foreground-child": "^3.1.0",
|
"foreground-child": "^3.1.0",
|
||||||
|
|
@ -6584,9 +6567,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -7194,9 +7177,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-to-hast": {
|
"node_modules/mdast-util-to-hast": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.0",
|
"@types/hast": "^3.0.0",
|
||||||
"@types/mdast": "^4.0.0",
|
"@types/mdast": "^4.0.0",
|
||||||
|
|
@ -7973,15 +7957,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.3.5",
|
"version": "15.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||||
"integrity": "sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==",
|
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.3.5",
|
"@next/env": "15.5.7",
|
||||||
"@swc/counter": "0.1.3",
|
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"busboy": "1.6.0",
|
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
"styled-jsx": "5.1.6"
|
"styled-jsx": "5.1.6"
|
||||||
|
|
@ -7993,19 +7975,19 @@
|
||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.3.5",
|
"@next/swc-darwin-arm64": "15.5.7",
|
||||||
"@next/swc-darwin-x64": "15.3.5",
|
"@next/swc-darwin-x64": "15.5.7",
|
||||||
"@next/swc-linux-arm64-gnu": "15.3.5",
|
"@next/swc-linux-arm64-gnu": "15.5.7",
|
||||||
"@next/swc-linux-arm64-musl": "15.3.5",
|
"@next/swc-linux-arm64-musl": "15.5.7",
|
||||||
"@next/swc-linux-x64-gnu": "15.3.5",
|
"@next/swc-linux-x64-gnu": "15.5.7",
|
||||||
"@next/swc-linux-x64-musl": "15.3.5",
|
"@next/swc-linux-x64-musl": "15.5.7",
|
||||||
"@next/swc-win32-arm64-msvc": "15.3.5",
|
"@next/swc-win32-arm64-msvc": "15.5.7",
|
||||||
"@next/swc-win32-x64-msvc": "15.3.5",
|
"@next/swc-win32-x64-msvc": "15.5.7",
|
||||||
"sharp": "^0.34.1"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentelemetry/api": "^1.1.0",
|
"@opentelemetry/api": "^1.1.0",
|
||||||
"@playwright/test": "^1.41.2",
|
"@playwright/test": "^1.51.1",
|
||||||
"babel-plugin-react-compiler": "*",
|
"babel-plugin-react-compiler": "*",
|
||||||
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||||
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||||
|
|
@ -9492,14 +9474,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/streamsearch": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
"next": "15.3.5",
|
"next": "15.5.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
|
||||||
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
|
return new_conversation
|
||||||
|
|
||||||
|
|
||||||
def store_conversation_thread(user_id: str, response_id: str, conversation_state: dict):
|
async def store_conversation_thread(user_id: str, response_id: str, conversation_state: dict):
|
||||||
"""Store conversation both in memory (with function calls) and persist metadata to disk"""
|
"""Store conversation both in memory (with function calls) and persist metadata to disk (async, non-blocking)"""
|
||||||
# 1. Store full conversation in memory for function call preservation
|
# 1. Store full conversation in memory for function call preservation
|
||||||
if user_id not in active_conversations:
|
if user_id not in active_conversations:
|
||||||
active_conversations[user_id] = {}
|
active_conversations[user_id] = {}
|
||||||
|
|
@ -76,7 +76,7 @@ def store_conversation_thread(user_id: str, response_id: str, conversation_state
|
||||||
# Don't store actual messages - Langflow has them
|
# Don't store actual messages - Langflow has them
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation_persistence.store_conversation_thread(
|
await conversation_persistence.store_conversation_thread(
|
||||||
user_id, response_id, metadata_only
|
user_id, response_id, metadata_only
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -382,7 +382,7 @@ async def async_chat(
|
||||||
# Store the conversation thread with its response_id
|
# Store the conversation thread with its response_id
|
||||||
if response_id:
|
if response_id:
|
||||||
conversation_state["last_activity"] = datetime.now()
|
conversation_state["last_activity"] = datetime.now()
|
||||||
store_conversation_thread(user_id, response_id, conversation_state)
|
await store_conversation_thread(user_id, response_id, conversation_state)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Stored conversation thread", user_id=user_id, response_id=response_id
|
"Stored conversation thread", user_id=user_id, response_id=response_id
|
||||||
)
|
)
|
||||||
|
|
@ -461,7 +461,7 @@ async def async_chat_stream(
|
||||||
# Store the conversation thread with its response_id
|
# Store the conversation thread with its response_id
|
||||||
if response_id:
|
if response_id:
|
||||||
conversation_state["last_activity"] = datetime.now()
|
conversation_state["last_activity"] = datetime.now()
|
||||||
store_conversation_thread(user_id, response_id, conversation_state)
|
await store_conversation_thread(user_id, response_id, conversation_state)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Stored conversation thread for user {user_id} with response_id: {response_id}"
|
f"Stored conversation thread for user {user_id} with response_id: {response_id}"
|
||||||
)
|
)
|
||||||
|
|
@ -549,7 +549,7 @@ async def async_langflow_chat(
|
||||||
# Store the conversation thread with its response_id
|
# Store the conversation thread with its response_id
|
||||||
if response_id:
|
if response_id:
|
||||||
conversation_state["last_activity"] = datetime.now()
|
conversation_state["last_activity"] = datetime.now()
|
||||||
store_conversation_thread(user_id, response_id, conversation_state)
|
await store_conversation_thread(user_id, response_id, conversation_state)
|
||||||
|
|
||||||
# Claim session ownership for this user
|
# Claim session ownership for this user
|
||||||
try:
|
try:
|
||||||
|
|
@ -656,7 +656,7 @@ async def async_langflow_chat_stream(
|
||||||
# Store the conversation thread with its response_id
|
# Store the conversation thread with its response_id
|
||||||
if response_id:
|
if response_id:
|
||||||
conversation_state["last_activity"] = datetime.now()
|
conversation_state["last_activity"] = datetime.now()
|
||||||
store_conversation_thread(user_id, response_id, conversation_state)
|
await store_conversation_thread(user_id, response_id, conversation_state)
|
||||||
|
|
||||||
# Claim session ownership for this user
|
# Claim session ownership for this user
|
||||||
try:
|
try:
|
||||||
|
|
@ -672,8 +672,8 @@ async def async_langflow_chat_stream(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def delete_user_conversation(user_id: str, response_id: str) -> bool:
|
async def delete_user_conversation(user_id: str, response_id: str) -> bool:
|
||||||
"""Delete a conversation for a user from both memory and persistent storage"""
|
"""Delete a conversation for a user from both memory and persistent storage (async, non-blocking)"""
|
||||||
deleted = False
|
deleted = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -684,7 +684,7 @@ def delete_user_conversation(user_id: str, response_id: str) -> bool:
|
||||||
deleted = True
|
deleted = True
|
||||||
|
|
||||||
# Delete from persistent storage
|
# Delete from persistent storage
|
||||||
conversation_deleted = conversation_persistence.delete_conversation_thread(user_id, response_id)
|
conversation_deleted = await conversation_persistence.delete_conversation_thread(user_id, response_id)
|
||||||
if conversation_deleted:
|
if conversation_deleted:
|
||||||
logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}")
|
logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}")
|
||||||
deleted = True
|
deleted = True
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Provider validation utilities for testing API keys and models during onboarding."""
|
"""Provider validation utilities for testing API keys and models during onboarding."""
|
||||||
|
|
||||||
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from utils.container_utils import transform_localhost_url
|
from utils.container_utils import transform_localhost_url
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
@ -7,6 +8,106 @@ from utils.logging_config import get_logger
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_error_message(error_text: str) -> str:
|
||||||
|
"""Parse JSON error message and extract just the message field."""
|
||||||
|
try:
|
||||||
|
# Try to parse as JSON
|
||||||
|
error_data = json.loads(error_text)
|
||||||
|
|
||||||
|
if isinstance(error_data, dict):
|
||||||
|
# WatsonX format: {"errors": [{"code": "...", "message": "..."}], ...}
|
||||||
|
if "errors" in error_data and isinstance(error_data["errors"], list):
|
||||||
|
errors = error_data["errors"]
|
||||||
|
if len(errors) > 0 and isinstance(errors[0], dict):
|
||||||
|
message = errors[0].get("message", "")
|
||||||
|
if message:
|
||||||
|
return message
|
||||||
|
code = errors[0].get("code", "")
|
||||||
|
if code:
|
||||||
|
return f"Error: {code}"
|
||||||
|
|
||||||
|
# OpenAI format: {"error": {"message": "...", "type": "...", "code": "..."}}
|
||||||
|
if "error" in error_data:
|
||||||
|
error_obj = error_data["error"]
|
||||||
|
if isinstance(error_obj, dict):
|
||||||
|
message = error_obj.get("message", "")
|
||||||
|
if message:
|
||||||
|
return message
|
||||||
|
|
||||||
|
# Direct message field
|
||||||
|
if "message" in error_data:
|
||||||
|
return error_data["message"]
|
||||||
|
|
||||||
|
# Generic format: {"detail": "..."}
|
||||||
|
if "detail" in error_data:
|
||||||
|
return error_data["detail"]
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Return original text if not JSON or can't parse
|
||||||
|
return error_text
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_error_details(response: httpx.Response) -> str:
|
||||||
|
"""Extract detailed error message from API response."""
|
||||||
|
try:
|
||||||
|
# Try to parse JSON error response
|
||||||
|
error_data = response.json()
|
||||||
|
|
||||||
|
# Common error response formats
|
||||||
|
if isinstance(error_data, dict):
|
||||||
|
# WatsonX format: {"errors": [{"code": "...", "message": "..."}], ...}
|
||||||
|
if "errors" in error_data and isinstance(error_data["errors"], list):
|
||||||
|
errors = error_data["errors"]
|
||||||
|
if len(errors) > 0 and isinstance(errors[0], dict):
|
||||||
|
# Extract just the message from the first error
|
||||||
|
message = errors[0].get("message", "")
|
||||||
|
if message:
|
||||||
|
return message
|
||||||
|
# Fallback to code if no message
|
||||||
|
code = errors[0].get("code", "")
|
||||||
|
if code:
|
||||||
|
return f"Error: {code}"
|
||||||
|
|
||||||
|
# OpenAI format: {"error": {"message": "...", "type": "...", "code": "..."}}
|
||||||
|
if "error" in error_data:
|
||||||
|
error_obj = error_data["error"]
|
||||||
|
if isinstance(error_obj, dict):
|
||||||
|
message = error_obj.get("message", "")
|
||||||
|
error_type = error_obj.get("type", "")
|
||||||
|
code = error_obj.get("code", "")
|
||||||
|
if message:
|
||||||
|
details = message
|
||||||
|
if error_type:
|
||||||
|
details += f" (type: {error_type})"
|
||||||
|
if code:
|
||||||
|
details += f" (code: {code})"
|
||||||
|
return details
|
||||||
|
|
||||||
|
# Anthropic format: {"error": {"message": "...", "type": "..."}}
|
||||||
|
if "message" in error_data:
|
||||||
|
return error_data["message"]
|
||||||
|
|
||||||
|
# Generic format: {"message": "..."}
|
||||||
|
if "detail" in error_data:
|
||||||
|
return error_data["detail"]
|
||||||
|
|
||||||
|
# If JSON parsing worked but no structured error found, try parsing text
|
||||||
|
response_text = response.text[:500]
|
||||||
|
parsed = _parse_json_error_message(response_text)
|
||||||
|
if parsed != response_text:
|
||||||
|
return parsed
|
||||||
|
return response_text
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# If JSON parsing fails, try parsing the text as JSON string
|
||||||
|
response_text = response.text[:500] if response.text else f"HTTP {response.status_code}"
|
||||||
|
parsed = _parse_json_error_message(response_text)
|
||||||
|
if parsed != response_text:
|
||||||
|
return parsed
|
||||||
|
return response_text
|
||||||
|
|
||||||
|
|
||||||
async def validate_provider_setup(
|
async def validate_provider_setup(
|
||||||
provider: str,
|
provider: str,
|
||||||
api_key: str = None,
|
api_key: str = None,
|
||||||
|
|
@ -30,7 +131,7 @@ async def validate_provider_setup(
|
||||||
If False, performs lightweight validation (no credits consumed). Default: False.
|
If False, performs lightweight validation (no credits consumed). Default: False.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If validation fails with message "Setup failed, please try again or select a different provider."
|
Exception: If validation fails, raises the original exception with the actual error message.
|
||||||
"""
|
"""
|
||||||
provider_lower = provider.lower()
|
provider_lower = provider.lower()
|
||||||
|
|
||||||
|
|
@ -70,7 +171,8 @@ async def validate_provider_setup(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Validation failed for provider {provider_lower}: {str(e)}")
|
logger.error(f"Validation failed for provider {provider_lower}: {str(e)}")
|
||||||
raise Exception("Setup failed, please try again or select a different provider.")
|
# Preserve the original error message instead of replacing it with a generic one
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def test_lightweight_health(
|
async def test_lightweight_health(
|
||||||
|
|
@ -155,8 +257,9 @@ async def _test_openai_lightweight_health(api_key: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"OpenAI lightweight health check failed: {response.status_code}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"OpenAI API key validation failed: {response.status_code}")
|
logger.error(f"OpenAI lightweight health check failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"OpenAI API key validation failed: {error_details}")
|
||||||
|
|
||||||
logger.info("OpenAI lightweight health check passed")
|
logger.info("OpenAI lightweight health check passed")
|
||||||
|
|
||||||
|
|
@ -225,8 +328,9 @@ async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> No
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"OpenAI completion test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"OpenAI API error: {response.status_code}")
|
logger.error(f"OpenAI completion test failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"OpenAI API error: {error_details}")
|
||||||
|
|
||||||
logger.info("OpenAI completion with tool calling test passed")
|
logger.info("OpenAI completion with tool calling test passed")
|
||||||
|
|
||||||
|
|
@ -260,8 +364,9 @@ async def _test_openai_embedding(api_key: str, embedding_model: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"OpenAI embedding test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"OpenAI API error: {response.status_code}")
|
logger.error(f"OpenAI embedding test failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"OpenAI API error: {error_details}")
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if not data.get("data") or len(data["data"]) == 0:
|
if not data.get("data") or len(data["data"]) == 0:
|
||||||
|
|
@ -300,8 +405,9 @@ async def _test_watsonx_lightweight_health(
|
||||||
)
|
)
|
||||||
|
|
||||||
if token_response.status_code != 200:
|
if token_response.status_code != 200:
|
||||||
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
|
error_details = _extract_error_details(token_response)
|
||||||
raise Exception("Failed to authenticate with IBM Watson - invalid API key")
|
logger.error(f"IBM IAM token request failed: {token_response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Failed to authenticate with IBM Watson: {error_details}")
|
||||||
|
|
||||||
bearer_token = token_response.json().get("access_token")
|
bearer_token = token_response.json().get("access_token")
|
||||||
if not bearer_token:
|
if not bearer_token:
|
||||||
|
|
@ -335,8 +441,9 @@ async def _test_watsonx_completion_with_tools(
|
||||||
)
|
)
|
||||||
|
|
||||||
if token_response.status_code != 200:
|
if token_response.status_code != 200:
|
||||||
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
|
error_details = _extract_error_details(token_response)
|
||||||
raise Exception("Failed to authenticate with IBM Watson")
|
logger.error(f"IBM IAM token request failed: {token_response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Failed to authenticate with IBM Watson: {error_details}")
|
||||||
|
|
||||||
bearer_token = token_response.json().get("access_token")
|
bearer_token = token_response.json().get("access_token")
|
||||||
if not bearer_token:
|
if not bearer_token:
|
||||||
|
|
@ -388,8 +495,11 @@ async def _test_watsonx_completion_with_tools(
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"IBM Watson completion test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"IBM Watson API error: {response.status_code}")
|
logger.error(f"IBM Watson completion test failed: {response.status_code} - {error_details}")
|
||||||
|
# If error_details is still JSON, parse it to extract just the message
|
||||||
|
parsed_details = _parse_json_error_message(error_details)
|
||||||
|
raise Exception(f"IBM Watson API error: {parsed_details}")
|
||||||
|
|
||||||
logger.info("IBM Watson completion with tool calling test passed")
|
logger.info("IBM Watson completion with tool calling test passed")
|
||||||
|
|
||||||
|
|
@ -398,6 +508,13 @@ async def _test_watsonx_completion_with_tools(
|
||||||
raise Exception("Request timed out")
|
raise Exception("Request timed out")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"IBM Watson completion test failed: {str(e)}")
|
logger.error(f"IBM Watson completion test failed: {str(e)}")
|
||||||
|
# If the error message contains JSON, parse it to extract just the message
|
||||||
|
error_str = str(e)
|
||||||
|
if "IBM Watson API error: " in error_str:
|
||||||
|
json_part = error_str.split("IBM Watson API error: ", 1)[1]
|
||||||
|
parsed_message = _parse_json_error_message(json_part)
|
||||||
|
if parsed_message != json_part:
|
||||||
|
raise Exception(f"IBM Watson API error: {parsed_message}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -419,8 +536,9 @@ async def _test_watsonx_embedding(
|
||||||
)
|
)
|
||||||
|
|
||||||
if token_response.status_code != 200:
|
if token_response.status_code != 200:
|
||||||
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
|
error_details = _extract_error_details(token_response)
|
||||||
raise Exception("Failed to authenticate with IBM Watson")
|
logger.error(f"IBM IAM token request failed: {token_response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Failed to authenticate with IBM Watson: {error_details}")
|
||||||
|
|
||||||
bearer_token = token_response.json().get("access_token")
|
bearer_token = token_response.json().get("access_token")
|
||||||
if not bearer_token:
|
if not bearer_token:
|
||||||
|
|
@ -450,8 +568,11 @@ async def _test_watsonx_embedding(
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"IBM Watson embedding test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"IBM Watson API error: {response.status_code}")
|
logger.error(f"IBM Watson embedding test failed: {response.status_code} - {error_details}")
|
||||||
|
# If error_details is still JSON, parse it to extract just the message
|
||||||
|
parsed_details = _parse_json_error_message(error_details)
|
||||||
|
raise Exception(f"IBM Watson API error: {parsed_details}")
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if not data.get("results") or len(data["results"]) == 0:
|
if not data.get("results") or len(data["results"]) == 0:
|
||||||
|
|
@ -464,6 +585,13 @@ async def _test_watsonx_embedding(
|
||||||
raise Exception("Request timed out")
|
raise Exception("Request timed out")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"IBM Watson embedding test failed: {str(e)}")
|
logger.error(f"IBM Watson embedding test failed: {str(e)}")
|
||||||
|
# If the error message contains JSON, parse it to extract just the message
|
||||||
|
error_str = str(e)
|
||||||
|
if "IBM Watson API error: " in error_str:
|
||||||
|
json_part = error_str.split("IBM Watson API error: ", 1)[1]
|
||||||
|
parsed_message = _parse_json_error_message(json_part)
|
||||||
|
if parsed_message != json_part:
|
||||||
|
raise Exception(f"IBM Watson API error: {parsed_message}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -483,8 +611,9 @@ async def _test_ollama_lightweight_health(endpoint: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Ollama lightweight health check failed: {response.status_code}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"Ollama endpoint not responding: {response.status_code}")
|
logger.error(f"Ollama lightweight health check failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Ollama endpoint not responding: {error_details}")
|
||||||
|
|
||||||
logger.info("Ollama lightweight health check passed")
|
logger.info("Ollama lightweight health check passed")
|
||||||
|
|
||||||
|
|
@ -537,8 +666,9 @@ async def _test_ollama_completion_with_tools(llm_model: str, endpoint: str) -> N
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Ollama completion test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"Ollama API error: {response.status_code}")
|
logger.error(f"Ollama completion test failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Ollama API error: {error_details}")
|
||||||
|
|
||||||
logger.info("Ollama completion with tool calling test passed")
|
logger.info("Ollama completion with tool calling test passed")
|
||||||
|
|
||||||
|
|
@ -569,8 +699,9 @@ async def _test_ollama_embedding(embedding_model: str, endpoint: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Ollama embedding test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"Ollama API error: {response.status_code}")
|
logger.error(f"Ollama embedding test failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Ollama API error: {error_details}")
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if not data.get("embedding"):
|
if not data.get("embedding"):
|
||||||
|
|
@ -616,8 +747,9 @@ async def _test_anthropic_lightweight_health(api_key: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Anthropic lightweight health check failed: {response.status_code}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"Anthropic API key validation failed: {response.status_code}")
|
logger.error(f"Anthropic lightweight health check failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Anthropic API key validation failed: {error_details}")
|
||||||
|
|
||||||
logger.info("Anthropic lightweight health check passed")
|
logger.info("Anthropic lightweight health check passed")
|
||||||
|
|
||||||
|
|
@ -672,8 +804,9 @@ async def _test_anthropic_completion_with_tools(api_key: str, llm_model: str) ->
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Anthropic completion test failed: {response.status_code} - {response.text}")
|
error_details = _extract_error_details(response)
|
||||||
raise Exception(f"Anthropic API error: {response.status_code}")
|
logger.error(f"Anthropic completion test failed: {response.status_code} - {error_details}")
|
||||||
|
raise Exception(f"Anthropic API error: {error_details}")
|
||||||
|
|
||||||
logger.info("Anthropic completion with tool calling test passed")
|
logger.info("Anthropic completion with tool calling test passed")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -897,7 +897,7 @@ async def onboarding(request, flows_service, session_manager=None):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate provider setup before initializing OpenSearch index
|
# Validate provider setup before initializing OpenSearch index
|
||||||
# Use lightweight validation (test_completion=False) to avoid consuming credits during onboarding
|
# Use full validation with completion tests (test_completion=True) to ensure provider health during onboarding
|
||||||
try:
|
try:
|
||||||
from api.provider_validation import validate_provider_setup
|
from api.provider_validation import validate_provider_setup
|
||||||
|
|
||||||
|
|
@ -906,14 +906,14 @@ async def onboarding(request, flows_service, session_manager=None):
|
||||||
llm_provider = current_config.agent.llm_provider.lower()
|
llm_provider = current_config.agent.llm_provider.lower()
|
||||||
llm_provider_config = current_config.get_llm_provider_config()
|
llm_provider_config = current_config.get_llm_provider_config()
|
||||||
|
|
||||||
logger.info(f"Validating LLM provider setup for {llm_provider} (lightweight)")
|
logger.info(f"Validating LLM provider setup for {llm_provider} (full validation with completion test)")
|
||||||
await validate_provider_setup(
|
await validate_provider_setup(
|
||||||
provider=llm_provider,
|
provider=llm_provider,
|
||||||
api_key=getattr(llm_provider_config, "api_key", None),
|
api_key=getattr(llm_provider_config, "api_key", None),
|
||||||
llm_model=current_config.agent.llm_model,
|
llm_model=current_config.agent.llm_model,
|
||||||
endpoint=getattr(llm_provider_config, "endpoint", None),
|
endpoint=getattr(llm_provider_config, "endpoint", None),
|
||||||
project_id=getattr(llm_provider_config, "project_id", None),
|
project_id=getattr(llm_provider_config, "project_id", None),
|
||||||
test_completion=False, # Lightweight validation - no credits consumed
|
test_completion=True, # Full validation with completion test - ensures provider health
|
||||||
)
|
)
|
||||||
logger.info(f"LLM provider setup validation completed successfully for {llm_provider}")
|
logger.info(f"LLM provider setup validation completed successfully for {llm_provider}")
|
||||||
|
|
||||||
|
|
@ -922,14 +922,14 @@ async def onboarding(request, flows_service, session_manager=None):
|
||||||
embedding_provider = current_config.knowledge.embedding_provider.lower()
|
embedding_provider = current_config.knowledge.embedding_provider.lower()
|
||||||
embedding_provider_config = current_config.get_embedding_provider_config()
|
embedding_provider_config = current_config.get_embedding_provider_config()
|
||||||
|
|
||||||
logger.info(f"Validating embedding provider setup for {embedding_provider} (lightweight)")
|
logger.info(f"Validating embedding provider setup for {embedding_provider} (full validation with completion test)")
|
||||||
await validate_provider_setup(
|
await validate_provider_setup(
|
||||||
provider=embedding_provider,
|
provider=embedding_provider,
|
||||||
api_key=getattr(embedding_provider_config, "api_key", None),
|
api_key=getattr(embedding_provider_config, "api_key", None),
|
||||||
embedding_model=current_config.knowledge.embedding_model,
|
embedding_model=current_config.knowledge.embedding_model,
|
||||||
endpoint=getattr(embedding_provider_config, "endpoint", None),
|
endpoint=getattr(embedding_provider_config, "endpoint", None),
|
||||||
project_id=getattr(embedding_provider_config, "project_id", None),
|
project_id=getattr(embedding_provider_config, "project_id", None),
|
||||||
test_completion=False, # Lightweight validation - no credits consumed
|
test_completion=True, # Full validation with completion test - ensures provider health
|
||||||
)
|
)
|
||||||
logger.info(f"Embedding provider setup validation completed successfully for {embedding_provider}")
|
logger.info(f"Embedding provider setup validation completed successfully for {embedding_provider}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1403,6 +1403,139 @@ async def reapply_all_settings(session_manager = None):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def rollback_onboarding(request, session_manager, task_service):
|
||||||
|
"""Rollback onboarding configuration when sample data files fail.
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Cancel all active tasks
|
||||||
|
2. Delete successfully ingested knowledge documents
|
||||||
|
3. Reset configuration to allow re-onboarding
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get current configuration
|
||||||
|
current_config = get_openrag_config()
|
||||||
|
|
||||||
|
# Only allow rollback if config was marked as edited (onboarding completed)
|
||||||
|
if not current_config.edited:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "No onboarding configuration to rollback"}, status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
user = request.state.user
|
||||||
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
|
logger.info("Rolling back onboarding configuration due to file failures")
|
||||||
|
|
||||||
|
# Get all tasks for the user
|
||||||
|
all_tasks = task_service.get_all_tasks(user.user_id)
|
||||||
|
|
||||||
|
cancelled_tasks = []
|
||||||
|
deleted_files = []
|
||||||
|
|
||||||
|
# Cancel all active tasks and collect successfully ingested files
|
||||||
|
for task_data in all_tasks:
|
||||||
|
task_id = task_data.get("task_id")
|
||||||
|
task_status = task_data.get("status")
|
||||||
|
|
||||||
|
# Cancel active tasks (pending, running, processing)
|
||||||
|
if task_status in ["pending", "running", "processing"]:
|
||||||
|
try:
|
||||||
|
success = await task_service.cancel_task(user.user_id, task_id)
|
||||||
|
if success:
|
||||||
|
cancelled_tasks.append(task_id)
|
||||||
|
logger.info(f"Cancelled task {task_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to cancel task {task_id}: {str(e)}")
|
||||||
|
|
||||||
|
# For completed tasks, find successfully ingested files and delete them
|
||||||
|
elif task_status == "completed":
|
||||||
|
files = task_data.get("files", {})
|
||||||
|
if isinstance(files, dict):
|
||||||
|
for file_path, file_info in files.items():
|
||||||
|
# Check if file was successfully ingested
|
||||||
|
if isinstance(file_info, dict):
|
||||||
|
file_status = file_info.get("status")
|
||||||
|
filename = file_info.get("filename") or file_path.split("/")[-1]
|
||||||
|
|
||||||
|
if file_status == "completed" and filename:
|
||||||
|
try:
|
||||||
|
# Get user's OpenSearch client
|
||||||
|
opensearch_client = session_manager.get_user_opensearch_client(
|
||||||
|
user.user_id, jwt_token
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete documents by filename
|
||||||
|
from utils.opensearch_queries import build_filename_delete_body
|
||||||
|
from config.settings import INDEX_NAME
|
||||||
|
|
||||||
|
delete_query = build_filename_delete_body(filename)
|
||||||
|
|
||||||
|
result = await opensearch_client.delete_by_query(
|
||||||
|
index=INDEX_NAME,
|
||||||
|
body=delete_query,
|
||||||
|
conflicts="proceed"
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = result.get("deleted", 0)
|
||||||
|
if deleted_count > 0:
|
||||||
|
deleted_files.append(filename)
|
||||||
|
logger.info(f"Deleted {deleted_count} chunks for filename {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete documents for {filename}: {str(e)}")
|
||||||
|
|
||||||
|
# Clear embedding provider and model settings
|
||||||
|
current_config.knowledge.embedding_provider = "openai" # Reset to default
|
||||||
|
current_config.knowledge.embedding_model = ""
|
||||||
|
|
||||||
|
# Mark config as not edited so user can go through onboarding again
|
||||||
|
current_config.edited = False
|
||||||
|
|
||||||
|
# Save the rolled back configuration manually to avoid save_config_file setting edited=True
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
config_file = config_manager.config_file
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save config with edited=False
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(current_config.to_dict(), f, default_flow_style=False, indent=2)
|
||||||
|
|
||||||
|
# Update cached config
|
||||||
|
config_manager._config = current_config
|
||||||
|
|
||||||
|
logger.info("Successfully saved rolled back configuration with edited=False")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save rolled back configuration: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Failed to save rolled back configuration"}, status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully rolled back onboarding configuration. "
|
||||||
|
f"Cancelled {len(cancelled_tasks)} tasks, deleted {len(deleted_files)} files"
|
||||||
|
)
|
||||||
|
await TelemetryClient.send_event(
|
||||||
|
Category.ONBOARDING,
|
||||||
|
MessageId.ORB_ONBOARD_ROLLBACK
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"message": "Onboarding configuration rolled back successfully",
|
||||||
|
"cancelled_tasks": len(cancelled_tasks),
|
||||||
|
"deleted_files": len(deleted_files),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to rollback onboarding configuration", error=str(e))
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": f"Failed to rollback onboarding: {str(e)}"}, status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_docling_preset(request, session_manager):
|
async def update_docling_preset(request, session_manager):
|
||||||
"""Update docling settings in the ingest flow - deprecated endpoint, use /settings instead"""
|
"""Update docling settings in the ingest flow - deprecated endpoint, use /settings instead"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
@ -140,61 +141,29 @@ INDEX_BODY = {
|
||||||
LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1"
|
LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
async def generate_langflow_api_key(modify: bool = False):
|
async def get_langflow_api_key(force_regenerate: bool = False):
|
||||||
"""Generate Langflow API key using superuser credentials at startup"""
|
"""Get the Langflow API key, generating one if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_regenerate: If True, generates a new key even if one is cached.
|
||||||
|
Used when a request fails with 401/403 to get a fresh key.
|
||||||
|
"""
|
||||||
global LANGFLOW_KEY
|
global LANGFLOW_KEY
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"generate_langflow_api_key called", current_key_present=bool(LANGFLOW_KEY)
|
"get_langflow_api_key called",
|
||||||
|
current_key_present=bool(LANGFLOW_KEY),
|
||||||
|
force_regenerate=force_regenerate,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If key already provided via env, do not attempt generation
|
# If we have a cached key and not forcing regeneration, return it
|
||||||
if LANGFLOW_KEY:
|
if LANGFLOW_KEY and not force_regenerate:
|
||||||
if os.getenv("LANGFLOW_KEY"):
|
return LANGFLOW_KEY
|
||||||
logger.info("Using LANGFLOW_KEY from environment; skipping generation")
|
|
||||||
return LANGFLOW_KEY
|
# If forcing regeneration, clear the cached key
|
||||||
else:
|
if force_regenerate and LANGFLOW_KEY:
|
||||||
# We have a cached key, but let's validate it first
|
logger.info("Forcing Langflow API key regeneration due to auth failure")
|
||||||
logger.debug("Validating cached LANGFLOW_KEY", key_prefix=LANGFLOW_KEY[:8])
|
LANGFLOW_KEY = None
|
||||||
try:
|
|
||||||
validation_response = requests.get(
|
|
||||||
f"{LANGFLOW_URL}/api/v1/users/whoami",
|
|
||||||
headers={"x-api-key": LANGFLOW_KEY},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if validation_response.status_code == 200:
|
|
||||||
logger.debug("Cached API key is valid", key_prefix=LANGFLOW_KEY[:8])
|
|
||||||
return LANGFLOW_KEY
|
|
||||||
elif validation_response.status_code in (401, 403):
|
|
||||||
logger.warning(
|
|
||||||
"Cached API key is unauthorized, generating fresh key",
|
|
||||||
status_code=validation_response.status_code,
|
|
||||||
)
|
|
||||||
LANGFLOW_KEY = None # Clear invalid key
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Cached API key validation returned non-access error; keeping existing key",
|
|
||||||
status_code=validation_response.status_code,
|
|
||||||
)
|
|
||||||
return LANGFLOW_KEY
|
|
||||||
except requests.exceptions.Timeout as e:
|
|
||||||
logger.warning(
|
|
||||||
"Cached API key validation timed out; keeping existing key",
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
return LANGFLOW_KEY
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.warning(
|
|
||||||
"Cached API key validation failed due to request error; keeping existing key",
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
return LANGFLOW_KEY
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Unexpected error during cached API key validation; keeping existing key",
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
return LANGFLOW_KEY
|
|
||||||
|
|
||||||
# Use default langflow/langflow credentials if auto-login is enabled and credentials not set
|
# Use default langflow/langflow credentials if auto-login is enabled and credentials not set
|
||||||
username = LANGFLOW_SUPERUSER
|
username = LANGFLOW_SUPERUSER
|
||||||
|
|
@ -216,72 +185,70 @@ async def generate_langflow_api_key(modify: bool = False):
|
||||||
max_attempts = int(os.getenv("LANGFLOW_KEY_RETRIES", "15"))
|
max_attempts = int(os.getenv("LANGFLOW_KEY_RETRIES", "15"))
|
||||||
delay_seconds = float(os.getenv("LANGFLOW_KEY_RETRY_DELAY", "2.0"))
|
delay_seconds = float(os.getenv("LANGFLOW_KEY_RETRY_DELAY", "2.0"))
|
||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
try:
|
for attempt in range(1, max_attempts + 1):
|
||||||
# Login to get access token
|
try:
|
||||||
login_response = requests.post(
|
# Login to get access token
|
||||||
f"{LANGFLOW_URL}/api/v1/login",
|
login_response = await client.post(
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
f"{LANGFLOW_URL}/api/v1/login",
|
||||||
data={
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
"username": username,
|
data={
|
||||||
"password": password,
|
"username": username,
|
||||||
},
|
"password": password,
|
||||||
timeout=10,
|
},
|
||||||
)
|
|
||||||
login_response.raise_for_status()
|
|
||||||
access_token = login_response.json().get("access_token")
|
|
||||||
if not access_token:
|
|
||||||
raise KeyError("access_token")
|
|
||||||
|
|
||||||
# Create API key
|
|
||||||
api_key_response = requests.post(
|
|
||||||
f"{LANGFLOW_URL}/api/v1/api_key/",
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {access_token}",
|
|
||||||
},
|
|
||||||
json={"name": "openrag-auto-generated"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
api_key_response.raise_for_status()
|
|
||||||
api_key = api_key_response.json().get("api_key")
|
|
||||||
if not api_key:
|
|
||||||
raise KeyError("api_key")
|
|
||||||
|
|
||||||
# Validate the API key works
|
|
||||||
validation_response = requests.get(
|
|
||||||
f"{LANGFLOW_URL}/api/v1/users/whoami",
|
|
||||||
headers={"x-api-key": api_key},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if validation_response.status_code == 200:
|
|
||||||
LANGFLOW_KEY = api_key
|
|
||||||
logger.info(
|
|
||||||
"Successfully generated and validated Langflow API key",
|
|
||||||
key_prefix=api_key[:8],
|
|
||||||
)
|
)
|
||||||
return api_key
|
login_response.raise_for_status()
|
||||||
else:
|
access_token = login_response.json().get("access_token")
|
||||||
logger.error(
|
if not access_token:
|
||||||
"Generated API key validation failed",
|
raise KeyError("access_token")
|
||||||
status_code=validation_response.status_code,
|
|
||||||
)
|
|
||||||
raise ValueError(
|
|
||||||
f"API key validation failed: {validation_response.status_code}"
|
|
||||||
)
|
|
||||||
except (requests.exceptions.RequestException, KeyError) as e:
|
|
||||||
logger.warning(
|
|
||||||
"Attempt to generate Langflow API key failed",
|
|
||||||
attempt=attempt,
|
|
||||||
max_attempts=max_attempts,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
if attempt < max_attempts:
|
|
||||||
time.sleep(delay_seconds)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
# Create API key
|
||||||
|
api_key_response = await client.post(
|
||||||
|
f"{LANGFLOW_URL}/api/v1/api_key/",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
},
|
||||||
|
json={"name": "openrag-auto-generated"},
|
||||||
|
)
|
||||||
|
api_key_response.raise_for_status()
|
||||||
|
api_key = api_key_response.json().get("api_key")
|
||||||
|
if not api_key:
|
||||||
|
raise KeyError("api_key")
|
||||||
|
|
||||||
|
# Validate the API key works
|
||||||
|
validation_response = await client.get(
|
||||||
|
f"{LANGFLOW_URL}/api/v1/users/whoami",
|
||||||
|
headers={"x-api-key": api_key},
|
||||||
|
)
|
||||||
|
if validation_response.status_code == 200:
|
||||||
|
LANGFLOW_KEY = api_key
|
||||||
|
logger.info(
|
||||||
|
"Successfully generated and validated Langflow API key",
|
||||||
|
key_prefix=api_key[:8],
|
||||||
|
)
|
||||||
|
return api_key
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Generated API key validation failed",
|
||||||
|
status_code=validation_response.status_code,
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"API key validation failed: {validation_response.status_code}"
|
||||||
|
)
|
||||||
|
except (httpx.HTTPStatusError, httpx.RequestError, KeyError) as e:
|
||||||
|
logger.warning(
|
||||||
|
"Attempt to generate Langflow API key failed",
|
||||||
|
attempt=attempt,
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
if attempt < max_attempts:
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except (httpx.HTTPStatusError, httpx.RequestError) as e:
|
||||||
logger.error("Failed to generate Langflow API key", error=str(e))
|
logger.error("Failed to generate Langflow API key", error=str(e))
|
||||||
return None
|
return None
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
|
|
@ -303,7 +270,7 @@ class AppClients:
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
# Generate Langflow API key first
|
# Generate Langflow API key first
|
||||||
await generate_langflow_api_key()
|
await get_langflow_api_key()
|
||||||
|
|
||||||
# Initialize OpenSearch client
|
# Initialize OpenSearch client
|
||||||
self.opensearch = AsyncOpenSearch(
|
self.opensearch = AsyncOpenSearch(
|
||||||
|
|
@ -362,7 +329,7 @@ class AppClients:
|
||||||
if self.langflow_client is not None:
|
if self.langflow_client is not None:
|
||||||
return self.langflow_client
|
return self.langflow_client
|
||||||
# Try generating key again (with retries)
|
# Try generating key again (with retries)
|
||||||
await generate_langflow_api_key()
|
await get_langflow_api_key()
|
||||||
if LANGFLOW_KEY and self.langflow_client is None:
|
if LANGFLOW_KEY and self.langflow_client is None:
|
||||||
try:
|
try:
|
||||||
self.langflow_client = AsyncOpenAI(
|
self.langflow_client = AsyncOpenAI(
|
||||||
|
|
@ -559,8 +526,11 @@ class AppClients:
|
||||||
self.langflow_client = None
|
self.langflow_client = None
|
||||||
|
|
||||||
async def langflow_request(self, method: str, endpoint: str, **kwargs):
|
async def langflow_request(self, method: str, endpoint: str, **kwargs):
|
||||||
"""Central method for all Langflow API requests"""
|
"""Central method for all Langflow API requests.
|
||||||
api_key = await generate_langflow_api_key()
|
|
||||||
|
Retries once with a fresh API key on auth failures (401/403).
|
||||||
|
"""
|
||||||
|
api_key = await get_langflow_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ValueError("No Langflow API key available")
|
raise ValueError("No Langflow API key available")
|
||||||
|
|
||||||
|
|
@ -575,57 +545,65 @@ class AppClients:
|
||||||
|
|
||||||
url = f"{LANGFLOW_URL}{endpoint}"
|
url = f"{LANGFLOW_URL}{endpoint}"
|
||||||
|
|
||||||
return await self.langflow_http_client.request(
|
response = await self.langflow_http_client.request(
|
||||||
method=method, url=url, headers=headers, **kwargs
|
method=method, url=url, headers=headers, **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Retry once with a fresh API key on auth failure
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
"Langflow request auth failed, regenerating API key and retrying",
|
||||||
|
status_code=response.status_code,
|
||||||
|
endpoint=endpoint,
|
||||||
|
)
|
||||||
|
api_key = await get_langflow_api_key(force_regenerate=True)
|
||||||
|
if api_key:
|
||||||
|
headers["x-api-key"] = api_key
|
||||||
|
response = await self.langflow_http_client.request(
|
||||||
|
method=method, url=url, headers=headers, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
async def _create_langflow_global_variable(
|
async def _create_langflow_global_variable(
|
||||||
self, name: str, value: str, modify: bool = False
|
self, name: str, value: str, modify: bool = False
|
||||||
):
|
):
|
||||||
"""Create a global variable in Langflow via API"""
|
"""Create a global variable in Langflow via API"""
|
||||||
api_key = await generate_langflow_api_key()
|
|
||||||
if not api_key:
|
|
||||||
logger.warning(
|
|
||||||
"Cannot create Langflow global variable: No API key", variable_name=name
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
url = f"{LANGFLOW_URL}/api/v1/variables/"
|
|
||||||
payload = {
|
payload = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"value": value,
|
"value": value,
|
||||||
"default_fields": [],
|
"default_fields": [],
|
||||||
"type": "Credential",
|
"type": "Credential",
|
||||||
}
|
}
|
||||||
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
response = await self.langflow_request(
|
||||||
response = await client.post(url, headers=headers, json=payload)
|
"POST", "/api/v1/variables/", json=payload
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
|
logger.info(
|
||||||
|
"Successfully created Langflow global variable",
|
||||||
|
variable_name=name,
|
||||||
|
)
|
||||||
|
elif response.status_code == 400 and "already exists" in response.text:
|
||||||
|
if modify:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Successfully created Langflow global variable",
|
"Langflow global variable already exists, attempting to update",
|
||||||
variable_name=name,
|
variable_name=name,
|
||||||
)
|
)
|
||||||
elif response.status_code == 400 and "already exists" in response.text:
|
await self._update_langflow_global_variable(name, value)
|
||||||
if modify:
|
|
||||||
logger.info(
|
|
||||||
"Langflow global variable already exists, attempting to update",
|
|
||||||
variable_name=name,
|
|
||||||
)
|
|
||||||
await self._update_langflow_global_variable(name, value)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Langflow global variable already exists",
|
|
||||||
variable_name=name,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.info(
|
||||||
"Failed to create Langflow global variable",
|
"Langflow global variable already exists",
|
||||||
variable_name=name,
|
variable_name=name,
|
||||||
status_code=response.status_code,
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create Langflow global variable",
|
||||||
|
variable_name=name,
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Exception creating Langflow global variable",
|
"Exception creating Langflow global variable",
|
||||||
|
|
@ -635,76 +613,62 @@ class AppClients:
|
||||||
|
|
||||||
async def _update_langflow_global_variable(self, name: str, value: str):
|
async def _update_langflow_global_variable(self, name: str, value: str):
|
||||||
"""Update an existing global variable in Langflow via API"""
|
"""Update an existing global variable in Langflow via API"""
|
||||||
api_key = await generate_langflow_api_key()
|
|
||||||
if not api_key:
|
|
||||||
logger.warning(
|
|
||||||
"Cannot update Langflow global variable: No API key", variable_name=name
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
# First, get all variables to find the one with the matching name
|
||||||
# First, get all variables to find the one with the matching name
|
get_response = await self.langflow_request("GET", "/api/v1/variables/")
|
||||||
get_response = await client.get(
|
|
||||||
f"{LANGFLOW_URL}/api/v1/variables/", headers=headers
|
if get_response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"Failed to retrieve variables for update",
|
||||||
|
variable_name=name,
|
||||||
|
status_code=get_response.status_code,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if get_response.status_code != 200:
|
variables = get_response.json()
|
||||||
logger.error(
|
target_variable = None
|
||||||
"Failed to retrieve variables for update",
|
|
||||||
variable_name=name,
|
|
||||||
status_code=get_response.status_code,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
variables = get_response.json()
|
# Find the variable with matching name
|
||||||
target_variable = None
|
for variable in variables:
|
||||||
|
if variable.get("name") == name:
|
||||||
|
target_variable = variable
|
||||||
|
break
|
||||||
|
|
||||||
# Find the variable with matching name
|
if not target_variable:
|
||||||
for variable in variables:
|
logger.error("Variable not found for update", variable_name=name)
|
||||||
if variable.get("name") == name:
|
return
|
||||||
target_variable = variable
|
|
||||||
break
|
|
||||||
|
|
||||||
if not target_variable:
|
variable_id = target_variable.get("id")
|
||||||
logger.error("Variable not found for update", variable_name=name)
|
if not variable_id:
|
||||||
return
|
logger.error("Variable ID not found for update", variable_name=name)
|
||||||
|
return
|
||||||
|
|
||||||
variable_id = target_variable.get("id")
|
# Update the variable using PATCH
|
||||||
if not variable_id:
|
update_payload = {
|
||||||
logger.error("Variable ID not found for update", variable_name=name)
|
"id": variable_id,
|
||||||
return
|
"name": name,
|
||||||
|
"value": value,
|
||||||
|
"default_fields": target_variable.get("default_fields", []),
|
||||||
|
}
|
||||||
|
|
||||||
# Update the variable using PATCH
|
patch_response = await self.langflow_request(
|
||||||
update_payload = {
|
"PATCH", f"/api/v1/variables/{variable_id}", json=update_payload
|
||||||
"id": variable_id,
|
)
|
||||||
"name": name,
|
|
||||||
"value": value,
|
|
||||||
"default_fields": target_variable.get("default_fields", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
patch_response = await client.patch(
|
if patch_response.status_code == 200:
|
||||||
f"{LANGFLOW_URL}/api/v1/variables/{variable_id}",
|
logger.info(
|
||||||
headers=headers,
|
"Successfully updated Langflow global variable",
|
||||||
json=update_payload,
|
variable_name=name,
|
||||||
|
variable_id=variable_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update Langflow global variable",
|
||||||
|
variable_name=name,
|
||||||
|
variable_id=variable_id,
|
||||||
|
status_code=patch_response.status_code,
|
||||||
|
response_text=patch_response.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if patch_response.status_code == 200:
|
|
||||||
logger.info(
|
|
||||||
"Successfully updated Langflow global variable",
|
|
||||||
variable_name=name,
|
|
||||||
variable_id=variable_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to update Langflow global variable",
|
|
||||||
variable_name=name,
|
|
||||||
variable_id=variable_id,
|
|
||||||
status_code=patch_response.status_code,
|
|
||||||
response_text=patch_response.text,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
12
src/main.py
12
src/main.py
|
|
@ -1179,6 +1179,18 @@ async def create_app():
|
||||||
),
|
),
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
),
|
),
|
||||||
|
# Onboarding rollback endpoint
|
||||||
|
Route(
|
||||||
|
"/onboarding/rollback",
|
||||||
|
require_auth(services["session_manager"])(
|
||||||
|
partial(
|
||||||
|
settings.rollback_onboarding,
|
||||||
|
session_manager=services["session_manager"],
|
||||||
|
task_service=services["task_service"],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
# Docling preset update endpoint
|
# Docling preset update endpoint
|
||||||
Route(
|
Route(
|
||||||
"/settings/docling-preset",
|
"/settings/docling-preset",
|
||||||
|
|
|
||||||
|
|
@ -595,7 +595,7 @@ class ChatService:
|
||||||
try:
|
try:
|
||||||
# Delete from local conversation storage
|
# Delete from local conversation storage
|
||||||
from agent import delete_user_conversation
|
from agent import delete_user_conversation
|
||||||
local_deleted = delete_user_conversation(user_id, session_id)
|
local_deleted = await delete_user_conversation(user_id, session_id)
|
||||||
|
|
||||||
# Delete from Langflow using the monitor API
|
# Delete from Langflow using the monitor API
|
||||||
langflow_deleted = await self._delete_langflow_session(session_id)
|
langflow_deleted = await self._delete_langflow_session(session_id)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Simple service to persist chat conversations to disk so they survive server rest
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -33,8 +34,8 @@ class ConversationPersistenceService:
|
||||||
return {}
|
return {}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _save_conversations(self):
|
def _save_conversations_sync(self):
|
||||||
"""Save conversations to disk"""
|
"""Synchronous save conversations to disk (runs in executor)"""
|
||||||
try:
|
try:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
with open(self.storage_file, 'w', encoding='utf-8') as f:
|
with open(self.storage_file, 'w', encoding='utf-8') as f:
|
||||||
|
|
@ -43,6 +44,12 @@ class ConversationPersistenceService:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving conversations to {self.storage_file}: {e}")
|
logger.error(f"Error saving conversations to {self.storage_file}: {e}")
|
||||||
|
|
||||||
|
async def _save_conversations(self):
|
||||||
|
"""Async save conversations to disk (non-blocking)"""
|
||||||
|
# Run the synchronous file I/O in a thread pool to avoid blocking the event loop
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._save_conversations_sync)
|
||||||
|
|
||||||
def _count_total_conversations(self, data: Dict[str, Any]) -> int:
|
def _count_total_conversations(self, data: Dict[str, Any]) -> int:
|
||||||
"""Count total conversations across all users"""
|
"""Count total conversations across all users"""
|
||||||
total = 0
|
total = 0
|
||||||
|
|
@ -68,8 +75,8 @@ class ConversationPersistenceService:
|
||||||
else:
|
else:
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def store_conversation_thread(self, user_id: str, response_id: str, conversation_state: Dict[str, Any]):
|
async def store_conversation_thread(self, user_id: str, response_id: str, conversation_state: Dict[str, Any]):
|
||||||
"""Store a conversation thread and persist to disk"""
|
"""Store a conversation thread and persist to disk (async, non-blocking)"""
|
||||||
if user_id not in self._conversations:
|
if user_id not in self._conversations:
|
||||||
self._conversations[user_id] = {}
|
self._conversations[user_id] = {}
|
||||||
|
|
||||||
|
|
@ -78,28 +85,28 @@ class ConversationPersistenceService:
|
||||||
|
|
||||||
self._conversations[user_id][response_id] = serialized_conversation
|
self._conversations[user_id][response_id] = serialized_conversation
|
||||||
|
|
||||||
# Save to disk (we could optimize this with batching if needed)
|
# Save to disk asynchronously (non-blocking)
|
||||||
self._save_conversations()
|
await self._save_conversations()
|
||||||
|
|
||||||
def get_conversation_thread(self, user_id: str, response_id: str) -> Dict[str, Any]:
|
def get_conversation_thread(self, user_id: str, response_id: str) -> Dict[str, Any]:
|
||||||
"""Get a specific conversation thread"""
|
"""Get a specific conversation thread"""
|
||||||
user_conversations = self.get_user_conversations(user_id)
|
user_conversations = self.get_user_conversations(user_id)
|
||||||
return user_conversations.get(response_id, {})
|
return user_conversations.get(response_id, {})
|
||||||
|
|
||||||
def delete_conversation_thread(self, user_id: str, response_id: str) -> bool:
|
async def delete_conversation_thread(self, user_id: str, response_id: str) -> bool:
|
||||||
"""Delete a specific conversation thread"""
|
"""Delete a specific conversation thread (async, non-blocking)"""
|
||||||
if user_id in self._conversations and response_id in self._conversations[user_id]:
|
if user_id in self._conversations and response_id in self._conversations[user_id]:
|
||||||
del self._conversations[user_id][response_id]
|
del self._conversations[user_id][response_id]
|
||||||
self._save_conversations()
|
await self._save_conversations()
|
||||||
logger.debug(f"Deleted conversation {response_id} for user {user_id}")
|
logger.debug(f"Deleted conversation {response_id} for user {user_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def clear_user_conversations(self, user_id: str):
|
async def clear_user_conversations(self, user_id: str):
|
||||||
"""Clear all conversations for a user"""
|
"""Clear all conversations for a user (async, non-blocking)"""
|
||||||
if user_id in self._conversations:
|
if user_id in self._conversations:
|
||||||
del self._conversations[user_id]
|
del self._conversations[user_id]
|
||||||
self._save_conversations()
|
await self._save_conversations()
|
||||||
logger.debug(f"Cleared all conversations for user {user_id}")
|
logger.debug(f"Cleared all conversations for user {user_id}")
|
||||||
|
|
||||||
def get_storage_stats(self) -> Dict[str, Any]:
|
def get_storage_stats(self) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -199,3 +199,5 @@ class MessageId:
|
||||||
ORB_ONBOARD_SAMPLE_DATA = "ORB_ONBOARD_SAMPLE_DATA"
|
ORB_ONBOARD_SAMPLE_DATA = "ORB_ONBOARD_SAMPLE_DATA"
|
||||||
# Message: Configuration marked as edited
|
# Message: Configuration marked as edited
|
||||||
ORB_ONBOARD_CONFIG_EDITED = "ORB_ONBOARD_CONFIG_EDITED"
|
ORB_ONBOARD_CONFIG_EDITED = "ORB_ONBOARD_CONFIG_EDITED"
|
||||||
|
# Message: Onboarding rolled back due to all files failing
|
||||||
|
ORB_ONBOARD_ROLLBACK = "ORB_ONBOARD_ROLLBACK"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue