From 2d0838d5a8f30d22571b1b1fabfab2b0cbe9d176 Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Fri, 14 Nov 2025 15:32:40 -0600 Subject: [PATCH] reorganize folder structure --- frontend/{src => }/app/admin/page.tsx | 0 frontend/{src => }/app/api/[...path]/route.ts | 0 .../{src => }/app/api/get-query-client.ts | 0 .../api/mutations/useCancelTaskMutation.ts | 0 .../app/api/mutations/useCreateFilter.ts | 0 .../app/api/mutations/useDeleteDocument.ts | 0 .../app/api/mutations/useDeleteFilter.ts | 0 .../api/mutations/useOnboardingMutation.ts | 0 .../app/api/mutations/useUpdateFilter.ts | 0 .../mutations/useUpdateSettingsMutation.ts | 0 .../api/queries/useDeleteSessionMutation.ts | 0 .../app/api/queries/useDoclingHealthQuery.ts | 0 .../api/queries/useGetConversationsQuery.ts | 0 .../api/queries/useGetFiltersSearchQuery.ts | 0 .../app/api/queries/useGetModelsQuery.ts | 0 .../app/api/queries/useGetNudgesQuery.ts | 0 .../api/queries/useGetSearchAggregations.ts | 0 .../app/api/queries/useGetSearchQuery.ts | 0 .../app/api/queries/useGetSettingsQuery.ts | 0 .../app/api/queries/useGetTasksQuery.ts | 0 .../app/api/queries/useProviderHealthQuery.ts | 0 frontend/app/auth/callback/page.tsx | 259 ++++ .../chat/_components/assistant-message.tsx | 95 ++ frontend/app/chat/_components/chat-input.tsx | 339 ++++ .../chat/_components}/file-preview.tsx | 0 .../app/chat/_components/function-calls.tsx | 237 +++ .../chat/_components}/message.tsx | 0 .../chat => app/chat/_components}/nudges.tsx | 0 .../_components/selected-knowledge-filter.tsx | 33 + .../chat/_components}/user-message.tsx | 0 .../app/chat => app/chat/_types}/types.ts | 0 frontend/app/chat/page.tsx | 1367 +++++++++++++++++ frontend/{src => }/app/connectors/page.tsx | 0 frontend/{src => }/app/favicon.ico | Bin frontend/{src => }/app/globals.css | 0 frontend/{src => }/app/icon.png | Bin .../{src => }/app/knowledge/chunks/page.tsx | 0 frontend/app/knowledge/page.tsx | 423 +++++ frontend/{src => }/app/layout.tsx | 0 frontend/app/login/page.tsx | 73 + .../onboarding/_components}/advanced.tsx | 0 .../_components/animated-provider-steps.tsx | 214 +++ .../_components/anthropic-onboarding.tsx | 154 ++ .../onboarding/_components/ibm-onboarding.tsx | 212 +++ .../_components}/model-selector.tsx | 0 .../_components/ollama-onboarding.tsx | 173 +++ .../_components/onboarding-card.tsx | 538 +++++++ .../_components/onboarding-content.tsx | 159 ++ .../_components/onboarding-step.tsx | 127 ++ .../_components}/onboarding-upload.tsx | 2 +- .../_components/openai-onboarding.tsx | 168 ++ .../onboarding/_components}/progress-bar.tsx | 0 .../onboarding/_components/tab-trigger.tsx | 33 + .../onboarding/_hooks}/useModelSelection.ts | 0 .../onboarding/_hooks}/useUpdateSettings.ts | 0 frontend/{src => }/app/onboarding/page.tsx | 2 +- frontend/{src => }/app/page.tsx | 0 frontend/{src => }/app/providers.tsx | 0 .../_components/anthropic-settings-dialog.tsx | 158 ++ .../_components}/anthropic-settings-form.tsx | 0 .../settings/_components/model-providers.tsx | 215 +++ .../settings/_components}/model-selectors.tsx | 2 +- .../_components/ollama-settings-dialog.tsx | 159 ++ .../_components}/ollama-settings-form.tsx | 0 .../_components/openai-settings-dialog.tsx | 158 ++ .../_components}/openai-settings-form.tsx | 0 .../_components/watsonx-settings-dialog.tsx | 166 ++ .../_components}/watsonx-settings-form.tsx | 2 +- .../app/settings/_helpers/model-helpers.tsx | 108 ++ .../settings/_helpers}/model-select-item.tsx | 0 frontend/app/settings/page.tsx | 1309 ++++++++++++++++ .../{src => }/app/upload/[provider]/page.tsx | 0 .../components/AgGrid/agGridStyles.css | 0 .../AgGrid/registerAgGridModules.ts | 0 .../components/animated-conditional.tsx | 0 .../{src => }/components/chat-renderer.tsx | 4 +- .../components/cloud-picker/file-item.tsx | 88 ++ .../components/cloud-picker/file-list.tsx | 0 .../components/cloud-picker/index.ts | 0 .../cloud-picker/ingest-settings.tsx | 252 +++ .../components/cloud-picker/picker-header.tsx | 0 .../cloud-picker/provider-handlers.ts | 0 .../components/cloud-picker/types.ts | 0 .../cloud-picker/unified-cloud-picker.tsx | 0 frontend/components/confirmation-dialog.tsx | 94 +- .../components/delete-confirmation-dialog.tsx | 84 + frontend/components/docling-health-banner.tsx | 232 +-- frontend/components/header.tsx | 65 + .../icons/animated-processing-icon.tsx | 225 +++ .../{logo => icons}/anthropic-logo.tsx | 0 .../icons/aws-logo.tsx} | 5 +- .../components/{logo => icons}/dog-icon.tsx | 0 .../icons/google-drive-logo.tsx} | 5 +- .../{logo => icons}/google-logo.tsx | 0 .../components/{logo => icons}/ibm-logo.tsx | 0 .../{logo => icons}/ollama-logo.tsx | 0 .../icons/one-drive-logo.tsx} | 5 +- .../{logo => icons}/openai-logo.tsx | 0 .../{logo/logo.tsx => icons/openrag-logo.tsx} | 2 +- .../icons/share-point-logo.tsx} | 5 +- .../components/knowledge-actions-dropdown.tsx | 134 +- frontend/components/knowledge-dropdown.tsx | 924 +++++------ frontend/components/knowledge-filter-list.tsx | 284 ++-- .../components/knowledge-filter-panel.tsx | 904 +++++------ frontend/components/layout-wrapper.tsx | 110 ++ .../{src => }/components/login-required.tsx | 0 .../{src => }/components/protected-route.tsx | 0 .../components/provider-health-banner.tsx | 190 +-- .../components/task-notification-menu.tsx | 0 .../theme-switcher-buttons.tsx} | 7 +- frontend/{src => }/components/ui/avatar.tsx | 0 .../{src => }/components/ui/dropdown-menu.tsx | 0 .../components/ui/inputs/embedding-model.tsx | 110 +- frontend/components/ui/status-badge.tsx | 58 + frontend/components/user-nav.tsx | 106 ++ frontend/{src => }/contexts/auth-context.tsx | 0 frontend/{src => }/contexts/chat-context.tsx | 0 .../contexts/knowledge-filter-context.tsx | 0 frontend/{src => }/contexts/task-context.tsx | 0 frontend/hooks/useChatStreaming.ts | 496 ++++++ frontend/{src => }/lib/constants.ts | 0 frontend/src/app/auth/callback/page.tsx | 259 ---- .../app/chat/components/assistant-message.tsx | 95 -- .../src/app/chat/components/chat-input.tsx | 339 ---- .../app/chat/components/function-calls.tsx | 237 --- .../components/selected-knowledge-filter.tsx | 33 - frontend/src/app/chat/page.tsx | 1367 ----------------- frontend/src/app/knowledge/page.tsx | 423 ----- frontend/src/app/login/page.tsx | 73 - .../components/animated-provider-steps.tsx | 215 --- .../components/anthropic-onboarding.tsx | 154 -- .../onboarding/components/ibm-onboarding.tsx | 212 --- .../components/ollama-onboarding.tsx | 173 --- .../onboarding/components/onboarding-card.tsx | 538 ------- .../components/onboarding-content.tsx | 159 -- .../onboarding/components/onboarding-step.tsx | 127 -- .../components/openai-onboarding.tsx | 168 -- .../app/onboarding/components/tab-trigger.tsx | 33 - .../components/anthropic-settings-dialog.tsx | 158 -- .../settings/components/model-providers.tsx | 215 --- .../components/ollama-settings-dialog.tsx | 159 -- .../components/openai-settings-dialog.tsx | 158 -- .../components/watsonx-settings-dialog.tsx | 166 -- .../app/settings/helpers/model-helpers.tsx | 108 -- frontend/src/app/settings/page.tsx | 1309 ---------------- .../components/cloud-connectors-dialog.tsx | 297 ---- .../components/cloud-connectors-dropdown.tsx | 89 -- .../src/components/cloud-picker/file-item.tsx | 88 -- .../cloud-picker/ingest-settings.tsx | 252 --- .../src/components/confirmation-dialog.tsx | 72 - frontend/src/components/header.tsx | 65 - frontend/src/components/layout-wrapper.tsx | 110 -- .../ui/animated-processing-icon.tsx | 224 --- frontend/src/components/ui/dialog.tsx | 122 -- frontend/src/components/ui/status-badge.tsx | 58 - frontend/src/components/ui/toast.tsx | 39 - frontend/src/components/user-nav.tsx | 100 -- frontend/src/hooks/useChatStreaming.ts | 492 ------ frontend/{src => }/stores/loadingStore.ts | 0 frontend/tsconfig.json | 2 +- 160 files changed, 9816 insertions(+), 10346 deletions(-) rename frontend/{src => }/app/admin/page.tsx (100%) rename frontend/{src => }/app/api/[...path]/route.ts (100%) rename frontend/{src => }/app/api/get-query-client.ts (100%) rename frontend/{src => }/app/api/mutations/useCancelTaskMutation.ts (100%) rename frontend/{src => }/app/api/mutations/useCreateFilter.ts (100%) rename frontend/{src => }/app/api/mutations/useDeleteDocument.ts (100%) rename frontend/{src => }/app/api/mutations/useDeleteFilter.ts (100%) rename frontend/{src => }/app/api/mutations/useOnboardingMutation.ts (100%) rename frontend/{src => }/app/api/mutations/useUpdateFilter.ts (100%) rename frontend/{src => }/app/api/mutations/useUpdateSettingsMutation.ts (100%) rename frontend/{src => }/app/api/queries/useDeleteSessionMutation.ts (100%) rename frontend/{src => }/app/api/queries/useDoclingHealthQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetConversationsQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetFiltersSearchQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetModelsQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetNudgesQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetSearchAggregations.ts (100%) rename frontend/{src => }/app/api/queries/useGetSearchQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetSettingsQuery.ts (100%) rename frontend/{src => }/app/api/queries/useGetTasksQuery.ts (100%) rename frontend/{src => }/app/api/queries/useProviderHealthQuery.ts (100%) create mode 100644 frontend/app/auth/callback/page.tsx create mode 100644 frontend/app/chat/_components/assistant-message.tsx create mode 100644 frontend/app/chat/_components/chat-input.tsx rename frontend/{src/app/chat/components => app/chat/_components}/file-preview.tsx (100%) create mode 100644 frontend/app/chat/_components/function-calls.tsx rename frontend/{src/app/chat/components => app/chat/_components}/message.tsx (100%) rename frontend/{src/app/chat => app/chat/_components}/nudges.tsx (100%) create mode 100644 frontend/app/chat/_components/selected-knowledge-filter.tsx rename frontend/{src/app/chat/components => app/chat/_components}/user-message.tsx (100%) rename frontend/{src/app/chat => app/chat/_types}/types.ts (100%) create mode 100644 frontend/app/chat/page.tsx rename frontend/{src => }/app/connectors/page.tsx (100%) rename frontend/{src => }/app/favicon.ico (100%) rename frontend/{src => }/app/globals.css (100%) rename frontend/{src => }/app/icon.png (100%) rename frontend/{src => }/app/knowledge/chunks/page.tsx (100%) create mode 100644 frontend/app/knowledge/page.tsx rename frontend/{src => }/app/layout.tsx (100%) create mode 100644 frontend/app/login/page.tsx rename frontend/{src/app/onboarding/components => app/onboarding/_components}/advanced.tsx (100%) create mode 100644 frontend/app/onboarding/_components/animated-provider-steps.tsx create mode 100644 frontend/app/onboarding/_components/anthropic-onboarding.tsx create mode 100644 frontend/app/onboarding/_components/ibm-onboarding.tsx rename frontend/{src/app/onboarding/components => app/onboarding/_components}/model-selector.tsx (100%) create mode 100644 frontend/app/onboarding/_components/ollama-onboarding.tsx create mode 100644 frontend/app/onboarding/_components/onboarding-card.tsx create mode 100644 frontend/app/onboarding/_components/onboarding-content.tsx create mode 100644 frontend/app/onboarding/_components/onboarding-step.tsx rename frontend/{src/app/onboarding/components => app/onboarding/_components}/onboarding-upload.tsx (97%) create mode 100644 frontend/app/onboarding/_components/openai-onboarding.tsx rename frontend/{src/app/onboarding/components => app/onboarding/_components}/progress-bar.tsx (100%) create mode 100644 frontend/app/onboarding/_components/tab-trigger.tsx rename frontend/{src/app/onboarding/hooks => app/onboarding/_hooks}/useModelSelection.ts (100%) rename frontend/{src/app/onboarding/hooks => app/onboarding/_hooks}/useUpdateSettings.ts (100%) rename frontend/{src => }/app/onboarding/page.tsx (97%) rename frontend/{src => }/app/page.tsx (100%) rename frontend/{src => }/app/providers.tsx (100%) create mode 100644 frontend/app/settings/_components/anthropic-settings-dialog.tsx rename frontend/{src/app/settings/components => app/settings/_components}/anthropic-settings-form.tsx (100%) create mode 100644 frontend/app/settings/_components/model-providers.tsx rename frontend/{src/app/settings/components => app/settings/_components}/model-selectors.tsx (98%) create mode 100644 frontend/app/settings/_components/ollama-settings-dialog.tsx rename frontend/{src/app/settings/components => app/settings/_components}/ollama-settings-form.tsx (100%) create mode 100644 frontend/app/settings/_components/openai-settings-dialog.tsx rename frontend/{src/app/settings/components => app/settings/_components}/openai-settings-form.tsx (100%) create mode 100644 frontend/app/settings/_components/watsonx-settings-dialog.tsx rename frontend/{src/app/settings/components => app/settings/_components}/watsonx-settings-form.tsx (98%) create mode 100644 frontend/app/settings/_helpers/model-helpers.tsx rename frontend/{src/app/settings/helpers => app/settings/_helpers}/model-select-item.tsx (100%) create mode 100644 frontend/app/settings/page.tsx rename frontend/{src => }/app/upload/[provider]/page.tsx (100%) rename frontend/{src => }/components/AgGrid/agGridStyles.css (100%) rename frontend/{src => }/components/AgGrid/registerAgGridModules.ts (100%) rename frontend/{src => }/components/animated-conditional.tsx (100%) rename frontend/{src => }/components/chat-renderer.tsx (97%) create mode 100644 frontend/components/cloud-picker/file-item.tsx rename frontend/{src => }/components/cloud-picker/file-list.tsx (100%) rename frontend/{src => }/components/cloud-picker/index.ts (100%) create mode 100644 frontend/components/cloud-picker/ingest-settings.tsx rename frontend/{src => }/components/cloud-picker/picker-header.tsx (100%) rename frontend/{src => }/components/cloud-picker/provider-handlers.ts (100%) rename frontend/{src => }/components/cloud-picker/types.ts (100%) rename frontend/{src => }/components/cloud-picker/unified-cloud-picker.tsx (100%) create mode 100644 frontend/components/delete-confirmation-dialog.tsx create mode 100644 frontend/components/header.tsx create mode 100644 frontend/components/icons/animated-processing-icon.tsx rename frontend/components/{logo => icons}/anthropic-logo.tsx (100%) rename frontend/{src/app/settings/icons/aws-icon.tsx => components/icons/aws-logo.tsx} (98%) rename frontend/components/{logo => icons}/dog-icon.tsx (100%) rename frontend/{src/app/settings/icons/google-drive-icon.tsx => components/icons/google-drive-logo.tsx} (91%) rename frontend/components/{logo => icons}/google-logo.tsx (100%) rename frontend/components/{logo => icons}/ibm-logo.tsx (100%) rename frontend/components/{logo => icons}/ollama-logo.tsx (100%) rename frontend/{src/app/settings/icons/one-drive-icon.tsx => components/icons/one-drive-logo.tsx} (98%) rename frontend/components/{logo => icons}/openai-logo.tsx (100%) rename frontend/components/{logo/logo.tsx => icons/openrag-logo.tsx} (98%) rename frontend/{src/app/settings/icons/share-point-icon.tsx => components/icons/share-point-logo.tsx} (98%) create mode 100644 frontend/components/layout-wrapper.tsx rename frontend/{src => }/components/login-required.tsx (100%) rename frontend/{src => }/components/protected-route.tsx (100%) rename frontend/{src => }/components/task-notification-menu.tsx (100%) rename frontend/{src/components/ui/buttonTheme.tsx => components/theme-switcher-buttons.tsx} (93%) rename frontend/{src => }/components/ui/avatar.tsx (100%) rename frontend/{src => }/components/ui/dropdown-menu.tsx (100%) create mode 100644 frontend/components/ui/status-badge.tsx create mode 100644 frontend/components/user-nav.tsx rename frontend/{src => }/contexts/auth-context.tsx (100%) rename frontend/{src => }/contexts/chat-context.tsx (100%) rename frontend/{src => }/contexts/knowledge-filter-context.tsx (100%) rename frontend/{src => }/contexts/task-context.tsx (100%) create mode 100644 frontend/hooks/useChatStreaming.ts rename frontend/{src => }/lib/constants.ts (100%) delete mode 100644 frontend/src/app/auth/callback/page.tsx delete mode 100644 frontend/src/app/chat/components/assistant-message.tsx delete mode 100644 frontend/src/app/chat/components/chat-input.tsx delete mode 100644 frontend/src/app/chat/components/function-calls.tsx delete mode 100644 frontend/src/app/chat/components/selected-knowledge-filter.tsx delete mode 100644 frontend/src/app/chat/page.tsx delete mode 100644 frontend/src/app/knowledge/page.tsx delete mode 100644 frontend/src/app/login/page.tsx delete mode 100644 frontend/src/app/onboarding/components/animated-provider-steps.tsx delete mode 100644 frontend/src/app/onboarding/components/anthropic-onboarding.tsx delete mode 100644 frontend/src/app/onboarding/components/ibm-onboarding.tsx delete mode 100644 frontend/src/app/onboarding/components/ollama-onboarding.tsx delete mode 100644 frontend/src/app/onboarding/components/onboarding-card.tsx delete mode 100644 frontend/src/app/onboarding/components/onboarding-content.tsx delete mode 100644 frontend/src/app/onboarding/components/onboarding-step.tsx delete mode 100644 frontend/src/app/onboarding/components/openai-onboarding.tsx delete mode 100644 frontend/src/app/onboarding/components/tab-trigger.tsx delete mode 100644 frontend/src/app/settings/components/anthropic-settings-dialog.tsx delete mode 100644 frontend/src/app/settings/components/model-providers.tsx delete mode 100644 frontend/src/app/settings/components/ollama-settings-dialog.tsx delete mode 100644 frontend/src/app/settings/components/openai-settings-dialog.tsx delete mode 100644 frontend/src/app/settings/components/watsonx-settings-dialog.tsx delete mode 100644 frontend/src/app/settings/helpers/model-helpers.tsx delete mode 100644 frontend/src/app/settings/page.tsx delete mode 100644 frontend/src/components/cloud-connectors-dialog.tsx delete mode 100644 frontend/src/components/cloud-connectors-dropdown.tsx delete mode 100644 frontend/src/components/cloud-picker/file-item.tsx delete mode 100644 frontend/src/components/cloud-picker/ingest-settings.tsx delete mode 100644 frontend/src/components/confirmation-dialog.tsx delete mode 100644 frontend/src/components/header.tsx delete mode 100644 frontend/src/components/layout-wrapper.tsx delete mode 100644 frontend/src/components/ui/animated-processing-icon.tsx delete mode 100644 frontend/src/components/ui/dialog.tsx delete mode 100644 frontend/src/components/ui/status-badge.tsx delete mode 100644 frontend/src/components/ui/toast.tsx delete mode 100644 frontend/src/components/user-nav.tsx delete mode 100644 frontend/src/hooks/useChatStreaming.ts rename frontend/{src => }/stores/loadingStore.ts (100%) diff --git a/frontend/src/app/admin/page.tsx b/frontend/app/admin/page.tsx similarity index 100% rename from frontend/src/app/admin/page.tsx rename to frontend/app/admin/page.tsx diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/app/api/[...path]/route.ts similarity index 100% rename from frontend/src/app/api/[...path]/route.ts rename to frontend/app/api/[...path]/route.ts diff --git a/frontend/src/app/api/get-query-client.ts b/frontend/app/api/get-query-client.ts similarity index 100% rename from frontend/src/app/api/get-query-client.ts rename to frontend/app/api/get-query-client.ts diff --git a/frontend/src/app/api/mutations/useCancelTaskMutation.ts b/frontend/app/api/mutations/useCancelTaskMutation.ts similarity index 100% rename from frontend/src/app/api/mutations/useCancelTaskMutation.ts rename to frontend/app/api/mutations/useCancelTaskMutation.ts diff --git a/frontend/src/app/api/mutations/useCreateFilter.ts b/frontend/app/api/mutations/useCreateFilter.ts similarity index 100% rename from frontend/src/app/api/mutations/useCreateFilter.ts rename to frontend/app/api/mutations/useCreateFilter.ts diff --git a/frontend/src/app/api/mutations/useDeleteDocument.ts b/frontend/app/api/mutations/useDeleteDocument.ts similarity index 100% rename from frontend/src/app/api/mutations/useDeleteDocument.ts rename to frontend/app/api/mutations/useDeleteDocument.ts diff --git a/frontend/src/app/api/mutations/useDeleteFilter.ts b/frontend/app/api/mutations/useDeleteFilter.ts similarity index 100% rename from frontend/src/app/api/mutations/useDeleteFilter.ts rename to frontend/app/api/mutations/useDeleteFilter.ts diff --git a/frontend/src/app/api/mutations/useOnboardingMutation.ts b/frontend/app/api/mutations/useOnboardingMutation.ts similarity index 100% rename from frontend/src/app/api/mutations/useOnboardingMutation.ts rename to frontend/app/api/mutations/useOnboardingMutation.ts diff --git a/frontend/src/app/api/mutations/useUpdateFilter.ts b/frontend/app/api/mutations/useUpdateFilter.ts similarity index 100% rename from frontend/src/app/api/mutations/useUpdateFilter.ts rename to frontend/app/api/mutations/useUpdateFilter.ts diff --git a/frontend/src/app/api/mutations/useUpdateSettingsMutation.ts b/frontend/app/api/mutations/useUpdateSettingsMutation.ts similarity index 100% rename from frontend/src/app/api/mutations/useUpdateSettingsMutation.ts rename to frontend/app/api/mutations/useUpdateSettingsMutation.ts diff --git a/frontend/src/app/api/queries/useDeleteSessionMutation.ts b/frontend/app/api/queries/useDeleteSessionMutation.ts similarity index 100% rename from frontend/src/app/api/queries/useDeleteSessionMutation.ts rename to frontend/app/api/queries/useDeleteSessionMutation.ts diff --git a/frontend/src/app/api/queries/useDoclingHealthQuery.ts b/frontend/app/api/queries/useDoclingHealthQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useDoclingHealthQuery.ts rename to frontend/app/api/queries/useDoclingHealthQuery.ts diff --git a/frontend/src/app/api/queries/useGetConversationsQuery.ts b/frontend/app/api/queries/useGetConversationsQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetConversationsQuery.ts rename to frontend/app/api/queries/useGetConversationsQuery.ts diff --git a/frontend/src/app/api/queries/useGetFiltersSearchQuery.ts b/frontend/app/api/queries/useGetFiltersSearchQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetFiltersSearchQuery.ts rename to frontend/app/api/queries/useGetFiltersSearchQuery.ts diff --git a/frontend/src/app/api/queries/useGetModelsQuery.ts b/frontend/app/api/queries/useGetModelsQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetModelsQuery.ts rename to frontend/app/api/queries/useGetModelsQuery.ts diff --git a/frontend/src/app/api/queries/useGetNudgesQuery.ts b/frontend/app/api/queries/useGetNudgesQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetNudgesQuery.ts rename to frontend/app/api/queries/useGetNudgesQuery.ts diff --git a/frontend/src/app/api/queries/useGetSearchAggregations.ts b/frontend/app/api/queries/useGetSearchAggregations.ts similarity index 100% rename from frontend/src/app/api/queries/useGetSearchAggregations.ts rename to frontend/app/api/queries/useGetSearchAggregations.ts diff --git a/frontend/src/app/api/queries/useGetSearchQuery.ts b/frontend/app/api/queries/useGetSearchQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetSearchQuery.ts rename to frontend/app/api/queries/useGetSearchQuery.ts diff --git a/frontend/src/app/api/queries/useGetSettingsQuery.ts b/frontend/app/api/queries/useGetSettingsQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetSettingsQuery.ts rename to frontend/app/api/queries/useGetSettingsQuery.ts diff --git a/frontend/src/app/api/queries/useGetTasksQuery.ts b/frontend/app/api/queries/useGetTasksQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useGetTasksQuery.ts rename to frontend/app/api/queries/useGetTasksQuery.ts diff --git a/frontend/src/app/api/queries/useProviderHealthQuery.ts b/frontend/app/api/queries/useProviderHealthQuery.ts similarity index 100% rename from frontend/src/app/api/queries/useProviderHealthQuery.ts rename to frontend/app/api/queries/useProviderHealthQuery.ts diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx new file mode 100644 index 00000000..3e76f9f5 --- /dev/null +++ b/frontend/app/auth/callback/page.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { ArrowLeft, CheckCircle, Loader2, XCircle } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; +import AnimatedProcessingIcon from "@/components/icons/animated-processing-icon"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useAuth } from "@/contexts/auth-context"; + +function AuthCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { refreshAuth } = useAuth(); + const [status, setStatus] = useState<"processing" | "success" | "error">( + "processing", + ); + const [error, setError] = useState(null); + const [purpose, setPurpose] = useState("app_auth"); + + useEffect(() => { + const code = searchParams.get("code"); + const callbackKey = `callback_processed_${code}`; + + // Prevent double execution across component remounts + if (sessionStorage.getItem(callbackKey)) { + return; + } + sessionStorage.setItem(callbackKey, "true"); + + const handleCallback = async () => { + try { + // Get parameters from URL + const state = searchParams.get("state"); + const errorParam = searchParams.get("error"); + + // Get stored auth info + const connectorId = localStorage.getItem("connecting_connector_id"); + const storedConnectorType = localStorage.getItem( + "connecting_connector_type", + ); + const authPurpose = localStorage.getItem("auth_purpose"); + + // Determine purpose - default to app_auth for login, data_source for connectors + const detectedPurpose = + authPurpose || + (storedConnectorType?.includes("drive") ? "data_source" : "app_auth"); + setPurpose(detectedPurpose); + + // Debug logging + console.log("OAuth Callback Debug:", { + urlParams: { code: !!code, state: !!state, error: errorParam }, + localStorage: { connectorId, storedConnectorType, authPurpose }, + detectedPurpose, + fullUrl: window.location.href, + }); + + // Use state parameter as connection_id if localStorage is missing + const finalConnectorId = connectorId || state; + + if (errorParam) { + throw new Error(`OAuth error: ${errorParam}`); + } + + if (!code || !state || !finalConnectorId) { + console.error("Missing OAuth callback parameters:", { + code: !!code, + state: !!state, + finalConnectorId: !!finalConnectorId, + }); + throw new Error("Missing required parameters for OAuth callback"); + } + + // Send callback data to backend + const response = await fetch("/api/auth/callback", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection_id: finalConnectorId, + authorization_code: code, + state: state, + }), + }); + + const result = await response.json(); + + if (response.ok) { + setStatus("success"); + + if (result.purpose === "app_auth" || detectedPurpose === "app_auth") { + // App authentication - refresh auth context and redirect to home/original page + await refreshAuth(); + + // Get redirect URL from login page + const redirectTo = searchParams.get("redirect") || "/chat"; + + // Clean up localStorage + localStorage.removeItem("connecting_connector_id"); + localStorage.removeItem("connecting_connector_type"); + localStorage.removeItem("auth_purpose"); + + // Redirect to the original page or home + setTimeout(() => { + router.push(redirectTo); + }, 2000); + } else { + // Connector authentication - redirect to connectors page + + // Clean up localStorage + localStorage.removeItem("connecting_connector_id"); + localStorage.removeItem("connecting_connector_type"); + localStorage.removeItem("auth_purpose"); + + // Redirect to connectors page with success indicator + setTimeout(() => { + router.push("/connectors?oauth_success=true"); + }, 2000); + } + } else { + throw new Error(result.error || "Authentication failed"); + } + } catch (err) { + console.error("OAuth callback error:", err); + setError(err instanceof Error ? err.message : "Unknown error occurred"); + setStatus("error"); + + // Clean up localStorage on error too + localStorage.removeItem("connecting_connector_id"); + localStorage.removeItem("connecting_connector_type"); + localStorage.removeItem("auth_purpose"); + } + }; + + handleCallback(); + }, [searchParams, router, refreshAuth]); + + // Dynamic UI content based on purpose + const isAppAuth = purpose === "app_auth"; + + const getTitle = () => { + if (status === "processing") { + return isAppAuth ? "Signing you in..." : "Connecting..."; + } + if (status === "success") { + return isAppAuth ? "Welcome to OpenRAG!" : "Connection Successful!"; + } + if (status === "error") { + return isAppAuth ? "Sign In Failed" : "Connection Failed"; + } + }; + + const getDescription = () => { + if (status === "processing") { + return isAppAuth + ? "Please wait while we complete your sign in..." + : "Please wait while we complete the connection..."; + } + if (status === "success") { + return "You will be redirected shortly."; + } + if (status === "error") { + return isAppAuth + ? "There was an issue signing you in." + : "There was an issue with the connection."; + } + }; + + return ( +
+ + + + {status === "processing" && ( + <> + + {getTitle()} + + )} + {status === "success" && ( + <> + + {getTitle()} + + )} + {status === "error" && ( + <> + + {getTitle()} + + )} + + {getDescription()} + + + {status === "error" && ( +
+
+

{error}

+
+ +
+ )} + {status === "success" && ( +
+
+

+ {isAppAuth + ? "Redirecting you to the app..." + : "Redirecting to connectors..."} +

+
+
+ )} +
+
+
+ ); +} + +export default function AuthCallbackPage() { + return ( + + + + + + Loading... + + + Please wait while we process your request... + + + + + } + > + + + ); +} diff --git a/frontend/app/chat/_components/assistant-message.tsx b/frontend/app/chat/_components/assistant-message.tsx new file mode 100644 index 00000000..04fbae5d --- /dev/null +++ b/frontend/app/chat/_components/assistant-message.tsx @@ -0,0 +1,95 @@ +import { GitBranch } from "lucide-react"; +import { motion } from "motion/react"; +import DogIcon from "@/components/icons/dog-icon"; +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { cn } from "@/lib/utils"; +import type { FunctionCall } from "../_types/types"; +import { FunctionCalls } from "./function-calls"; +import { Message } from "./message"; + +interface AssistantMessageProps { + content: string; + functionCalls?: FunctionCall[]; + messageIndex?: number; + expandedFunctionCalls: Set; + onToggle: (functionCallId: string) => void; + isStreaming?: boolean; + showForkButton?: boolean; + onFork?: (e: React.MouseEvent) => void; + isCompleted?: boolean; + isInactive?: boolean; + animate?: boolean; + delay?: number; +} + +export function AssistantMessage({ + content, + functionCalls = [], + messageIndex, + expandedFunctionCalls, + onToggle, + isStreaming = false, + showForkButton = false, + onFork, + isCompleted = false, + isInactive = false, + animate = true, + delay = 0.2, +}: AssistantMessageProps) { + return ( + + + + + } + actions={ + showForkButton && onFork ? ( + + ) : undefined + } + > + +
+ ' + : content + } + /> +
+
+
+ ); +} diff --git a/frontend/app/chat/_components/chat-input.tsx b/frontend/app/chat/_components/chat-input.tsx new file mode 100644 index 00000000..cbf16977 --- /dev/null +++ b/frontend/app/chat/_components/chat-input.tsx @@ -0,0 +1,339 @@ +import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react"; +import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import type { FilterColor } from "@/components/filter-icon-popover"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import type { KnowledgeFilterData } from "../_types/types"; +import { FilePreview } from "./file-preview"; +import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; + +export interface ChatInputHandle { + focusInput: () => void; + clickFileInput: () => void; +} + +interface ChatInputProps { + input: string; + loading: boolean; + isUploading: boolean; + selectedFilter: KnowledgeFilterData | null; + isFilterDropdownOpen: boolean; + availableFilters: KnowledgeFilterData[]; + filterSearchTerm: string; + selectedFilterIndex: number; + anchorPosition: { x: number; y: number } | null; + parsedFilterData: { color?: FilterColor } | null; + uploadedFile: File | null; + onSubmit: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFilterSelect: (filter: KnowledgeFilterData | null) => void; + onAtClick: () => void; + onFilePickerClick: () => void; + setSelectedFilter: (filter: KnowledgeFilterData | null) => void; + setIsFilterHighlighted: (highlighted: boolean) => void; + setIsFilterDropdownOpen: (open: boolean) => void; + onFileSelected: (file: File | null) => void; +} + +export const ChatInput = forwardRef( + ( + { + input, + loading, + isUploading, + selectedFilter, + isFilterDropdownOpen, + availableFilters, + filterSearchTerm, + selectedFilterIndex, + anchorPosition, + parsedFilterData, + uploadedFile, + onSubmit, + onChange, + onKeyDown, + onFilterSelect, + onAtClick, + onFilePickerClick, + setSelectedFilter, + setIsFilterHighlighted, + setIsFilterDropdownOpen, + onFileSelected, + }, + ref, + ) => { + const inputRef = useRef(null); + const fileInputRef = useRef(null); + const [textareaHeight, setTextareaHeight] = useState(0); + + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + clickFileInput: () => { + fileInputRef.current?.click(); + }, + })); + + const handleFilePickerChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + onFileSelected(files[0]); + } else { + onFileSelected(null); + } + }; + + return ( +
+
+ {/* Outer container - flex-col to stack file preview above input */} +
+ {/* File Preview Section - Always above */} + {uploadedFile && ( + { + onFileSelected(null); + }} + /> + )} + + {/* Main Input Container - flex-row or flex-col based on textarea height */} +
40 ? "flex-col" : "flex-row items-center" + }`} + > + {/* Filter + Textarea Section */} +
40 ? "w-full" : "flex-1"}`} + > + {textareaHeight <= 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ setTextareaHeight(height)} + maxRows={7} + autoComplete="off" + minRows={1} + placeholder="Ask a question..." + disabled={loading} + className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} + rows={1} + /> +
+
+ + {/* Action Buttons Section */} +
40 ? "justify-between w-full" : ""}`} + > + {textareaHeight > 40 && + (selectedFilter ? ( + { + setSelectedFilter(null); + setIsFilterHighlighted(false); + }} + /> + ) : ( + + ))} +
+ + +
+
+
+
+ + + { + setIsFilterDropdownOpen(open); + }} + > + {anchorPosition && ( + +
+ + )} + { + // Prevent auto focus on the popover content + e.preventDefault(); + // Keep focus on the input + }} + > +
+ {filterSearchTerm && ( +
+ Searching: @{filterSearchTerm} +
+ )} + {availableFilters.length === 0 ? ( +
+ No knowledge filters available +
+ ) : ( + <> + {!filterSearchTerm && ( + + )} + {availableFilters + .filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ) + .map((filter, index) => ( + + ))} + {availableFilters.filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ).length === 0 && + filterSearchTerm && ( +
+ No filters match "{filterSearchTerm}" +
+ )} + + )} +
+
+ + +
+ ); + }, +); + +ChatInput.displayName = "ChatInput"; diff --git a/frontend/src/app/chat/components/file-preview.tsx b/frontend/app/chat/_components/file-preview.tsx similarity index 100% rename from frontend/src/app/chat/components/file-preview.tsx rename to frontend/app/chat/_components/file-preview.tsx diff --git a/frontend/app/chat/_components/function-calls.tsx b/frontend/app/chat/_components/function-calls.tsx new file mode 100644 index 00000000..5433305e --- /dev/null +++ b/frontend/app/chat/_components/function-calls.tsx @@ -0,0 +1,237 @@ +import { ChevronDown, ChevronRight, Settings } from "lucide-react"; +import type { FunctionCall } from "../_types/types"; + +interface FunctionCallsProps { + functionCalls: FunctionCall[]; + messageIndex?: number; + expandedFunctionCalls: Set; + onToggle: (functionCallId: string) => void; +} + +export function FunctionCalls({ + functionCalls, + messageIndex, + expandedFunctionCalls, + onToggle, +}: FunctionCallsProps) { + if (!functionCalls || functionCalls.length === 0) return null; + + return ( +
+ {functionCalls.map((fc, index) => { + const functionCallId = `${messageIndex || "streaming"}-${index}`; + const isExpanded = expandedFunctionCalls.has(functionCallId); + + // Determine display name - show both name and type if available + const displayName = + fc.type && fc.type !== fc.name ? `${fc.name} (${fc.type})` : fc.name; + + return ( +
+
onToggle(functionCallId)} + > + + + Function Call: {displayName} + + {fc.id && ( + + {fc.id.substring(0, 8)}... + + )} +
+ {fc.status} +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {isExpanded && ( +
+ {/* Show type information if available */} + {fc.type && ( +
+ Type: + + {fc.type} + +
+ )} + + {/* Show ID if available */} + {fc.id && ( +
+ ID: + + {fc.id} + +
+ )} + + {/* Show arguments - either completed or streaming */} + {(fc.arguments || fc.argumentsString) && ( +
+ Arguments: +
+											{fc.arguments
+												? JSON.stringify(fc.arguments, null, 2)
+												: fc.argumentsString || "..."}
+										
+
+ )} + + {fc.result && ( +
+ Result: + {Array.isArray(fc.result) ? ( +
+ {(() => { + // Handle different result formats + let resultsToRender = fc.result; + + // Check if this is function_call format with nested results + // Function call format: results = [{ results: [...] }] + // Tool call format: results = [{ text_key: ..., data: {...} }] + if ( + fc.result.length > 0 && + fc.result[0]?.results && + Array.isArray(fc.result[0].results) && + !fc.result[0].text_key + ) { + resultsToRender = fc.result[0].results; + } + + type ToolResultItem = { + text_key?: string; + data?: { file_path?: string; text?: string }; + filename?: string; + page?: number; + score?: number; + source_url?: string | null; + text?: string; + }; + const items = + resultsToRender as unknown as ToolResultItem[]; + return items.map((result, idx: number) => ( +
+ {/* Handle tool_call format (file_path in data) */} + {result.data?.file_path && ( +
+ 📄 {result.data.file_path || "Unknown file"} +
+ )} + + {/* Handle function_call format (filename directly) */} + {result.filename && !result.data?.file_path && ( +
+ 📄 {result.filename} + {result.page && ` (page ${result.page})`} + {result.score && ( + + Score: {result.score.toFixed(3)} + + )} +
+ )} + + {/* Handle tool_call text format */} + {result.data?.text && ( +
+ {result.data.text.length > 300 + ? result.data.text.substring(0, 300) + "..." + : result.data.text} +
+ )} + + {/* Handle function_call text format */} + {result.text && !result.data?.text && ( +
+ {result.text.length > 300 + ? result.text.substring(0, 300) + "..." + : result.text} +
+ )} + + {/* Show additional metadata for function_call format */} + {result.source_url && ( + + )} + + {result.text_key && ( +
+ Key: {result.text_key} +
+ )} +
+ )); + })()} +
+ Found {(() => { + let resultsToCount = fc.result; + if ( + fc.result.length > 0 && + fc.result[0]?.results && + Array.isArray(fc.result[0].results) && + !fc.result[0].text_key + ) { + resultsToCount = fc.result[0].results; + } + return resultsToCount.length; + })()} result + {(() => { + let resultsToCount = fc.result; + if ( + fc.result.length > 0 && + fc.result[0]?.results && + Array.isArray(fc.result[0].results) && + !fc.result[0].text_key + ) { + resultsToCount = fc.result[0].results; + } + return resultsToCount.length !== 1 ? "s" : ""; + })()} +
+
+ ) : ( +
+												{JSON.stringify(fc.result, null, 2)}
+											
+ )} +
+ )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/app/chat/components/message.tsx b/frontend/app/chat/_components/message.tsx similarity index 100% rename from frontend/src/app/chat/components/message.tsx rename to frontend/app/chat/_components/message.tsx diff --git a/frontend/src/app/chat/nudges.tsx b/frontend/app/chat/_components/nudges.tsx similarity index 100% rename from frontend/src/app/chat/nudges.tsx rename to frontend/app/chat/_components/nudges.tsx diff --git a/frontend/app/chat/_components/selected-knowledge-filter.tsx b/frontend/app/chat/_components/selected-knowledge-filter.tsx new file mode 100644 index 00000000..7a8f78b6 --- /dev/null +++ b/frontend/app/chat/_components/selected-knowledge-filter.tsx @@ -0,0 +1,33 @@ +import { X } from "lucide-react"; +import type { FilterColor } from "@/components/filter-icon-popover"; +import { filterAccentClasses } from "@/components/knowledge-filter-panel"; +import type { KnowledgeFilterData } from "../_types/types"; + +interface SelectedKnowledgeFilterProps { + selectedFilter: KnowledgeFilterData; + parsedFilterData: { color?: FilterColor } | null; + onClear: () => void; +} + +export const SelectedKnowledgeFilter = ({ + selectedFilter, + parsedFilterData, + onClear, +}: SelectedKnowledgeFilterProps) => { + return ( + + {selectedFilter.name} + + + ); +}; diff --git a/frontend/src/app/chat/components/user-message.tsx b/frontend/app/chat/_components/user-message.tsx similarity index 100% rename from frontend/src/app/chat/components/user-message.tsx rename to frontend/app/chat/_components/user-message.tsx diff --git a/frontend/src/app/chat/types.ts b/frontend/app/chat/_types/types.ts similarity index 100% rename from frontend/src/app/chat/types.ts rename to frontend/app/chat/_types/types.ts diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx new file mode 100644 index 00000000..cd613af4 --- /dev/null +++ b/frontend/app/chat/page.tsx @@ -0,0 +1,1367 @@ +"use client"; + +import { Loader2, Zap } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; +import { ProtectedRoute } from "@/components/protected-route"; +import { Button } from "@/components/ui/button"; +import { type EndpointType, useChat } from "@/contexts/chat-context"; +import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useTask } from "@/contexts/task-context"; +import { useChatStreaming } from "@/hooks/useChatStreaming"; +import { FILE_CONFIRMATION, FILES_REGEX } from "@/lib/constants"; +import { useLoadingStore } from "@/stores/loadingStore"; +import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; +import { AssistantMessage } from "./_components/assistant-message"; +import { ChatInput, type ChatInputHandle } from "./_components/chat-input"; +import Nudges from "./_components/nudges"; +import { UserMessage } from "./_components/user-message"; +import type { + FunctionCall, + KnowledgeFilterData, + Message, + RequestBody, + SelectedFilters, + ToolCallResult, +} from "./_types/types"; + +function ChatPage() { + const isDebugMode = process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true"; + const { + endpoint, + setEndpoint, + currentConversationId, + conversationData, + setCurrentConversationId, + addConversationDoc, + forkFromResponse, + refreshConversations, + refreshConversationsSilent, + previousResponseIds, + setPreviousResponseIds, + placeholderConversation, + } = useChat(); + const [messages, setMessages] = useState([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + const [input, setInput] = useState(""); + const { loading, setLoading } = useLoadingStore(); + const [asyncMode, setAsyncMode] = useState(true); + const [expandedFunctionCalls, setExpandedFunctionCalls] = useState< + Set + >(new Set()); + // previousResponseIds now comes from useChat context + const [isUploading, setIsUploading] = useState(false); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); + const [availableFilters, setAvailableFilters] = useState< + KnowledgeFilterData[] + >([]); + const [filterSearchTerm, setFilterSearchTerm] = useState(""); + const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); + const [isFilterHighlighted, setIsFilterHighlighted] = useState(false); + const [dropdownDismissed, setDropdownDismissed] = useState(false); + const [isUserInteracting, setIsUserInteracting] = useState(false); + const [isForkingInProgress, setIsForkingInProgress] = useState(false); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [uploadedFile, setUploadedFile] = useState(null); + + const chatInputRef = useRef(null); + + const { scrollToBottom } = useStickToBottomContext(); + + const lastLoadedConversationRef = useRef(null); + const { addTask } = useTask(); + const { selectedFilter, parsedFilterData, setSelectedFilter } = + useKnowledgeFilter(); + + // Use the chat streaming hook + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + const { + streamingMessage, + sendMessage: sendStreamingMessage, + abortStream, + } = useChatStreaming({ + endpoint: apiEndpoint, + onComplete: (message, responseId) => { + setMessages((prev) => [...prev, message]); + setLoading(false); + + if (responseId) { + cancelNudges(); + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + + if (!currentConversationId) { + setCurrentConversationId(responseId); + refreshConversations(true); + } else { + refreshConversationsSilent(); + } + } + }, + onError: (error) => { + console.error("Streaming error:", error); + setLoading(false); + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + }, + }); + + const getCursorPosition = (textarea: HTMLTextAreaElement) => { + // Create a hidden div with the same styles as the textarea + const div = document.createElement("div"); + const computedStyle = getComputedStyle(textarea); + + // Copy all computed styles to the hidden div + for (const style of computedStyle) { + (div.style as unknown as Record)[style] = + computedStyle.getPropertyValue(style); + } + + // Set the div to be hidden but not un-rendered + div.style.position = "absolute"; + div.style.visibility = "hidden"; + div.style.whiteSpace = "pre-wrap"; + div.style.wordWrap = "break-word"; + div.style.overflow = "hidden"; + div.style.height = "auto"; + div.style.width = `${textarea.getBoundingClientRect().width}px`; + + // Get the text up to the cursor position + const cursorPos = textarea.selectionStart || 0; + const textBeforeCursor = textarea.value.substring(0, cursorPos); + + // Add the text before cursor + div.textContent = textBeforeCursor; + + // Create a span to mark the end position + const span = document.createElement("span"); + span.textContent = "|"; // Cursor marker + div.appendChild(span); + + // Add the text after cursor to handle word wrapping + const textAfterCursor = textarea.value.substring(cursorPos); + div.appendChild(document.createTextNode(textAfterCursor)); + + // Add the div to the document temporarily + document.body.appendChild(div); + + // Get positions + const inputRect = textarea.getBoundingClientRect(); + const divRect = div.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + + // Calculate the cursor position relative to the input + const x = inputRect.left + (spanRect.left - divRect.left); + const y = inputRect.top + (spanRect.top - divRect.top); + + // Clean up + document.body.removeChild(div); + + return { x, y }; + }; + + const handleEndpointChange = (newEndpoint: EndpointType) => { + setEndpoint(newEndpoint); + // Clear the conversation when switching endpoints to avoid response ID conflicts + setMessages([]); + setPreviousResponseIds({ chat: null, langflow: null }); + }; + + const handleFileUpload = async (file: File) => { + console.log("handleFileUpload called with file:", file.name); + + if (isUploading) return; + + setIsUploading(true); + setLoading(true); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("endpoint", endpoint); + + // Add previous_response_id if we have one for this endpoint + const currentResponseId = previousResponseIds[endpoint]; + if (currentResponseId) { + formData.append("previous_response_id", currentResponseId); + } + + const response = await fetch("/api/upload_context", { + method: "POST", + body: formData, + }); + + console.log("Upload response status:", response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + "Upload failed with status:", + response.status, + "Response:", + errorText, + ); + throw new Error("Failed to process document"); + } + + const result = await response.json(); + console.log("Upload result:", result); + + if (response.status === 201) { + // New flow: Got task ID, start tracking with centralized system + const taskId = result.task_id || result.id; + + if (!taskId) { + console.error("No task ID in 201 response:", result); + throw new Error("No task ID received from server"); + } + + // Add task to centralized tracking + addTask(taskId); + + return null; + } else if (response.ok) { + // Original flow: Direct response + + const uploadMessage: Message = { + role: "user", + content: `I'm uploading a document called "${result.filename}". Here is its content:`, + timestamp: new Date(), + }; + + const confirmationMessage: Message = { + role: "assistant", + content: `Confirmed`, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, uploadMessage, confirmationMessage]); + + // Add file to conversation docs + if (result.filename) { + addConversationDoc(result.filename); + } + + // Update the response ID for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + + // If this is a new conversation (no currentConversationId), set it now + if (!currentConversationId) { + setCurrentConversationId(result.response_id); + refreshConversations(true); + } else { + // For existing conversations, do a silent refresh to keep backend in sync + refreshConversationsSilent(); + } + + return result.response_id; + } + } else { + throw new Error(`Upload failed: ${response.status}`); + } + } catch (error) { + console.error("Upload failed:", error); + const errorMessage: Message = { + role: "assistant", + content: `❌ Failed to process document. Please try again.`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + } finally { + setIsUploading(false); + setLoading(false); + } + }; + + const handleFilePickerClick = () => { + chatInputRef.current?.clickFileInput(); + }; + + const loadAvailableFilters = async () => { + try { + const response = await fetch("/api/knowledge-filter/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: "", + limit: 20, + }), + }); + + const result = await response.json(); + if (response.ok && result.success) { + setAvailableFilters(result.filters); + } else { + console.error("Failed to load knowledge filters:", result.error); + setAvailableFilters([]); + } + } catch (error) { + console.error("Failed to load knowledge filters:", error); + setAvailableFilters([]); + } + }; + + const handleFilterSelect = (filter: KnowledgeFilterData | null) => { + setSelectedFilter(filter); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setIsFilterHighlighted(false); + + // Remove the @searchTerm from the input and replace with filter pill + const words = input.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@")) { + // Remove the @search term + words.pop(); + setInput(words.join(" ") + (words.length > 0 ? " " : "")); + } + }; + + // Reset selected index when search term changes + useEffect(() => { + setSelectedFilterIndex(0); + }, []); + + // Auto-focus the input on component mount + useEffect(() => { + chatInputRef.current?.focusInput(); + }, []); + + // Explicitly handle external new conversation trigger + useEffect(() => { + const handleNewConversation = () => { + // Abort any in-flight streaming so it doesn't bleed into new chat + abortStream(); + // Reset chat UI even if context state was already 'new' + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + setInput(""); + setExpandedFunctionCalls(new Set()); + setIsFilterHighlighted(false); + setLoading(false); + lastLoadedConversationRef.current = null; + }; + + const handleFocusInput = () => { + chatInputRef.current?.focusInput(); + }; + + window.addEventListener("newConversation", handleNewConversation); + window.addEventListener("focusInput", handleFocusInput); + return () => { + window.removeEventListener("newConversation", handleNewConversation); + window.removeEventListener("focusInput", handleFocusInput); + }; + }, [abortStream, setLoading]); + + // Load conversation only when user explicitly selects a conversation + useEffect(() => { + // Only load conversation data when: + // 1. conversationData exists AND + // 2. It's different from the last loaded conversation AND + // 3. User is not in the middle of an interaction + if ( + conversationData && + conversationData.messages && + lastLoadedConversationRef.current !== conversationData.response_id && + !isUserInteracting && + !isForkingInProgress + ) { + console.log( + "Loading conversation with", + conversationData.messages.length, + "messages", + ); + // Convert backend message format to frontend Message interface + const convertedMessages: Message[] = conversationData.messages.map( + (msg: { + role: string; + content: string; + timestamp?: string; + response_id?: string; + chunks?: Array<{ + item?: { + type?: string; + tool_name?: string; + id?: string; + inputs?: unknown; + results?: unknown; + status?: string; + }; + delta?: { + tool_calls?: Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + type?: string; + }>; + }; + type?: string; + result?: unknown; + output?: unknown; + response?: unknown; + }>; + response_data?: unknown; + }) => { + const message: Message = { + role: msg.role as "user" | "assistant", + content: msg.content, + timestamp: new Date(msg.timestamp || new Date()), + }; + + // Extract function calls from chunks or response_data + if (msg.role === "assistant" && (msg.chunks || msg.response_data)) { + const functionCalls: FunctionCall[] = []; + console.log("Processing assistant message for function calls:", { + hasChunks: !!msg.chunks, + chunksLength: msg.chunks?.length, + hasResponseData: !!msg.response_data, + }); + + // Process chunks (streaming data) + if (msg.chunks && Array.isArray(msg.chunks)) { + for (const chunk of msg.chunks) { + // Handle Langflow format: chunks[].item.tool_call + if (chunk.item && chunk.item.type === "tool_call") { + const toolCall = chunk.item; + console.log("Found Langflow tool call:", toolCall); + functionCalls.push({ + id: toolCall.id || "", + name: toolCall.tool_name || "unknown", + arguments: + (toolCall.inputs as Record) || {}, + argumentsString: JSON.stringify(toolCall.inputs || {}), + result: toolCall.results as + | Record + | ToolCallResult[], + status: + (toolCall.status as "pending" | "completed" | "error") || + "completed", + type: "tool_call", + }); + } + // Handle OpenAI format: chunks[].delta.tool_calls + else if (chunk.delta?.tool_calls) { + for (const toolCall of chunk.delta.tool_calls) { + if (toolCall.function) { + functionCalls.push({ + id: toolCall.id || "", + name: toolCall.function.name || "unknown", + arguments: toolCall.function.arguments + ? JSON.parse(toolCall.function.arguments) + : {}, + argumentsString: toolCall.function.arguments || "", + status: "completed", + type: toolCall.type || "function", + }); + } + } + } + // Process tool call results from chunks + if ( + chunk.type === "response.tool_call.result" || + chunk.type === "tool_call_result" + ) { + const lastCall = functionCalls[functionCalls.length - 1]; + if (lastCall) { + lastCall.result = + (chunk.result as + | Record + | ToolCallResult[]) || + (chunk as Record); + lastCall.status = "completed"; + } + } + } + } + + // Process response_data (non-streaming data) + if (msg.response_data && typeof msg.response_data === "object") { + // Look for tool_calls in various places in the response data + const responseData = + typeof msg.response_data === "string" + ? JSON.parse(msg.response_data) + : msg.response_data; + + if ( + responseData.tool_calls && + Array.isArray(responseData.tool_calls) + ) { + for (const toolCall of responseData.tool_calls) { + functionCalls.push({ + id: toolCall.id, + name: toolCall.function?.name || toolCall.name, + arguments: + toolCall.function?.arguments || toolCall.arguments, + argumentsString: + typeof ( + toolCall.function?.arguments || toolCall.arguments + ) === "string" + ? toolCall.function?.arguments || toolCall.arguments + : JSON.stringify( + toolCall.function?.arguments || toolCall.arguments, + ), + result: toolCall.result, + status: "completed", + type: toolCall.type || "function", + }); + } + } + } + + if (functionCalls.length > 0) { + console.log("Setting functionCalls on message:", functionCalls); + message.functionCalls = functionCalls; + } else { + console.log("No function calls found in message"); + } + } + + return message; + }, + ); + + setMessages(convertedMessages); + lastLoadedConversationRef.current = conversationData.response_id; + + // Set the previous response ID for this conversation + setPreviousResponseIds((prev) => ({ + ...prev, + [conversationData.endpoint]: conversationData.response_id, + })); + } + }, [ + conversationData, + isUserInteracting, + isForkingInProgress, + setPreviousResponseIds, + ]); + + // Handle new conversation creation - only reset messages when placeholderConversation is set + useEffect(() => { + if (placeholderConversation && currentConversationId === null) { + console.log("Starting new conversation"); + setMessages([ + { + role: "assistant", + content: "How can I assist?", + timestamp: new Date(), + }, + ]); + lastLoadedConversationRef.current = null; + } + }, [placeholderConversation, currentConversationId]); + + // Listen for file upload events from navigation + useEffect(() => { + const handleFileUploadStart = (event: CustomEvent) => { + const { filename } = event.detail; + console.log("Chat page received file upload start event:", filename); + + setLoading(true); + setIsUploading(true); + setUploadedFile(null); // Clear previous file + }; + + const handleFileUploaded = (event: CustomEvent) => { + const { result } = event.detail; + console.log("Chat page received file upload event:", result); + + setUploadedFile(null); // Clear file after upload + + // Update the response ID for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + } + }; + + const handleFileUploadComplete = () => { + console.log("Chat page received file upload complete event"); + setLoading(false); + setIsUploading(false); + }; + + const handleFileUploadError = (event: CustomEvent) => { + const { filename, error } = event.detail; + console.log( + "Chat page received file upload error event:", + filename, + error, + ); + + // Replace the last message with error message + const errorMessage: Message = { + role: "assistant", + content: `❌ Upload failed for **${filename}**: ${error}`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev.slice(0, -1), errorMessage]); + setUploadedFile(null); // Clear file on error + }; + + window.addEventListener( + "fileUploadStart", + handleFileUploadStart as EventListener, + ); + window.addEventListener( + "fileUploaded", + handleFileUploaded as EventListener, + ); + window.addEventListener( + "fileUploadComplete", + handleFileUploadComplete as EventListener, + ); + window.addEventListener( + "fileUploadError", + handleFileUploadError as EventListener, + ); + + return () => { + window.removeEventListener( + "fileUploadStart", + handleFileUploadStart as EventListener, + ); + window.removeEventListener( + "fileUploaded", + handleFileUploaded as EventListener, + ); + window.removeEventListener( + "fileUploadComplete", + handleFileUploadComplete as EventListener, + ); + window.removeEventListener( + "fileUploadError", + handleFileUploadError as EventListener, + ); + }; + }, [endpoint, setPreviousResponseIds, setLoading]); + + // Check if onboarding is complete by looking at local storage + const [isOnboardingComplete, setIsOnboardingComplete] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("onboarding-step") === null; + }); + + // Listen for storage changes to detect when onboarding completes + useEffect(() => { + const checkOnboarding = () => { + if (typeof window !== "undefined") { + setIsOnboardingComplete( + localStorage.getItem("onboarding-step") === null, + ); + } + }; + + // Check periodically since storage events don't fire in the same tab + const interval = setInterval(checkOnboarding, 500); + + return () => clearInterval(interval); + }, []); + + // Prepare filters for nudges (same as chat) + const processedFiltersForNudges = parsedFilterData?.filters + ? (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") ? [] : filters.owners; + + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? processed : undefined; + })() + : undefined; + + const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( + { + chatId: previousResponseIds[endpoint], + filters: processedFiltersForNudges, + limit: parsedFilterData?.limit ?? 3, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }, + { + enabled: isOnboardingComplete, // Only fetch nudges after onboarding is complete + }, + ); + + const handleSSEStream = async ( + userMessage: Message, + previousResponseId?: string, + ) => { + // Prepare filters + const processedFilters = parsedFilterData?.filters + ? (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") ? [] : filters.owners; + + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? processed : undefined; + })() + : undefined; + + // Use passed previousResponseId if available, otherwise fall back to state + const responseIdToUse = previousResponseId || previousResponseIds[endpoint]; + + // Use the hook to send the message + await sendStreamingMessage({ + prompt: userMessage.content, + previousResponseId: responseIdToUse || undefined, + filters: processedFilters, + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }); + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + }; + + const handleSendMessage = async ( + inputMessage: string, + previousResponseId?: string, + ) => { + if (!inputMessage.trim() || loading) return; + + const userMessage: Message = { + role: "user", + content: inputMessage.trim(), + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(""); + setLoading(true); + setIsFilterHighlighted(false); + + scrollToBottom({ + animation: "smooth", + duration: 1000, + }); + + if (asyncMode) { + await handleSSEStream(userMessage, previousResponseId); + } else { + // Original non-streaming logic + try { + const apiEndpoint = endpoint === "chat" ? "/api/chat" : "/api/langflow"; + + const requestBody: RequestBody = { + prompt: userMessage.content, + ...(parsedFilterData?.filters && + (() => { + const filters = parsedFilterData.filters; + const processed: SelectedFilters = { + data_sources: [], + document_types: [], + owners: [], + }; + // Only copy non-wildcard arrays + processed.data_sources = filters.data_sources.includes("*") + ? [] + : filters.data_sources; + processed.document_types = filters.document_types.includes("*") + ? [] + : filters.document_types; + processed.owners = filters.owners.includes("*") + ? [] + : filters.owners; + + // Only include filters if any array has values + const hasFilters = + processed.data_sources.length > 0 || + processed.document_types.length > 0 || + processed.owners.length > 0; + return hasFilters ? { filters: processed } : {}; + })()), + limit: parsedFilterData?.limit ?? 10, + scoreThreshold: parsedFilterData?.scoreThreshold ?? 0, + }; + + // Add previous_response_id if we have one for this endpoint + const currentResponseId = previousResponseIds[endpoint]; + if (currentResponseId) { + requestBody.previous_response_id = currentResponseId; + } + + const response = await fetch(apiEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json(); + + if (response.ok) { + const assistantMessage: Message = { + role: "assistant", + content: result.response, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + if (result.response_id) { + cancelNudges(); + } + + // Store the response ID if present for this endpoint + if (result.response_id) { + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: result.response_id, + })); + + // If this is a new conversation (no currentConversationId), set it now + if (!currentConversationId) { + setCurrentConversationId(result.response_id); + refreshConversations(true); + } else { + // For existing conversations, do a silent refresh to keep backend in sync + refreshConversationsSilent(); + } + } + } else { + console.error("Chat failed:", result.error); + const errorMessage: Message = { + role: "assistant", + content: "Sorry, I encountered an error. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } + } catch (error) { + console.error("Chat error:", error); + const errorMessage: Message = { + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } + } + + setLoading(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Check if there's an uploaded file and upload it first + let uploadedResponseId: string | null = null; + if (uploadedFile) { + // Upload the file first + const responseId = await handleFileUpload(uploadedFile); + // Clear the file after upload + setUploadedFile(null); + + // If the upload resulted in a new conversation, store the response ID + if (responseId) { + uploadedResponseId = responseId; + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseId, + })); + } + } + + // Only send message if there's input text + if (input.trim() || uploadedFile) { + // Pass the responseId from upload (if any) to handleSendMessage + handleSendMessage( + !input.trim() ? FILE_CONFIRMATION : input, + uploadedResponseId || undefined, + ); + } + }; + + const toggleFunctionCall = (functionCallId: string) => { + setExpandedFunctionCalls((prev) => { + const newSet = new Set(prev); + if (newSet.has(functionCallId)) { + newSet.delete(functionCallId); + } else { + newSet.add(functionCallId); + } + return newSet; + }); + }; + + const handleForkConversation = ( + messageIndex: number, + event?: React.MouseEvent, + ) => { + // Prevent any default behavior and stop event propagation + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + // Set interaction state to prevent auto-scroll interference + setIsUserInteracting(true); + setIsForkingInProgress(true); + + console.log("Fork conversation called for message index:", messageIndex); + + // Get messages up to and including the selected assistant message + const messagesToKeep = messages.slice(0, messageIndex + 1); + + // The selected message should be an assistant message (since fork button is only on assistant messages) + const forkedMessage = messages[messageIndex]; + if (forkedMessage.role !== "assistant") { + console.error("Fork button should only be on assistant messages"); + setIsUserInteracting(false); + setIsForkingInProgress(false); + return; + } + + // For forking, we want to continue from the response_id of the assistant message we're forking from + // Since we don't store individual response_ids per message yet, we'll use the current conversation's response_id + // This means we're continuing the conversation thread from that point + const responseIdToForkFrom = + currentConversationId || previousResponseIds[endpoint]; + + // Create a new conversation by properly forking + setMessages(messagesToKeep); + + // Use the chat context's fork method which handles creating a new conversation properly + if (forkFromResponse) { + forkFromResponse(responseIdToForkFrom || ""); + } else { + // Fallback to manual approach + setCurrentConversationId(null); // This creates a new conversation thread + + // Set the response_id we want to continue from as the previous response ID + // This tells the backend to continue the conversation from this point + setPreviousResponseIds((prev) => ({ + ...prev, + [endpoint]: responseIdToForkFrom, + })); + } + + console.log("Forked conversation with", messagesToKeep.length, "messages"); + + // Reset interaction state after a longer delay to ensure all effects complete + setTimeout(() => { + setIsUserInteracting(false); + setIsForkingInProgress(false); + console.log("Fork interaction complete, re-enabling auto effects"); + }, 500); + + // The original conversation remains unchanged in the sidebar + // This new forked conversation will get its own response_id when the user sends the next message + }; + + const handleSuggestionClick = (suggestion: string) => { + handleSendMessage(suggestion); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle backspace for filter clearing + if (e.key === "Backspace" && selectedFilter && input.trim() === "") { + e.preventDefault(); + + if (isFilterHighlighted) { + // Second backspace - remove the filter + setSelectedFilter(null); + setIsFilterHighlighted(false); + } else { + // First backspace - highlight the filter + setIsFilterHighlighted(true); + } + return; + } + + if (isFilterDropdownOpen) { + const filteredFilters = availableFilters.filter((filter) => + filter.name.toLowerCase().includes(filterSearchTerm.toLowerCase()), + ); + + if (e.key === "Escape") { + e.preventDefault(); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + setDropdownDismissed(true); + + // Keep focus on the textarea so user can continue typing normally + chatInputRef.current?.focusInput(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev < filteredFilters.length - 1 ? prev + 1 : 0, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedFilterIndex((prev) => + prev > 0 ? prev - 1 : filteredFilters.length - 1, + ); + return; + } + + if (e.key === "Enter") { + // Check if we're at the end of an @ mention (space before cursor or end of input) + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + + if (e.key === " ") { + // Select filter on space if we're typing an @ mention + const cursorPos = e.currentTarget.selectionStart || 0; + const textBeforeCursor = input.slice(0, cursorPos); + const words = textBeforeCursor.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && filteredFilters[selectedFilterIndex]) { + e.preventDefault(); + handleFilterSelect(filteredFilters[selectedFilterIndex]); + return; + } + } + } + + if (e.key === "Enter" && !e.shiftKey && !isFilterDropdownOpen) { + e.preventDefault(); + if (input.trim() && !loading) { + // Trigger form submission by finding the form and calling submit + const form = e.currentTarget.closest("form"); + if (form) { + form.requestSubmit(); + } + } + } + }; + + const onChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInput(newValue); + + // Clear filter highlight when user starts typing + if (isFilterHighlighted) { + setIsFilterHighlighted(false); + } + + // Find if there's an @ at the start of the last word + const words = newValue.split(" "); + const lastWord = words[words.length - 1]; + + if (lastWord.startsWith("@") && !dropdownDismissed) { + const searchTerm = lastWord.slice(1); // Remove the @ + console.log("Setting search term:", searchTerm); + setFilterSearchTerm(searchTerm); + setSelectedFilterIndex(0); + + // Only set anchor position when @ is first detected (search term is empty) + if (searchTerm === "") { + const pos = getCursorPosition(e.target); + setAnchorPosition(pos); + } + + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + } + } else if (isFilterDropdownOpen) { + // Close dropdown if @ is no longer present + console.log("Closing dropdown - no @ found"); + setIsFilterDropdownOpen(false); + setFilterSearchTerm(""); + } + + // Reset dismissed flag when user moves to a different word + if (dropdownDismissed && !lastWord.startsWith("@")) { + setDropdownDismissed(false); + } + }; + + const onAtClick = () => { + if (!isFilterDropdownOpen) { + loadAvailableFilters(); + setIsFilterDropdownOpen(true); + setFilterSearchTerm(""); + setSelectedFilterIndex(0); + + // Get button position for popover anchoring + const button = document.querySelector( + "[data-filter-button]", + ) as HTMLElement; + if (button) { + const rect = button.getBoundingClientRect(); + setAnchorPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 - 12, + }); + } + } else { + setIsFilterDropdownOpen(false); + setAnchorPosition(null); + } + }; + + return ( + <> + {/* Debug header - only show in debug mode */} + {isDebugMode && ( +
+
+
+ {/* Async Mode Toggle */} +
+ + +
+ {/* Endpoint Toggle */} +
+ + +
+
+
+ )} + + +
+ {messages.length === 0 && !streamingMessage ? ( +
+
+ {isUploading ? ( + <> + +

Processing your document...

+

This may take a few moments

+ + ) : null} +
+
+ ) : ( + <> + {messages.map((message, index) => + message.role === "user" + ? (messages[index]?.content.match(FILES_REGEX)?.[0] ?? + null) === null && ( +
+ = 2 && + (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? + undefined) && + message.content === FILE_CONFIRMATION + ? undefined + : message.content + } + files={ + index >= 2 + ? (messages[index - 2]?.content.match( + FILES_REGEX, + )?.[0] ?? undefined) + : undefined + } + /> +
+ ) + : message.role === "assistant" && + (index < 1 || + (messages[index - 1]?.content.match(FILES_REGEX)?.[0] ?? + null) === null) && ( +
+ handleForkConversation(index, e)} + animate={false} + isInactive={index < messages.length - 1} + /> +
+ ), + )} + + {/* Streaming Message Display */} + {streamingMessage && ( + + )} + + )} + {!streamingMessage && ( +
+ +
+ )} +
+
+
+ {/* Input Area - Fixed at bottom */} + +
+ + ); +} + +export default function ProtectedChatPage() { + return ( + +
+ + + +
+
+ ); +} diff --git a/frontend/src/app/connectors/page.tsx b/frontend/app/connectors/page.tsx similarity index 100% rename from frontend/src/app/connectors/page.tsx rename to frontend/app/connectors/page.tsx diff --git a/frontend/src/app/favicon.ico b/frontend/app/favicon.ico similarity index 100% rename from frontend/src/app/favicon.ico rename to frontend/app/favicon.ico diff --git a/frontend/src/app/globals.css b/frontend/app/globals.css similarity index 100% rename from frontend/src/app/globals.css rename to frontend/app/globals.css diff --git a/frontend/src/app/icon.png b/frontend/app/icon.png similarity index 100% rename from frontend/src/app/icon.png rename to frontend/app/icon.png diff --git a/frontend/src/app/knowledge/chunks/page.tsx b/frontend/app/knowledge/chunks/page.tsx similarity index 100% rename from frontend/src/app/knowledge/chunks/page.tsx rename to frontend/app/knowledge/chunks/page.tsx diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx new file mode 100644 index 00000000..d3545582 --- /dev/null +++ b/frontend/app/knowledge/page.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { + type CheckboxSelectionCallbackParams, + type ColDef, + type GetRowIdParams, + themeQuartz, + type ValueFormatterParams, +} from "ag-grid-community"; +import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; +import { Cloud, FileIcon, Globe } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; +import { ProtectedRoute } from "@/components/protected-route"; +import { Button } from "@/components/ui/button"; +import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useTask } from "@/contexts/task-context"; +import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; +import "@/components/AgGrid/registerAgGridModules"; +import "@/components/AgGrid/agGridStyles.css"; +import { toast } from "sonner"; +import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown"; +import { KnowledgeSearchInput } from "@/components/knowledge-search-input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { DeleteConfirmationDialog } from "../../components/delete-confirmation-dialog"; +import GoogleDriveIcon from "../../components/icons/google-drive-logo"; +import OneDriveIcon from "../../components/icons/one-drive-logo"; +import SharePointIcon from "../../components/icons/share-point-logo"; +import { useDeleteDocument } from "../api/mutations/useDeleteDocument"; + +// Function to get the appropriate icon for a connector type +function getSourceIcon(connectorType?: string) { + switch (connectorType) { + case "google_drive": + return ( + + ); + case "onedrive": + return ; + case "sharepoint": + return ( + + ); + case "url": + return ; + case "s3": + return ; + default: + return ( + + ); + } +} + +function SearchPage() { + const router = useRouter(); + const { files: taskFiles, refreshTasks } = useTask(); + const { parsedFilterData, queryOverride } = useKnowledgeFilter(); + const [selectedRows, setSelectedRows] = useState([]); + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + + const deleteDocumentMutation = useDeleteDocument(); + + useEffect(() => { + refreshTasks(); + }, [refreshTasks]); + + const { data: searchData = [], isFetching } = useGetSearchQuery( + queryOverride, + parsedFilterData, + ); + // Convert TaskFiles to File format and merge with backend results + const taskFilesAsFiles: File[] = taskFiles.map((taskFile) => { + return { + filename: taskFile.filename, + mimetype: taskFile.mimetype, + source_url: taskFile.source_url, + size: taskFile.size, + connector_type: taskFile.connector_type, + status: taskFile.status, + error: taskFile.error, + embedding_model: taskFile.embedding_model, + embedding_dimensions: taskFile.embedding_dimensions, + }; + }); + // Create a map of task files by filename for quick lookup + const taskFileMap = new Map( + taskFilesAsFiles.map((file) => [file.filename, file]), + ); + // Override backend files with task file status if they exist + const backendFiles = (searchData as File[]) + .map((file) => { + const taskFile = taskFileMap.get(file.filename); + if (taskFile) { + // Override backend file with task file data (includes status) + return { ...file, ...taskFile }; + } + return file; + }) + .filter((file) => { + // Only filter out files that are currently processing AND in taskFiles + const taskFile = taskFileMap.get(file.filename); + return !taskFile || taskFile.status !== "processing"; + }); + + const filteredTaskFiles = taskFilesAsFiles.filter((taskFile) => { + return ( + taskFile.status !== "active" && + !backendFiles.some( + (backendFile) => backendFile.filename === taskFile.filename, + ) + ); + }); + // Combine task files first, then backend files + const fileResults = [...backendFiles, ...filteredTaskFiles]; + const gridRef = useRef(null); + + const columnDefs: ColDef[] = [ + { + field: "filename", + headerName: "Source", + checkboxSelection: (params: CheckboxSelectionCallbackParams) => + (params?.data?.status || "active") === "active", + headerCheckboxSelection: true, + initialFlex: 2, + minWidth: 220, + cellRenderer: ({ data, value }: CustomCellRendererProps) => { + // Read status directly from data on each render + const status = data?.status || "active"; + const isActive = status === "active"; + return ( +
+
+ +
+ ); + }, + }, + { + field: "size", + headerName: "Size", + valueFormatter: (params: ValueFormatterParams) => + params.value ? `${Math.round(params.value / 1024)} KB` : "-", + }, + { + field: "mimetype", + headerName: "Type", + }, + { + field: "owner", + headerName: "Owner", + valueFormatter: (params: ValueFormatterParams) => + params.data?.owner_name || params.data?.owner_email || "—", + }, + { + field: "chunkCount", + headerName: "Chunks", + valueFormatter: (params: ValueFormatterParams) => + params.data?.chunkCount?.toString() || "-", + }, + { + field: "avgScore", + headerName: "Avg score", + cellRenderer: ({ value }: CustomCellRendererProps) => { + return ( + + {value?.toFixed(2) ?? "-"} + + ); + }, + }, + { + field: "embedding_model", + headerName: "Embedding model", + minWidth: 200, + cellRenderer: ({ data }: CustomCellRendererProps) => ( + + {data?.embedding_model || "—"} + + ), + }, + { + field: "embedding_dimensions", + headerName: "Dimensions", + width: 110, + cellRenderer: ({ data }: CustomCellRendererProps) => ( + + {typeof data?.embedding_dimensions === "number" + ? data.embedding_dimensions.toString() + : "—"} + + ), + }, + { + field: "status", + headerName: "Status", + cellRenderer: ({ data }: CustomCellRendererProps) => { + const status = data?.status || "active"; + const error = + typeof data?.error === "string" && data.error.trim().length > 0 + ? data.error.trim() + : undefined; + if (status === "failed" && error) { + return ( + + + + + + + Ingestion failed + + {data?.filename || "Unknown file"} + + +
+ {error} +
+
+
+ ); + } + return ; + }, + }, + { + cellRenderer: ({ data }: CustomCellRendererProps) => { + const status = data?.status || "active"; + if (status !== "active") { + return null; + } + return ; + }, + cellStyle: { + alignItems: "center", + display: "flex", + justifyContent: "center", + padding: 0, + }, + colId: "actions", + filter: false, + minWidth: 0, + width: 40, + resizable: false, + sortable: false, + initialFlex: 0, + }, + ]; + + const defaultColDef: ColDef = { + resizable: false, + suppressMovable: true, + initialFlex: 1, + minWidth: 100, + }; + + const onSelectionChanged = useCallback(() => { + if (gridRef.current) { + const selectedNodes = gridRef.current.api.getSelectedRows(); + setSelectedRows(selectedNodes); + } + }, []); + + const handleBulkDelete = async () => { + if (selectedRows.length === 0) return; + + try { + // Delete each file individually since the API expects one filename at a time + const deletePromises = selectedRows.map((row) => + deleteDocumentMutation.mutateAsync({ filename: row.filename }), + ); + + await Promise.all(deletePromises); + + toast.success( + `Successfully deleted ${selectedRows.length} document${ + selectedRows.length > 1 ? "s" : "" + }`, + ); + setSelectedRows([]); + setShowBulkDeleteDialog(false); + + // Clear selection in the grid + if (gridRef.current) { + gridRef.current.api.deselectAll(); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to delete some documents", + ); + } + }; + + return ( + <> +
+
+

Project Knowledge

+
+ + {/* Search Input Area */} +
+ + {/* //TODO: Implement sync button */} + {/* */} + {selectedRows.length > 0 && ( + + )} +
+ +
+
+ []} + defaultColDef={defaultColDef} + loading={isFetching} + ref={gridRef} + theme={themeQuartz.withParams({ browserColorScheme: "inherit" })} + rowData={fileResults} + rowSelection="multiple" + rowMultiSelectWithClick={false} + suppressRowClickSelection={true} + getRowId={(params: GetRowIdParams) => params.data?.filename} + domLayout="normal" + onSelectionChanged={onSelectionChanged} + noRowsOverlayComponent={() => ( +
+
+ No knowledge +
+
+ Add files from local or your preferred cloud. +
+
+ )} + /> +
+ + {/* Bulk Delete Confirmation Dialog */} + 1 ? "s" : "" + }? This will remove all chunks and data associated with these documents. This action cannot be undone. + +Documents to be deleted: +${selectedRows.map((row) => `• ${row.filename}`).join("\n")}`} + confirmText="Delete All" + onConfirm={handleBulkDelete} + isLoading={deleteDocumentMutation.isPending} + /> + + ); +} + +export default function ProtectedSearchPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/app/layout.tsx similarity index 100% rename from frontend/src/app/layout.tsx rename to frontend/app/layout.tsx diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 00000000..eed36a9e --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useEffect } from "react"; +import GoogleLogo from "@/components/icons/google-logo"; +import Logo from "@/components/icons/openrag-logo"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth-context"; + +function LoginPageContent() { + const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const redirect = searchParams.get("redirect") || "/chat"; + + // Redirect if already authenticated or in no-auth mode + useEffect(() => { + if (!isLoading && (isAuthenticated || isNoAuthMode)) { + router.push(redirect); + } + }, [isLoading, isAuthenticated, isNoAuthMode, router, redirect]); + + if (isLoading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + if (isAuthenticated || isNoAuthMode) { + return null; // Will redirect in useEffect + } + + return ( +
+
+ +
+

+ Welcome to OpenRAG +

+ +
+
+
+ ); +} + +export default function LoginPage() { + return ( + +
+ +

Loading...

+
+
+ } + > + + + ); +} diff --git a/frontend/src/app/onboarding/components/advanced.tsx b/frontend/app/onboarding/_components/advanced.tsx similarity index 100% rename from frontend/src/app/onboarding/components/advanced.tsx rename to frontend/app/onboarding/_components/advanced.tsx diff --git a/frontend/app/onboarding/_components/animated-provider-steps.tsx b/frontend/app/onboarding/_components/animated-provider-steps.tsx new file mode 100644 index 00000000..da941426 --- /dev/null +++ b/frontend/app/onboarding/_components/animated-provider-steps.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import AnimatedProcessingIcon from "@/components/icons/animated-processing-icon"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { cn } from "@/lib/utils"; + +export function AnimatedProviderSteps({ + currentStep, + isCompleted, + setCurrentStep, + steps, + storageKey = "provider-steps", + processingStartTime, + hasError = false, +}: { + currentStep: number; + isCompleted: boolean; + setCurrentStep: (step: number) => void; + steps: string[]; + storageKey?: string; + processingStartTime?: number | null; + hasError?: boolean; +}) { + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(0); + + // Initialize start time from prop or local storage + useEffect(() => { + const storedElapsedTime = localStorage.getItem(`${storageKey}-elapsed`); + + if (isCompleted && storedElapsedTime) { + // If completed, use stored elapsed time + setElapsedTime(parseFloat(storedElapsedTime)); + } else if (processingStartTime) { + // Use the start time passed from parent (when user clicked Complete) + setStartTime(processingStartTime); + } + }, [storageKey, isCompleted, processingStartTime]); + + // Progress through steps + useEffect(() => { + if (currentStep < steps.length - 1 && !isCompleted) { + const interval = setInterval(() => { + setCurrentStep(currentStep + 1); + }, 1500); + return () => clearInterval(interval); + } + }, [currentStep, setCurrentStep, steps, isCompleted]); + + // Calculate and store elapsed time when completed + useEffect(() => { + if (isCompleted && startTime) { + const elapsed = Date.now() - startTime; + setElapsedTime(elapsed); + localStorage.setItem(`${storageKey}-elapsed`, elapsed.toString()); + } + }, [isCompleted, startTime, storageKey]); + + const isDone = currentStep >= steps.length && !isCompleted && !hasError; + + return ( + + {!isCompleted ? ( + +
+
+ + + +
+ + + {hasError ? "Error" : isDone ? "Done" : "Thinking"} + +
+
+ + {!isDone && !hasError && ( + +
+
+ + + {steps[currentStep]} + + +
+ + )} + +
+
+ ) : ( + + + + +
+ + {`Initialized in ${(elapsedTime / 1000).toFixed(1)} seconds`} + +
+
+ +
+ {/* Connecting line on the left */} + + +
+ + {steps.map((step, index) => ( + + + + + + + + {step} + + + ))} + +
+
+
+
+
+
+ )} +
+ ); +} diff --git a/frontend/app/onboarding/_components/anthropic-onboarding.tsx b/frontend/app/onboarding/_components/anthropic-onboarding.tsx new file mode 100644 index 00000000..c1a30242 --- /dev/null +++ b/frontend/app/onboarding/_components/anthropic-onboarding.tsx @@ -0,0 +1,154 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import AnthropicLogo from "@/components/icons/anthropic-logo"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetAnthropicModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../_hooks/useModelSelection"; +import { useUpdateSettings } from "../_hooks/useUpdateSettings"; +import { AdvancedOnboarding } from "./advanced"; + +export function AnthropicOnboarding({ + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + isEmbedding = false, + hasEnvApiKey = false, +}: { + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + isEmbedding?: boolean; + hasEnvApiKey?: boolean; +}) { + const [apiKey, setApiKey] = useState(""); + const [getFromEnv, setGetFromEnv] = useState(hasEnvApiKey); + const debouncedApiKey = useDebouncedValue(apiKey, 500); + + // Fetch models from API when API key is provided + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetAnthropicModelsQuery( + getFromEnv + ? { apiKey: "" } + : debouncedApiKey + ? { apiKey: debouncedApiKey } + : undefined, + { enabled: debouncedApiKey !== "" || getFromEnv }, + ); + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + const handleSampleDatasetChange = (dataset: boolean) => { + setSampleDataset(dataset); + }; + + const handleGetFromEnvChange = (fromEnv: boolean) => { + setGetFromEnv(fromEnv); + if (fromEnv) { + setApiKey(""); + } + setEmbeddingModel?.(""); + setLanguageModel?.(""); + }; + + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + }, [isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "anthropic", + { + apiKey, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + return ( + <> +
+ + + +
+ +
+
+ {!hasEnvApiKey && ( + + Anthropic API key not detected in the environment. + + )} +
+
+ {!getFromEnv && ( +
+ setApiKey(e.target.value)} + /> + {isLoadingModels && ( +

+ Validating API key... +

+ )} + {modelsError && ( +

+ Invalid Anthropic API key. Verify or replace the key. +

+ )} +
+ )} +
+ } + languageModels={languageModels} + embeddingModels={embeddingModels} + languageModel={languageModel} + embeddingModel={embeddingModel} + sampleDataset={sampleDataset} + setLanguageModel={setLanguageModel} + setSampleDataset={handleSampleDatasetChange} + setEmbeddingModel={setEmbeddingModel} + /> + + ); +} diff --git a/frontend/app/onboarding/_components/ibm-onboarding.tsx b/frontend/app/onboarding/_components/ibm-onboarding.tsx new file mode 100644 index 00000000..5e444e54 --- /dev/null +++ b/frontend/app/onboarding/_components/ibm-onboarding.tsx @@ -0,0 +1,212 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import IBMLogo from "@/components/icons/ibm-logo"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetIBMModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../_hooks/useModelSelection"; +import { useUpdateSettings } from "../_hooks/useUpdateSettings"; +import { AdvancedOnboarding } from "./advanced"; +import { ModelSelector } from "./model-selector"; + +export function IBMOnboarding({ + isEmbedding = false, + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + alreadyConfigured = false, +}: { + isEmbedding?: boolean; + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + alreadyConfigured?: boolean; +}) { + const [endpoint, setEndpoint] = useState("https://us-south.ml.cloud.ibm.com"); + const [apiKey, setApiKey] = useState(""); + const [projectId, setProjectId] = useState(""); + + const options = [ + { + value: "https://us-south.ml.cloud.ibm.com", + label: "https://us-south.ml.cloud.ibm.com", + default: true, + }, + { + value: "https://eu-de.ml.cloud.ibm.com", + label: "https://eu-de.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://eu-gb.ml.cloud.ibm.com", + label: "https://eu-gb.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://au-syd.ml.cloud.ibm.com", + label: "https://au-syd.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://jp-tok.ml.cloud.ibm.com", + label: "https://jp-tok.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://ca-tor.ml.cloud.ibm.com", + label: "https://ca-tor.ml.cloud.ibm.com", + default: false, + }, + ]; + const debouncedEndpoint = useDebouncedValue(endpoint, 500); + const debouncedApiKey = useDebouncedValue(apiKey, 500); + const debouncedProjectId = useDebouncedValue(projectId, 500); + + // Fetch models from API when all credentials are provided + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetIBMModelsQuery( + { + endpoint: debouncedEndpoint, + apiKey: debouncedApiKey, + projectId: debouncedProjectId, + }, + { + enabled: !!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId, + }, + ); + + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + const handleSampleDatasetChange = (dataset: boolean) => { + setSampleDataset(dataset); + }; + + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + }, [isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "watsonx", + { + endpoint, + apiKey, + projectId, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + return ( + <> +
+ +
+ {} : setEndpoint} + searchPlaceholder="Search endpoint..." + noOptionsPlaceholder={ + alreadyConfigured + ? "https://•••••••••••••••••••••••••••••••••••••••••" + : "No endpoints available" + } + placeholder="Select endpoint..." + /> + {alreadyConfigured && ( +

+ Reusing endpoint from model provider selection. +

+ )} +
+
+ +
+ setProjectId(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing project ID from model provider selection. +

+ )} +
+
+ setApiKey(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing API key from model provider selection. +

+ )} +
+ {isLoadingModels && ( +

+ Validating configuration... +

+ )} + {modelsError && ( +

+ Connection failed. Check your configuration. +

+ )} +
+ } + languageModels={languageModels} + embeddingModels={embeddingModels} + languageModel={languageModel} + embeddingModel={embeddingModel} + sampleDataset={sampleDataset} + setLanguageModel={setLanguageModel} + setEmbeddingModel={setEmbeddingModel} + setSampleDataset={handleSampleDatasetChange} + /> + + ); +} diff --git a/frontend/src/app/onboarding/components/model-selector.tsx b/frontend/app/onboarding/_components/model-selector.tsx similarity index 100% rename from frontend/src/app/onboarding/components/model-selector.tsx rename to frontend/app/onboarding/_components/model-selector.tsx diff --git a/frontend/app/onboarding/_components/ollama-onboarding.tsx b/frontend/app/onboarding/_components/ollama-onboarding.tsx new file mode 100644 index 00000000..42ec1f79 --- /dev/null +++ b/frontend/app/onboarding/_components/ollama-onboarding.tsx @@ -0,0 +1,173 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import OllamaLogo from "@/components/icons/ollama-logo"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetOllamaModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../_hooks/useModelSelection"; +import { useUpdateSettings } from "../_hooks/useUpdateSettings"; +import { ModelSelector } from "./model-selector"; + +export function OllamaOnboarding({ + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + isEmbedding = false, + alreadyConfigured = false, +}: { + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + isEmbedding?: boolean; + alreadyConfigured?: boolean; +}) { + const [endpoint, setEndpoint] = useState(`http://localhost:11434`); + const [showConnecting, setShowConnecting] = useState(false); + const debouncedEndpoint = useDebouncedValue(endpoint, 500); + + // Fetch models from API when endpoint is provided (debounced) + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetOllamaModelsQuery( + debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined, + ); + + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + + // Handle delayed display of connecting state + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (debouncedEndpoint && isLoadingModels) { + timeoutId = setTimeout(() => { + setIsLoadingModels?.(true); + setShowConnecting(true); + }, 500); + } else { + setShowConnecting(false); + setIsLoadingModels?.(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [debouncedEndpoint, isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "ollama", + { + endpoint, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + // Check validation state based on models query + const hasConnectionError = debouncedEndpoint && modelsError; + const hasNoModels = + modelsData && + !modelsData.language_models?.length && + !modelsData.embedding_models?.length; + + return ( +
+
+ setEndpoint(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing endpoint from model provider selection. +

+ )} + {showConnecting && ( +

+ Connecting to Ollama server... +

+ )} + {hasConnectionError && ( +

+ Can't reach Ollama at {debouncedEndpoint}. Update the base URL or + start the server. +

+ )} + {hasNoModels && ( +

+ No models found. Install embedding and agent models on your Ollama + server. +

+ )} +
+ {isEmbedding && setEmbeddingModel && ( + + } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No embedding models detected. Install an embedding model to continue." + } + value={embeddingModel} + onValueChange={setEmbeddingModel} + /> + + )} + {!isEmbedding && setLanguageModel && ( + + } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No language models detected. Install a language model to continue." + } + value={languageModel} + onValueChange={setLanguageModel} + /> + + )} +
+ ); +} diff --git a/frontend/app/onboarding/_components/onboarding-card.tsx b/frontend/app/onboarding/_components/onboarding-card.tsx new file mode 100644 index 00000000..c0dd9591 --- /dev/null +++ b/frontend/app/onboarding/_components/onboarding-card.tsx @@ -0,0 +1,538 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "framer-motion"; +import { Info, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + type OnboardingVariables, + useOnboardingMutation, +} from "@/app/api/mutations/useOnboardingMutation"; +import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; +import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery"; +import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; +import { useDoclingHealth } from "@/components/docling-health-banner"; +import AnthropicLogo from "@/components/icons/anthropic-logo"; +import IBMLogo from "@/components/icons/ibm-logo"; +import OllamaLogo from "@/components/icons/ollama-logo"; +import OpenAILogo from "@/components/icons/openai-logo"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { AnimatedProviderSteps } from "./animated-provider-steps"; +import { AnthropicOnboarding } from "./anthropic-onboarding"; +import { IBMOnboarding } from "./ibm-onboarding"; +import { OllamaOnboarding } from "./ollama-onboarding"; +import { OpenAIOnboarding } from "./openai-onboarding"; +import { TabTrigger } from "./tab-trigger"; + +interface OnboardingCardProps { + onComplete: () => void; + isCompleted?: boolean; + isEmbedding?: boolean; + setIsLoadingModels?: (isLoading: boolean) => void; + setLoadingStatus?: (status: string[]) => void; +} + +const STEP_LIST = [ + "Setting up your model provider", + "Defining schema", + "Configuring Langflow", +]; + +const EMBEDDING_STEP_LIST = [ + "Setting up your model provider", + "Defining schema", + "Configuring Langflow", + "Ingesting sample data", +]; + +const OnboardingCard = ({ + onComplete, + isEmbedding = false, + isCompleted = false, +}: OnboardingCardProps) => { + const { isHealthy: isDoclingHealthy } = useDoclingHealth(); + + const [modelProvider, setModelProvider] = useState( + isEmbedding ? "openai" : "anthropic", + ); + + const [sampleDataset, setSampleDataset] = useState(true); + + const [isLoadingModels, setIsLoadingModels] = useState(false); + + const queryClient = useQueryClient(); + + // Fetch current settings to check if providers are already configured + const { data: currentSettings } = useGetSettingsQuery(); + + const handleSetModelProvider = (provider: string) => { + setIsLoadingModels(false); + setModelProvider(provider); + setSettings({ + [isEmbedding ? "embedding_provider" : "llm_provider"]: provider, + embedding_model: "", + llm_model: "", + }); + setError(null); + }; + + // Check if the selected provider is already configured + const isProviderAlreadyConfigured = (provider: string): boolean => { + if (!isEmbedding || !currentSettings?.providers) return false; + + // Check if provider has been explicitly configured (not just from env vars) + if (provider === "openai") { + return currentSettings.providers.openai?.configured === true; + } else if (provider === "anthropic") { + return currentSettings.providers.anthropic?.configured === true; + } else if (provider === "watsonx") { + return currentSettings.providers.watsonx?.configured === true; + } else if (provider === "ollama") { + return currentSettings.providers.ollama?.configured === true; + } + return false; + }; + + const showProviderConfiguredMessage = + isProviderAlreadyConfigured(modelProvider); + const providerAlreadyConfigured = + isEmbedding && showProviderConfiguredMessage; + + const totalSteps = isEmbedding + ? EMBEDDING_STEP_LIST.length + : STEP_LIST.length; + + const [settings, setSettings] = useState({ + [isEmbedding ? "embedding_provider" : "llm_provider"]: modelProvider, + embedding_model: "", + llm_model: "", + // Provider-specific fields will be set by provider components + openai_api_key: "", + anthropic_api_key: "", + watsonx_api_key: "", + watsonx_endpoint: "", + watsonx_project_id: "", + ollama_endpoint: "", + }); + + const [currentStep, setCurrentStep] = useState( + isCompleted ? totalSteps : null, + ); + + const [processingStartTime, setProcessingStartTime] = useState( + null, + ); + + const [error, setError] = useState(null); + + // Query tasks to track completion + const { data: tasks } = useGetTasksQuery({ + enabled: currentStep !== null, // Only poll when onboarding has started + refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding + }); + + // Monitor tasks and call onComplete when all tasks are done + useEffect(() => { + if (currentStep === null || !tasks || !isEmbedding) { + return; + } + + // Check if there are any active tasks (pending, running, or processing) + const activeTasks = tasks.find( + (task) => + task.status === "pending" || + task.status === "running" || + task.status === "processing", + ); + + // If no active tasks and we've started onboarding, complete it + if ( + (!activeTasks || (activeTasks.processed_files ?? 0) > 0) && + tasks.length > 0 && + !isCompleted + ) { + // Set to final step to show "Done" + setCurrentStep(totalSteps); + // Wait a bit before completing + setTimeout(() => { + onComplete(); + }, 1000); + } + }, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps]); + + // Mutations + const onboardingMutation = useOnboardingMutation({ + onSuccess: (data) => { + console.log("Onboarding completed successfully", data); + // Update provider health cache to healthy since backend just validated + const provider = + (isEmbedding ? settings.embedding_provider : settings.llm_provider) || + modelProvider; + const healthData: ProviderHealthResponse = { + status: "healthy", + message: "Provider is configured and working correctly", + provider: provider, + }; + queryClient.setQueryData(["provider", "health"], healthData); + setError(null); + if (!isEmbedding) { + setCurrentStep(totalSteps); + setTimeout(() => { + onComplete(); + }, 1000); + } else { + setCurrentStep(0); + } + }, + onError: (error) => { + setError(error.message); + setCurrentStep(totalSteps); + // Reset to provider selection after 1 second + setTimeout(() => { + setCurrentStep(null); + }, 1000); + }, + }); + + const handleComplete = () => { + const currentProvider = isEmbedding + ? settings.embedding_provider + : settings.llm_provider; + + if ( + !currentProvider || + (isEmbedding && + !settings.embedding_model && + !showProviderConfiguredMessage) || + (!isEmbedding && !settings.llm_model) + ) { + toast.error("Please complete all required fields"); + return; + } + + // Clear any previous error + setError(null); + + // Prepare onboarding data with provider-specific fields + const onboardingData: OnboardingVariables = { + sample_data: sampleDataset, + }; + + // Set the provider field + if (isEmbedding) { + onboardingData.embedding_provider = currentProvider; + // If provider is already configured, use the existing embedding model from settings + // Otherwise, use the embedding model from the form + if ( + showProviderConfiguredMessage && + currentSettings?.knowledge?.embedding_model + ) { + onboardingData.embedding_model = + currentSettings.knowledge.embedding_model; + } else { + onboardingData.embedding_model = settings.embedding_model; + } + } else { + onboardingData.llm_provider = currentProvider; + onboardingData.llm_model = settings.llm_model; + } + + // Add provider-specific credentials based on the selected provider + if (currentProvider === "openai" && settings.openai_api_key) { + onboardingData.openai_api_key = settings.openai_api_key; + } else if (currentProvider === "anthropic" && settings.anthropic_api_key) { + onboardingData.anthropic_api_key = settings.anthropic_api_key; + } else if (currentProvider === "watsonx") { + if (settings.watsonx_api_key) { + onboardingData.watsonx_api_key = settings.watsonx_api_key; + } + if (settings.watsonx_endpoint) { + onboardingData.watsonx_endpoint = settings.watsonx_endpoint; + } + if (settings.watsonx_project_id) { + onboardingData.watsonx_project_id = settings.watsonx_project_id; + } + } else if (currentProvider === "ollama" && settings.ollama_endpoint) { + onboardingData.ollama_endpoint = settings.ollama_endpoint; + } + + // Record the start time when user clicks Complete + setProcessingStartTime(Date.now()); + onboardingMutation.mutate(onboardingData); + setCurrentStep(0); + }; + + const isComplete = + (isEmbedding && + (!!settings.embedding_model || showProviderConfiguredMessage)) || + (!isEmbedding && !!settings.llm_model && isDoclingHealthy); + + return ( + + {currentStep === null ? ( + +
+ + {error && ( + +
+ + + {error} + +
+
+ )} +
+
+ + + {!isEmbedding && ( + + +
+ +
+ Anthropic +
+
+ )} + + +
+ +
+ OpenAI +
+
+ + +
+ +
+ IBM watsonx.ai +
+
+ + +
+ +
+ Ollama +
+
+
+ {!isEmbedding && ( + + + + )} + + + + + + + + + +
+ + + +
+ +
+
+ {!isComplete && ( + + {isLoadingModels + ? "Loading models..." + : !!settings.llm_model && + !!settings.embedding_model && + !isDoclingHealthy + ? "docling-serve must be running to continue" + : "Please fill in all required fields"} + + )} +
+
+
+
+ ) : ( + + + + )} +
+ ); +}; + +export default OnboardingCard; diff --git a/frontend/app/onboarding/_components/onboarding-content.tsx b/frontend/app/onboarding/_components/onboarding-content.tsx new file mode 100644 index 00000000..85d6cccf --- /dev/null +++ b/frontend/app/onboarding/_components/onboarding-content.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { StickToBottom } from "use-stick-to-bottom"; +import { AssistantMessage } from "@/app/chat/_components/assistant-message"; +import Nudges from "@/app/chat/_components/nudges"; +import { UserMessage } from "@/app/chat/_components/user-message"; +import type { Message } from "@/app/chat/_types/types"; +import OnboardingCard from "@/app/onboarding/_components/onboarding-card"; +import { useChatStreaming } from "@/hooks/useChatStreaming"; + +import { OnboardingStep } from "./onboarding-step"; +import OnboardingUpload from "./onboarding-upload"; + +export function OnboardingContent({ + handleStepComplete, + currentStep, +}: { + handleStepComplete: () => void; + currentStep: number; +}) { + const [responseId, setResponseId] = useState(null); + const [selectedNudge, setSelectedNudge] = useState(""); + const [assistantMessage, setAssistantMessage] = useState( + null, + ); + + const { streamingMessage, isLoading, sendMessage } = useChatStreaming({ + onComplete: (message, newResponseId) => { + setAssistantMessage(message); + if (newResponseId) { + setResponseId(newResponseId); + } + }, + onError: (error) => { + console.error("Chat error:", error); + setAssistantMessage({ + role: "assistant", + content: + "Sorry, I couldn't connect to the chat service. Please try again.", + timestamp: new Date(), + }); + }, + }); + + const NUDGES = ["What is OpenRAG?"]; + + const handleNudgeClick = async (nudge: string) => { + setSelectedNudge(nudge); + setAssistantMessage(null); + setTimeout(async () => { + await sendMessage({ + prompt: nudge, + previousResponseId: responseId || undefined, + }); + }, 1500); + }; + + // Determine which message to show (streaming takes precedence) + const displayMessage = streamingMessage || assistantMessage; + + useEffect(() => { + if (currentStep === 2 && !isLoading && !!displayMessage) { + handleStepComplete(); + } + }, [isLoading, displayMessage, handleStepComplete, currentStep]); + + return ( + + +
+ {/* Step 1 - LLM Provider */} + = 0} + isCompleted={currentStep > 0} + showCompleted={true} + text="Let's get started by setting up your LLM provider." + > + { + handleStepComplete(); + }} + isCompleted={currentStep > 0} + /> + + + {/* Step 2 - Embedding provider and ingestion */} + = 1} + isCompleted={currentStep > 1} + showCompleted={true} + text="Now, let's set up your embedding provider." + > + { + handleStepComplete(); + }} + isCompleted={currentStep > 1} + /> + + + {/* Step 2 */} + = 2} + isCompleted={currentStep > 2 || !!selectedNudge} + text="Excellent, let's move on to learning the basics." + > +
+ +
+
+ + {/* User message - show when nudge is selected */} + {currentStep >= 2 && !!selectedNudge && ( + 3} + /> + )} + + {/* Assistant message - show streaming or final message */} + {currentStep >= 2 && + !!selectedNudge && + (displayMessage || isLoading) && ( + {}} + isStreaming={!!streamingMessage} + isCompleted={currentStep > 3} + /> + )} + + {/* Step 3 */} + = 3 && !isLoading && !!displayMessage} + isCompleted={currentStep > 3} + text="Lastly, let's add your data." + hideIcon={true} + > + + +
+
+
+ ); +} diff --git a/frontend/app/onboarding/_components/onboarding-step.tsx b/frontend/app/onboarding/_components/onboarding-step.tsx new file mode 100644 index 00000000..a93d3919 --- /dev/null +++ b/frontend/app/onboarding/_components/onboarding-step.tsx @@ -0,0 +1,127 @@ +import { AnimatePresence, motion } from "motion/react"; +import { type ReactNode, useEffect, useState } from "react"; +import { Message } from "@/app/chat/_components/message"; +import DogIcon from "@/components/icons/dog-icon"; +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { cn } from "@/lib/utils"; + +interface OnboardingStepProps { + text: string; + children?: ReactNode; + isVisible: boolean; + isCompleted?: boolean; + showCompleted?: boolean; + icon?: ReactNode; + isMarkdown?: boolean; + hideIcon?: boolean; +} + +export function OnboardingStep({ + text, + children, + isVisible, + isCompleted = false, + showCompleted = false, + icon, + isMarkdown = false, + hideIcon = false, +}: OnboardingStepProps) { + const [displayedText, setDisplayedText] = useState(""); + const [showChildren, setShowChildren] = useState(false); + + useEffect(() => { + if (!isVisible) { + setDisplayedText(""); + setShowChildren(false); + return; + } + + if (isCompleted) { + setDisplayedText(text); + setShowChildren(true); + return; + } + + let currentIndex = 0; + setDisplayedText(""); + setShowChildren(false); + + const interval = setInterval(() => { + if (currentIndex < text.length) { + setDisplayedText(text.slice(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(interval); + setShowChildren(true); + } + }, 20); // 20ms per character + + return () => clearInterval(interval); + }, [text, isVisible, isCompleted]); + + if (!isVisible) return null; + + return ( + + + ) : ( + icon || ( +
+ +
+ ) + ) + } + > +
+ {isMarkdown ? ( + + ) : ( +

+ {displayedText} + {!showChildren && !isCompleted && ( + + )} +

+ )} + {children && ( + + {((showChildren && (!isCompleted || showCompleted)) || + isMarkdown) && ( + +
{children}
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/onboarding/components/onboarding-upload.tsx b/frontend/app/onboarding/_components/onboarding-upload.tsx similarity index 97% rename from frontend/src/app/onboarding/components/onboarding-upload.tsx rename to frontend/app/onboarding/_components/onboarding-upload.tsx index 3ea2a22a..70d487bd 100644 --- a/frontend/src/app/onboarding/components/onboarding-upload.tsx +++ b/frontend/app/onboarding/_components/onboarding-upload.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "motion/react"; import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { useGetNudgesQuery } from "@/app/api/queries/useGetNudgesQuery"; 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 { uploadFile } from "@/lib/upload-utils"; diff --git a/frontend/app/onboarding/_components/openai-onboarding.tsx b/frontend/app/onboarding/_components/openai-onboarding.tsx new file mode 100644 index 00000000..b7afa6f0 --- /dev/null +++ b/frontend/app/onboarding/_components/openai-onboarding.tsx @@ -0,0 +1,168 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import OpenAILogo from "@/components/icons/openai-logo"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetOpenAIModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../_hooks/useModelSelection"; +import { useUpdateSettings } from "../_hooks/useUpdateSettings"; +import { AdvancedOnboarding } from "./advanced"; + +export function OpenAIOnboarding({ + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + isEmbedding = false, + hasEnvApiKey = false, + alreadyConfigured = false, +}: { + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + isEmbedding?: boolean; + hasEnvApiKey?: boolean; + alreadyConfigured?: boolean; +}) { + const [apiKey, setApiKey] = useState(""); + const [getFromEnv, setGetFromEnv] = useState(hasEnvApiKey); + const debouncedApiKey = useDebouncedValue(apiKey, 500); + + // Fetch models from API when API key is provided + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetOpenAIModelsQuery( + getFromEnv + ? { apiKey: "" } + : debouncedApiKey + ? { apiKey: debouncedApiKey } + : undefined, + { enabled: debouncedApiKey !== "" || getFromEnv }, + ); + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + const handleSampleDatasetChange = (dataset: boolean) => { + setSampleDataset(dataset); + }; + + const handleGetFromEnvChange = (fromEnv: boolean) => { + setGetFromEnv(fromEnv); + if (fromEnv) { + setApiKey(""); + } + setEmbeddingModel?.(""); + setLanguageModel?.(""); + }; + + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + }, [isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "openai", + { + apiKey, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + return ( + <> +
+ {!alreadyConfigured && ( + + + +
+ +
+
+ {!hasEnvApiKey && ( + + OpenAI API key not detected in the environment. + + )} +
+
+ )} + {(!getFromEnv || alreadyConfigured) && ( +
+ setApiKey(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing key from model provider selection. +

+ )} + {isLoadingModels && ( +

+ Validating API key... +

+ )} + {modelsError && ( +

+ Invalid OpenAI API key. Verify or replace the key. +

+ )} +
+ )} +
+ } + languageModels={languageModels} + embeddingModels={embeddingModels} + languageModel={languageModel} + embeddingModel={embeddingModel} + sampleDataset={sampleDataset} + setLanguageModel={setLanguageModel} + setSampleDataset={handleSampleDatasetChange} + setEmbeddingModel={setEmbeddingModel} + /> + + ); +} diff --git a/frontend/src/app/onboarding/components/progress-bar.tsx b/frontend/app/onboarding/_components/progress-bar.tsx similarity index 100% rename from frontend/src/app/onboarding/components/progress-bar.tsx rename to frontend/app/onboarding/_components/progress-bar.tsx diff --git a/frontend/app/onboarding/_components/tab-trigger.tsx b/frontend/app/onboarding/_components/tab-trigger.tsx new file mode 100644 index 00000000..c552abd6 --- /dev/null +++ b/frontend/app/onboarding/_components/tab-trigger.tsx @@ -0,0 +1,33 @@ +import AnimatedProcessingIcon from "@/components/icons/animated-processing-icon"; +import { cn } from "@/lib/utils"; + +export function TabTrigger({ + children, + selected, + isLoading, +}: { + children: React.ReactNode; + selected: boolean; + isLoading: boolean; +}) { + return ( +
+
+ +
+
+ {children} +
+
+ ); +} diff --git a/frontend/src/app/onboarding/hooks/useModelSelection.ts b/frontend/app/onboarding/_hooks/useModelSelection.ts similarity index 100% rename from frontend/src/app/onboarding/hooks/useModelSelection.ts rename to frontend/app/onboarding/_hooks/useModelSelection.ts diff --git a/frontend/src/app/onboarding/hooks/useUpdateSettings.ts b/frontend/app/onboarding/_hooks/useUpdateSettings.ts similarity index 100% rename from frontend/src/app/onboarding/hooks/useUpdateSettings.ts rename to frontend/app/onboarding/_hooks/useUpdateSettings.ts diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx similarity index 97% rename from frontend/src/app/onboarding/page.tsx rename to frontend/app/onboarding/page.tsx index 529718a1..5cbaa250 100644 --- a/frontend/src/app/onboarding/page.tsx +++ b/frontend/app/onboarding/page.tsx @@ -7,7 +7,7 @@ import { ProtectedRoute } from "@/components/protected-route"; import { DotPattern } from "@/components/ui/dot-pattern"; import { cn } from "@/lib/utils"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; -import OnboardingCard from "./components/onboarding-card"; +import OnboardingCard from "./_components/onboarding-card"; function LegacyOnboardingPage() { const router = useRouter(); diff --git a/frontend/src/app/page.tsx b/frontend/app/page.tsx similarity index 100% rename from frontend/src/app/page.tsx rename to frontend/app/page.tsx diff --git a/frontend/src/app/providers.tsx b/frontend/app/providers.tsx similarity index 100% rename from frontend/src/app/providers.tsx rename to frontend/app/providers.tsx diff --git a/frontend/app/settings/_components/anthropic-settings-dialog.tsx b/frontend/app/settings/_components/anthropic-settings-dialog.tsx new file mode 100644 index 00000000..bd54b57e --- /dev/null +++ b/frontend/app/settings/_components/anthropic-settings-dialog.tsx @@ -0,0 +1,158 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; +import { useGetAnthropicModelsQuery } from "@/app/api/queries/useGetModelsQuery"; +import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; +import AnthropicLogo from "@/components/icons/anthropic-logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AnthropicSettingsForm, + type AnthropicSettingsFormData, +} from "./anthropic-settings-form"; + +const AnthropicSettingsDialog = ({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) => { + const queryClient = useQueryClient(); + const [isValidating, setIsValidating] = useState(false); + const [validationError, setValidationError] = useState(null); + + const methods = useForm({ + mode: "onSubmit", + defaultValues: { + apiKey: "", + }, + }); + + const { handleSubmit, watch } = methods; + const apiKey = watch("apiKey"); + + const { refetch: validateCredentials } = useGetAnthropicModelsQuery( + { + apiKey: apiKey, + }, + { + enabled: false, + }, + ); + + const settingsMutation = useUpdateSettingsMutation({ + onSuccess: () => { + // Update provider health cache to healthy since backend validated the setup + const healthData: ProviderHealthResponse = { + status: "healthy", + message: "Provider is configured and working correctly", + provider: "anthropic", + }; + queryClient.setQueryData(["provider", "health"], healthData); + + toast.success( + "Anthropic credentials saved. Configure models in the Settings page.", + ); + setOpen(false); + }, + }); + + const onSubmit = async (data: AnthropicSettingsFormData) => { + // Clear any previous validation errors + setValidationError(null); + + // Only validate if a new API key was entered + if (data.apiKey) { + setIsValidating(true); + const result = await validateCredentials(); + setIsValidating(false); + + if (result.isError) { + setValidationError(result.error); + return; + } + } + + const payload: { + anthropic_api_key?: string; + } = {}; + + // Only include api_key if a value was entered + if (data.apiKey) { + payload.anthropic_api_key = data.apiKey; + } + + // Submit the update + settingsMutation.mutate(payload); + }; + + return ( + + + +
+ + +
+ +
+ Anthropic Setup +
+
+ + + + + {settingsMutation.isError && ( + +

+ {settingsMutation.error?.message} +

+
+ )} +
+ + + + + +
+
+
+ ); +}; + +export default AnthropicSettingsDialog; diff --git a/frontend/src/app/settings/components/anthropic-settings-form.tsx b/frontend/app/settings/_components/anthropic-settings-form.tsx similarity index 100% rename from frontend/src/app/settings/components/anthropic-settings-form.tsx rename to frontend/app/settings/_components/anthropic-settings-form.tsx diff --git a/frontend/app/settings/_components/model-providers.tsx b/frontend/app/settings/_components/model-providers.tsx new file mode 100644 index 00000000..9a3f0997 --- /dev/null +++ b/frontend/app/settings/_components/model-providers.tsx @@ -0,0 +1,215 @@ +import { useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useEffect, useState } from "react"; +import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; +import AnthropicLogo from "@/components/icons/anthropic-logo"; +import IBMLogo from "@/components/icons/ibm-logo"; +import OllamaLogo from "@/components/icons/ollama-logo"; +import OpenAILogo from "@/components/icons/openai-logo"; +import { useProviderHealth } from "@/components/provider-health-banner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAuth } from "@/contexts/auth-context"; +import { cn } from "@/lib/utils"; +import type { ModelProvider } from "../_helpers/model-helpers"; +import AnthropicSettingsDialog from "./anthropic-settings-dialog"; +import OllamaSettingsDialog from "./ollama-settings-dialog"; +import OpenAISettingsDialog from "./openai-settings-dialog"; +import WatsonxSettingsDialog from "./watsonx-settings-dialog"; + +export const ModelProviders = () => { + const { isAuthenticated, isNoAuthMode } = useAuth(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const { data: settings = {} } = useGetSettingsQuery({ + enabled: isAuthenticated || isNoAuthMode, + }); + + const { health } = useProviderHealth(); + + const [dialogOpen, setDialogOpen] = useState(); + + const allProviderKeys: ModelProvider[] = [ + "openai", + "ollama", + "watsonx", + "anthropic", + ]; + + // Handle URL search param to open dialogs + useEffect(() => { + const searchParam = searchParams.get("setup"); + if (searchParam && allProviderKeys.includes(searchParam as ModelProvider)) { + setDialogOpen(searchParam as ModelProvider); + } + }, [searchParams]); + + // Function to close dialog and remove search param + const handleCloseDialog = () => { + setDialogOpen(undefined); + // Remove search param from URL + const params = new URLSearchParams(searchParams.toString()); + params.delete("setup"); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + router.replace(newUrl); + }; + + const modelProvidersMap: Record< + ModelProvider, + { + name: string; + logo: (props: React.SVGProps) => ReactNode; + logoColor: string; + logoBgColor: string; + } + > = { + openai: { + name: "OpenAI", + logo: OpenAILogo, + logoColor: "text-black", + logoBgColor: "bg-white", + }, + anthropic: { + name: "Anthropic", + logo: AnthropicLogo, + logoColor: "text-[#D97757]", + logoBgColor: "bg-white", + }, + ollama: { + name: "Ollama", + logo: OllamaLogo, + logoColor: "text-black", + logoBgColor: "bg-white", + }, + watsonx: { + name: "IBM watsonx.ai", + logo: IBMLogo, + logoColor: "text-white", + logoBgColor: "bg-[#1063FE]", + }, + }; + + const currentLlmProvider = + (settings.agent?.llm_provider as ModelProvider) || "openai"; + const currentEmbeddingProvider = + (settings.knowledge?.embedding_provider as ModelProvider) || "openai"; + + // Get all provider keys with active providers first + const activeProviders = new Set([ + currentLlmProvider, + currentEmbeddingProvider, + ]); + const sortedProviderKeys = [ + ...Array.from(activeProviders), + ...allProviderKeys.filter((key) => !activeProviders.has(key)), + ]; + + return ( + <> +
+ {sortedProviderKeys.map((providerKey) => { + const { + name, + logo: Logo, + logoColor, + logoBgColor, + } = modelProvidersMap[providerKey]; + const isLlmProvider = providerKey === currentLlmProvider; + const isEmbeddingProvider = providerKey === currentEmbeddingProvider; + const isCurrentProvider = isLlmProvider || isEmbeddingProvider; + + // Check if this specific provider is unhealthy + const hasLlmError = isLlmProvider && health?.llm_error; + const hasEmbeddingError = + isEmbeddingProvider && health?.embedding_error; + const isProviderUnhealthy = hasLlmError || hasEmbeddingError; + + return ( + + +
+
+
+
+ { + + } +
+
+ + {name} + {isCurrentProvider && ( + + )} + +
+
+
+ + + +
+ ); + })} +
+ + + + + + ); +}; + +export default ModelProviders; diff --git a/frontend/src/app/settings/components/model-selectors.tsx b/frontend/app/settings/_components/model-selectors.tsx similarity index 98% rename from frontend/src/app/settings/components/model-selectors.tsx rename to frontend/app/settings/_components/model-selectors.tsx index 6e6f724f..14c8b969 100644 --- a/frontend/src/app/settings/components/model-selectors.tsx +++ b/frontend/app/settings/_components/model-selectors.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useEffect } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { ModelOption } from "@/app/api/queries/useGetModelsQuery"; -import { ModelSelector } from "@/app/onboarding/components/model-selector"; +import { ModelSelector } from "@/app/onboarding/_components/model-selector"; import { LabelWrapper } from "@/components/label-wrapper"; interface ModelSelectorsProps { diff --git a/frontend/app/settings/_components/ollama-settings-dialog.tsx b/frontend/app/settings/_components/ollama-settings-dialog.tsx new file mode 100644 index 00000000..37ebab86 --- /dev/null +++ b/frontend/app/settings/_components/ollama-settings-dialog.tsx @@ -0,0 +1,159 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; +import { useGetOllamaModelsQuery } from "@/app/api/queries/useGetModelsQuery"; +import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; +import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; +import OllamaLogo from "@/components/icons/ollama-logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useAuth } from "@/contexts/auth-context"; +import { + OllamaSettingsForm, + type OllamaSettingsFormData, +} from "./ollama-settings-form"; + +const OllamaSettingsDialog = ({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) => { + const { isAuthenticated, isNoAuthMode } = useAuth(); + const queryClient = useQueryClient(); + const [isValidating, setIsValidating] = useState(false); + const [validationError, setValidationError] = useState(null); + + const { data: settings = {} } = useGetSettingsQuery({ + enabled: isAuthenticated || isNoAuthMode, + }); + + const isOllamaConfigured = settings.providers?.ollama?.configured === true; + + const methods = useForm({ + mode: "onSubmit", + defaultValues: { + endpoint: isOllamaConfigured + ? settings.providers?.ollama?.endpoint + : "http://localhost:11434", + }, + }); + + const { handleSubmit, watch } = methods; + const endpoint = watch("endpoint"); + + const { refetch: validateCredentials } = useGetOllamaModelsQuery( + { + endpoint: endpoint, + }, + { + enabled: false, + }, + ); + + const settingsMutation = useUpdateSettingsMutation({ + onSuccess: () => { + // Update provider health cache to healthy since backend validated the setup + const healthData: ProviderHealthResponse = { + status: "healthy", + message: "Provider is configured and working correctly", + provider: "ollama", + }; + queryClient.setQueryData(["provider", "health"], healthData); + + toast.success( + "Ollama endpoint saved. Configure models in the Settings page.", + ); + setOpen(false); + }, + }); + + const onSubmit = async (data: OllamaSettingsFormData) => { + // Clear any previous validation errors + setValidationError(null); + + // Validate endpoint by fetching models + setIsValidating(true); + const result = await validateCredentials(); + setIsValidating(false); + + if (result.isError) { + setValidationError(result.error); + return; + } + + settingsMutation.mutate({ + ollama_endpoint: data.endpoint, + }); + }; + + return ( + + + +
+ + +
+ +
+ Ollama Setup +
+
+ + + + + {settingsMutation.isError && ( + +

+ {settingsMutation.error?.message} +

+
+ )} +
+ + + + + +
+
+
+ ); +}; + +export default OllamaSettingsDialog; diff --git a/frontend/src/app/settings/components/ollama-settings-form.tsx b/frontend/app/settings/_components/ollama-settings-form.tsx similarity index 100% rename from frontend/src/app/settings/components/ollama-settings-form.tsx rename to frontend/app/settings/_components/ollama-settings-form.tsx diff --git a/frontend/app/settings/_components/openai-settings-dialog.tsx b/frontend/app/settings/_components/openai-settings-dialog.tsx new file mode 100644 index 00000000..7ae86d1d --- /dev/null +++ b/frontend/app/settings/_components/openai-settings-dialog.tsx @@ -0,0 +1,158 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; +import { useGetOpenAIModelsQuery } from "@/app/api/queries/useGetModelsQuery"; +import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; +import OpenAILogo from "@/components/icons/openai-logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + OpenAISettingsForm, + type OpenAISettingsFormData, +} from "./openai-settings-form"; + +const OpenAISettingsDialog = ({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) => { + const queryClient = useQueryClient(); + const [isValidating, setIsValidating] = useState(false); + const [validationError, setValidationError] = useState(null); + + const methods = useForm({ + mode: "onSubmit", + defaultValues: { + apiKey: "", + }, + }); + + const { handleSubmit, watch } = methods; + const apiKey = watch("apiKey"); + + const { refetch: validateCredentials } = useGetOpenAIModelsQuery( + { + apiKey: apiKey, + }, + { + enabled: false, + }, + ); + + const settingsMutation = useUpdateSettingsMutation({ + onSuccess: () => { + // Update provider health cache to healthy since backend validated the setup + const healthData: ProviderHealthResponse = { + status: "healthy", + message: "Provider is configured and working correctly", + provider: "openai", + }; + queryClient.setQueryData(["provider", "health"], healthData); + + toast.success( + "OpenAI credentials saved. Configure models in the Settings page.", + ); + setOpen(false); + }, + }); + + const onSubmit = async (data: OpenAISettingsFormData) => { + // Clear any previous validation errors + setValidationError(null); + + // Only validate if a new API key was entered + if (data.apiKey) { + setIsValidating(true); + const result = await validateCredentials(); + setIsValidating(false); + + if (result.isError) { + setValidationError(result.error); + return; + } + } + + const payload: { + openai_api_key?: string; + } = {}; + + // Only include api_key if a value was entered + if (data.apiKey) { + payload.openai_api_key = data.apiKey; + } + + // Submit the update + settingsMutation.mutate(payload); + }; + + return ( + + + +
+ + +
+ +
+ OpenAI Setup +
+
+ + + + + {settingsMutation.isError && ( + +

+ {settingsMutation.error?.message} +

+
+ )} +
+ + + + + +
+
+
+ ); +}; + +export default OpenAISettingsDialog; diff --git a/frontend/src/app/settings/components/openai-settings-form.tsx b/frontend/app/settings/_components/openai-settings-form.tsx similarity index 100% rename from frontend/src/app/settings/components/openai-settings-form.tsx rename to frontend/app/settings/_components/openai-settings-form.tsx diff --git a/frontend/app/settings/_components/watsonx-settings-dialog.tsx b/frontend/app/settings/_components/watsonx-settings-dialog.tsx new file mode 100644 index 00000000..b930aba9 --- /dev/null +++ b/frontend/app/settings/_components/watsonx-settings-dialog.tsx @@ -0,0 +1,166 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; +import { useGetIBMModelsQuery } from "@/app/api/queries/useGetModelsQuery"; +import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; +import IBMLogo from "@/components/icons/ibm-logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + WatsonxSettingsForm, + type WatsonxSettingsFormData, +} from "./watsonx-settings-form"; + +const WatsonxSettingsDialog = ({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) => { + const queryClient = useQueryClient(); + const [isValidating, setIsValidating] = useState(false); + const [validationError, setValidationError] = useState(null); + + const methods = useForm({ + mode: "onSubmit", + defaultValues: { + endpoint: "https://us-south.ml.cloud.ibm.com", + apiKey: "", + projectId: "", + }, + }); + + const { handleSubmit, watch } = methods; + const endpoint = watch("endpoint"); + const apiKey = watch("apiKey"); + const projectId = watch("projectId"); + + const { refetch: validateCredentials } = useGetIBMModelsQuery( + { + endpoint: endpoint, + apiKey: apiKey, + projectId: projectId, + }, + { + enabled: false, + }, + ); + + const settingsMutation = useUpdateSettingsMutation({ + onSuccess: () => { + // Update provider health cache to healthy since backend validated the setup + const healthData: ProviderHealthResponse = { + status: "healthy", + message: "Provider is configured and working correctly", + provider: "watsonx", + }; + queryClient.setQueryData(["provider", "health"], healthData); + toast.success( + "watsonx credentials saved. Configure models in the Settings page.", + ); + setOpen(false); + }, + }); + + const onSubmit = async (data: WatsonxSettingsFormData) => { + // Clear any previous validation errors + setValidationError(null); + + // Validate credentials by fetching models + setIsValidating(true); + const result = await validateCredentials(); + setIsValidating(false); + + if (result.isError) { + setValidationError(result.error); + return; + } + + const payload: { + watsonx_endpoint: string; + watsonx_api_key?: string; + watsonx_project_id: string; + } = { + watsonx_endpoint: data.endpoint, + watsonx_project_id: data.projectId, + }; + + // Only include api_key if a value was entered + if (data.apiKey) { + payload.watsonx_api_key = data.apiKey; + } + + // Submit the update + settingsMutation.mutate(payload); + }; + + return ( + + + +
+ + +
+ +
+ IBM watsonx.ai Setup +
+
+ + + + + {settingsMutation.isError && ( + +

+ {settingsMutation.error?.message} +

+
+ )} +
+ + + + + +
+
+
+ ); +}; + +export default WatsonxSettingsDialog; diff --git a/frontend/src/app/settings/components/watsonx-settings-form.tsx b/frontend/app/settings/_components/watsonx-settings-form.tsx similarity index 98% rename from frontend/src/app/settings/components/watsonx-settings-form.tsx rename to frontend/app/settings/_components/watsonx-settings-form.tsx index 27d4a371..c994413b 100644 --- a/frontend/src/app/settings/components/watsonx-settings-form.tsx +++ b/frontend/app/settings/_components/watsonx-settings-form.tsx @@ -1,7 +1,7 @@ import { useFormContext, Controller } from "react-hook-form"; import { LabelWrapper } from "@/components/label-wrapper"; import { Input } from "@/components/ui/input"; -import { ModelSelector } from "@/app/onboarding/components/model-selector"; +import { ModelSelector } from "@/app/onboarding/_components/model-selector"; export interface WatsonxSettingsFormData { endpoint: string; diff --git a/frontend/app/settings/_helpers/model-helpers.tsx b/frontend/app/settings/_helpers/model-helpers.tsx new file mode 100644 index 00000000..d35f5b30 --- /dev/null +++ b/frontend/app/settings/_helpers/model-helpers.tsx @@ -0,0 +1,108 @@ +import AnthropicLogo from "@/components/icons/anthropic-logo"; +import IBMLogo from "@/components/icons/ibm-logo"; +import OllamaLogo from "@/components/icons/ollama-logo"; +import OpenAILogo from "@/components/icons/openai-logo"; + +export type ModelProvider = "openai" | "anthropic" | "ollama" | "watsonx"; + +export interface ModelOption { + value: string; + label: string; +} + +// Helper function to get model logo based on provider or model name +export function getModelLogo(modelValue: string, provider?: ModelProvider) { + // First check by provider + if (provider === "openai") { + return ; + } else if (provider === "anthropic") { + return ; + } else if (provider === "ollama") { + return ; + } else if (provider === "watsonx") { + return ; + } + + // Fallback to model name analysis + if (modelValue.includes("gpt") || modelValue.includes("text-embedding")) { + return ; + } else if (modelValue.includes("llama") || modelValue.includes("ollama")) { + return ; + } else if ( + modelValue.includes("granite") || + modelValue.includes("slate") || + modelValue.includes("ibm") + ) { + return ; + } + + return ; // Default to OpenAI logo +} + +// Helper function to get fallback models by provider +export function getFallbackModels(provider: ModelProvider) { + switch (provider) { + case "openai": + return { + language: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-4-turbo", label: "GPT-4 Turbo" }, + { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" }, + ], + embedding: [ + { value: "text-embedding-ada-002", label: "text-embedding-ada-002" }, + { value: "text-embedding-3-small", label: "text-embedding-3-small" }, + { value: "text-embedding-3-large", label: "text-embedding-3-large" }, + ], + }; + case "anthropic": + return { + language: [ + { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" }, + { value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" }, + { value: "claude-opus-4-20250514", label: "Claude Opus 4" }, + ], + }; + case "ollama": + return { + language: [ + { value: "llama2", label: "Llama 2" }, + { value: "llama2:13b", label: "Llama 2 13B" }, + { value: "codellama", label: "Code Llama" }, + ], + embedding: [ + { value: "mxbai-embed-large", label: "MxBai Embed Large" }, + { value: "nomic-embed-text", label: "Nomic Embed Text" }, + ], + }; + case "watsonx": + return { + language: [ + { + value: "meta-llama/llama-3-1-70b-instruct", + label: "Llama 3.1 70B Instruct", + }, + { value: "ibm/granite-13b-chat-v2", label: "Granite 13B Chat v2" }, + ], + embedding: [ + { + value: "ibm/slate-125m-english-rtrvr", + label: "Slate 125M English Retriever", + }, + ], + }; + default: + return { + language: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-4-turbo", label: "GPT-4 Turbo" }, + { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" }, + ], + embedding: [ + { value: "text-embedding-ada-002", label: "text-embedding-ada-002" }, + { value: "text-embedding-3-small", label: "text-embedding-3-small" }, + { value: "text-embedding-3-large", label: "text-embedding-3-large" }, + ], + }; + } +} diff --git a/frontend/src/app/settings/helpers/model-select-item.tsx b/frontend/app/settings/_helpers/model-select-item.tsx similarity index 100% rename from frontend/src/app/settings/helpers/model-select-item.tsx rename to frontend/app/settings/_helpers/model-select-item.tsx diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx new file mode 100644 index 00000000..818a6aa6 --- /dev/null +++ b/frontend/app/settings/page.tsx @@ -0,0 +1,1309 @@ +"use client"; + +import { ArrowUpRight, Loader2, Minus, PlugZap, Plus } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + useGetAnthropicModelsQuery, + useGetIBMModelsQuery, + useGetOllamaModelsQuery, + useGetOpenAIModelsQuery, +} from "@/app/api/queries/useGetModelsQuery"; +import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; +import { ConfirmationDialog } from "@/components/confirmation-dialog"; +import { LabelWrapper } from "@/components/label-wrapper"; +import { ProtectedRoute } from "@/components/protected-route"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/contexts/auth-context"; +import { useTask } from "@/contexts/task-context"; +import { + DEFAULT_AGENT_SETTINGS, + DEFAULT_KNOWLEDGE_SETTINGS, + UI_CONSTANTS, +} from "@/lib/constants"; +import { useDebounce } from "@/lib/debounce"; +import GoogleDriveIcon from "../../components/icons/google-drive-logo"; +import OneDriveIcon from "../../components/icons/one-drive-logo"; +import SharePointIcon from "../../components/icons/share-point-logo"; +import { useUpdateSettingsMutation } from "../api/mutations/useUpdateSettingsMutation"; +import { ModelSelector } from "../onboarding/_components/model-selector"; +import ModelProviders from "./_components/model-providers"; +import { getModelLogo, type ModelProvider } from "./_helpers/model-helpers"; + +const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS; + +interface GoogleDriveFile { + id: string; + name: string; + mimeType: string; + webViewLink?: string; + iconLink?: string; +} + +interface OneDriveFile { + id: string; + name: string; + mimeType?: string; + webUrl?: string; + driveItem?: { + file?: { mimeType: string }; + folder?: unknown; + }; +} + +interface Connector { + id: string; + name: string; + description: string; + icon: React.ReactNode; + status: "not_connected" | "connecting" | "connected" | "error"; + type: string; + connectionId?: string; + access_token?: string; + selectedFiles?: GoogleDriveFile[] | OneDriveFile[]; + available?: boolean; +} + +interface SyncResult { + processed?: number; + added?: number; + errors?: number; + skipped?: number; + total?: number; +} + +interface Connection { + connection_id: string; + is_active: boolean; + created_at: string; + last_sync?: string; +} +function KnowledgeSourcesPage() { + const { isAuthenticated, isNoAuthMode } = useAuth(); + const { addTask, tasks } = useTask(); + const searchParams = useSearchParams(); + const router = useRouter(); + + // Connectors state + const [connectors, setConnectors] = useState([]); + const [isConnecting, setIsConnecting] = useState(null); + const [isSyncing, setIsSyncing] = useState(null); + const [syncResults, setSyncResults] = useState<{ + [key: string]: SyncResult | null; + }>({}); + const [maxFiles, setMaxFiles] = useState(10); + const [syncAllFiles, setSyncAllFiles] = useState(false); + + // Only keep systemPrompt state since it needs manual save button + const [systemPrompt, setSystemPrompt] = useState(""); + const [chunkSize, setChunkSize] = useState(1024); + const [chunkOverlap, setChunkOverlap] = useState(50); + const [tableStructure, setTableStructure] = useState(true); + const [ocr, setOcr] = useState(false); + const [pictureDescriptions, setPictureDescriptions] = + useState(false); + + // Fetch settings using React Query + const { data: settings = {} } = useGetSettingsQuery({ + enabled: isAuthenticated || isNoAuthMode, + }); + + // Fetch models for each provider + const { data: openaiModels, isLoading: openaiLoading } = + useGetOpenAIModelsQuery( + { apiKey: "" }, + { enabled: settings?.providers?.openai?.configured === true }, + ); + + const { data: anthropicModels, isLoading: anthropicLoading } = + useGetAnthropicModelsQuery( + { apiKey: "" }, + { enabled: settings?.providers?.anthropic?.configured === true }, + ); + + const { data: ollamaModels, isLoading: ollamaLoading } = + useGetOllamaModelsQuery( + { endpoint: settings?.providers?.ollama?.endpoint }, + { + enabled: + settings?.providers?.ollama?.configured === true && + !!settings?.providers?.ollama?.endpoint, + }, + ); + + const { data: watsonxModels, isLoading: watsonxLoading } = + useGetIBMModelsQuery( + { + endpoint: settings?.providers?.watsonx?.endpoint, + apiKey: "", + projectId: settings?.providers?.watsonx?.project_id, + }, + { + enabled: + settings?.providers?.watsonx?.configured === true && + !!settings?.providers?.watsonx?.endpoint && + !!settings?.providers?.watsonx?.project_id, + }, + ); + + // Build grouped LLM model options from all configured providers + const groupedLlmModels = [ + { + group: "OpenAI", + provider: "openai", + icon: getModelLogo("", "openai"), + models: openaiModels?.language_models || [], + configured: settings.providers?.openai?.configured === true, + }, + { + group: "Anthropic", + provider: "anthropic", + icon: getModelLogo("", "anthropic"), + models: anthropicModels?.language_models || [], + configured: settings.providers?.anthropic?.configured === true, + }, + { + group: "Ollama", + provider: "ollama", + icon: getModelLogo("", "ollama"), + models: ollamaModels?.language_models || [], + configured: settings.providers?.ollama?.configured === true, + }, + { + group: "IBM watsonx.ai", + provider: "watsonx", + icon: getModelLogo("", "watsonx"), + models: watsonxModels?.language_models || [], + configured: settings.providers?.watsonx?.configured === true, + }, + ] + .filter((provider) => provider.configured) + .map((provider) => ({ + group: provider.group, + icon: provider.icon, + options: provider.models.map((model) => ({ + ...model, + provider: provider.provider, + })), + })) + .filter((provider) => provider.options.length > 0); + + // Build grouped embedding model options from all configured providers (excluding Anthropic) + const groupedEmbeddingModels = [ + { + group: "OpenAI", + provider: "openai", + icon: getModelLogo("", "openai"), + models: openaiModels?.embedding_models || [], + configured: settings.providers?.openai?.configured === true, + }, + { + group: "Ollama", + provider: "ollama", + icon: getModelLogo("", "ollama"), + models: ollamaModels?.embedding_models || [], + configured: settings.providers?.ollama?.configured === true, + }, + { + group: "IBM watsonx.ai", + provider: "watsonx", + icon: getModelLogo("", "watsonx"), + models: watsonxModels?.embedding_models || [], + configured: settings.providers?.watsonx?.configured === true, + }, + ] + .filter((provider) => provider.configured) + .map((provider) => ({ + group: provider.group, + icon: provider.icon, + options: provider.models.map((model) => ({ + ...model, + provider: provider.provider, + })), + })) + .filter((provider) => provider.options.length > 0); + + const isLoadingAnyLlmModels = + openaiLoading || anthropicLoading || ollamaLoading || watsonxLoading; + const isLoadingAnyEmbeddingModels = + openaiLoading || ollamaLoading || watsonxLoading; + + // Mutations + const updateSettingsMutation = useUpdateSettingsMutation({ + onSuccess: () => { + toast.success("Settings updated successfully"); + }, + onError: (error) => { + toast.error("Failed to update settings", { + description: error.message, + }); + }, + }); + + // Debounced update function + const debouncedUpdate = useDebounce( + (variables: Parameters[0]) => { + updateSettingsMutation.mutate(variables); + }, + 500, + ); + + // Sync system prompt state with settings data + useEffect(() => { + if (settings.agent?.system_prompt) { + setSystemPrompt(settings.agent.system_prompt); + } + }, [settings.agent?.system_prompt]); + + // Sync chunk size and overlap state with settings data + useEffect(() => { + if (settings.knowledge?.chunk_size) { + setChunkSize(settings.knowledge.chunk_size); + } + }, [settings.knowledge?.chunk_size]); + + useEffect(() => { + if (settings.knowledge?.chunk_overlap) { + setChunkOverlap(settings.knowledge.chunk_overlap); + } + }, [settings.knowledge?.chunk_overlap]); + + // Sync docling settings with settings data + useEffect(() => { + if (settings.knowledge?.table_structure !== undefined) { + setTableStructure(settings.knowledge.table_structure); + } + }, [settings.knowledge?.table_structure]); + + useEffect(() => { + if (settings.knowledge?.ocr !== undefined) { + setOcr(settings.knowledge.ocr); + } + }, [settings.knowledge?.ocr]); + + useEffect(() => { + if (settings.knowledge?.picture_descriptions !== undefined) { + setPictureDescriptions(settings.knowledge.picture_descriptions); + } + }, [settings.knowledge?.picture_descriptions]); + + // Update model selection immediately (also updates provider) + const handleModelChange = (newModel: string, provider?: string) => { + if (newModel && provider) { + updateSettingsMutation.mutate({ + llm_model: newModel, + llm_provider: provider, + }); + } else if (newModel) { + updateSettingsMutation.mutate({ llm_model: newModel }); + } + }; + + // Update system prompt with save button + const handleSystemPromptSave = () => { + updateSettingsMutation.mutate({ system_prompt: systemPrompt }); + }; + + // Update embedding model selection immediately (also updates provider) + const handleEmbeddingModelChange = (newModel: string, provider?: string) => { + if (newModel && provider) { + updateSettingsMutation.mutate({ + embedding_model: newModel, + embedding_provider: provider, + }); + } else if (newModel) { + updateSettingsMutation.mutate({ embedding_model: newModel }); + } + }; + + // Update chunk size setting with debounce + const handleChunkSizeChange = (value: string) => { + const numValue = Math.max(0, parseInt(value) || 0); + setChunkSize(numValue); + debouncedUpdate({ chunk_size: numValue }); + }; + + // Update chunk overlap setting with debounce + const handleChunkOverlapChange = (value: string) => { + const numValue = Math.max(0, parseInt(value) || 0); + setChunkOverlap(numValue); + debouncedUpdate({ chunk_overlap: numValue }); + }; + + // Update docling settings + const handleTableStructureChange = (checked: boolean) => { + setTableStructure(checked); + updateSettingsMutation.mutate({ table_structure: checked }); + }; + + const handleOcrChange = (checked: boolean) => { + setOcr(checked); + updateSettingsMutation.mutate({ ocr: checked }); + }; + + const handlePictureDescriptionsChange = (checked: boolean) => { + setPictureDescriptions(checked); + updateSettingsMutation.mutate({ picture_descriptions: checked }); + }; + + // Helper function to get connector icon + const getConnectorIcon = useCallback((iconName: string) => { + const iconMap: { [key: string]: React.ReactElement } = { + "google-drive": , + sharepoint: , + onedrive: , + }; + return ( + iconMap[iconName] || ( +
+ ? +
+ ) + ); + }, []); + + // Connector functions + const checkConnectorStatuses = useCallback(async () => { + try { + // Fetch available connectors from backend + const connectorsResponse = await fetch("/api/connectors"); + if (!connectorsResponse.ok) { + throw new Error("Failed to load connectors"); + } + + const connectorsResult = await connectorsResponse.json(); + const connectorTypes = Object.keys(connectorsResult.connectors); + + // Initialize connectors list with metadata from backend + const initialConnectors = connectorTypes + .filter((type) => connectorsResult.connectors[type].available) // Only show available connectors + .map((type) => ({ + id: type, + name: connectorsResult.connectors[type].name, + description: connectorsResult.connectors[type].description, + icon: getConnectorIcon(connectorsResult.connectors[type].icon), + status: "not_connected" as const, + type: type, + available: connectorsResult.connectors[type].available, + })); + + setConnectors(initialConnectors); + + // Check status for each connector type + + for (const connectorType of connectorTypes) { + const response = await fetch(`/api/connectors/${connectorType}/status`); + if (response.ok) { + const data = await response.json(); + const connections = data.connections || []; + const activeConnection = connections.find( + (conn: Connection) => conn.is_active, + ); + const isConnected = activeConnection !== undefined; + + setConnectors((prev) => + prev.map((c) => + c.type === connectorType + ? { + ...c, + status: isConnected ? "connected" : "not_connected", + connectionId: activeConnection?.connection_id, + } + : c, + ), + ); + } + } + } catch (error) { + console.error("Failed to check connector statuses:", error); + } + }, [getConnectorIcon]); + + const handleConnect = async (connector: Connector) => { + setIsConnecting(connector.id); + setSyncResults((prev) => ({ ...prev, [connector.id]: null })); + + try { + // Use the shared auth callback URL, same as connectors page + const redirectUri = `${window.location.origin}/auth/callback`; + + const response = await fetch("/api/auth/init", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connector_type: connector.type, + purpose: "data_source", + name: `${connector.name} Connection`, + redirect_uri: redirectUri, + }), + }); + + if (response.ok) { + const result = await response.json(); + + if (result.oauth_config) { + localStorage.setItem("connecting_connector_id", result.connection_id); + localStorage.setItem("connecting_connector_type", connector.type); + + const authUrl = + `${result.oauth_config.authorization_endpoint}?` + + `client_id=${result.oauth_config.client_id}&` + + `response_type=code&` + + `scope=${result.oauth_config.scopes.join(" ")}&` + + `redirect_uri=${encodeURIComponent( + result.oauth_config.redirect_uri, + )}&` + + `access_type=offline&` + + `prompt=consent&` + + `state=${result.connection_id}`; + + window.location.href = authUrl; + } + } else { + console.error("Failed to initiate connection"); + setIsConnecting(null); + } + } catch (error) { + console.error("Connection error:", error); + setIsConnecting(null); + } + }; + + // const handleSync = async (connector: Connector) => { + // if (!connector.connectionId) return; + + // setIsSyncing(connector.id); + // setSyncResults(prev => ({ ...prev, [connector.id]: null })); + + // try { + // const syncBody: { + // connection_id: string; + // max_files?: number; + // selected_files?: string[]; + // } = { + // connection_id: connector.connectionId, + // max_files: syncAllFiles ? 0 : maxFiles || undefined, + // }; + + // // Note: File selection is now handled via the cloud connectors dialog + + // const response = await fetch(`/api/connectors/${connector.type}/sync`, { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify(syncBody), + // }); + + // const result = await response.json(); + + // if (response.status === 201) { + // const taskId = result.task_id; + // if (taskId) { + // addTask(taskId); + // setSyncResults(prev => ({ + // ...prev, + // [connector.id]: { + // processed: 0, + // total: result.total_files || 0, + // }, + // })); + // } + // } else if (response.ok) { + // setSyncResults(prev => ({ ...prev, [connector.id]: result })); + // // Note: Stats will auto-refresh via task completion watcher for async syncs + // } else { + // console.error("Sync failed:", result.error); + // } + // } catch (error) { + // console.error("Sync error:", error); + // } finally { + // setIsSyncing(null); + // } + // }; + + const getStatusBadge = (status: Connector["status"]) => { + switch (status) { + case "connected": + return ( +
+ ); + case "connecting": + return ( +
+ ); + case "error": + return ( +
+ ); + default: + return
; + } + }; + + const navigateToKnowledgePage = (connector: Connector) => { + const provider = connector.type.replace(/-/g, "_"); + router.push(`/upload/${provider}`); + }; + + // Check connector status on mount and when returning from OAuth + useEffect(() => { + if (isAuthenticated) { + checkConnectorStatuses(); + } + + if (searchParams.get("oauth_success") === "true") { + const url = new URL(window.location.href); + url.searchParams.delete("oauth_success"); + window.history.replaceState({}, "", url.toString()); + } + }, [searchParams, isAuthenticated, checkConnectorStatuses]); + + // Track previous tasks to detect new completions + const [prevTasks, setPrevTasks] = useState([]); + + // Watch for task completions and refresh stats + useEffect(() => { + // Find newly completed tasks by comparing with previous state + const newlyCompletedTasks = tasks.filter((task) => { + const wasCompleted = + prevTasks.find((prev) => prev.task_id === task.task_id)?.status === + "completed"; + return task.status === "completed" && !wasCompleted; + }); + + if (newlyCompletedTasks.length > 0) { + // Task completed - could refresh data here if needed + const timeoutId = setTimeout(() => { + // Stats refresh removed + }, 1000); + + // Update previous tasks state + setPrevTasks(tasks); + + return () => clearTimeout(timeoutId); + } else { + // Always update previous tasks state + setPrevTasks(tasks); + } + }, [tasks, prevTasks]); + + const handleEditInLangflow = ( + flowType: "chat" | "ingest", + closeDialog: () => void, + ) => { + // Select the appropriate flow ID and edit URL based on flow type + const targetFlowId = + flowType === "ingest" ? settings.ingest_flow_id : settings.flow_id; + const editUrl = + flowType === "ingest" + ? settings.langflow_ingest_edit_url + : settings.langflow_edit_url; + + const derivedFromWindow = + typeof window !== "undefined" + ? `${window.location.protocol}//${window.location.hostname}:7860` + : ""; + const base = ( + settings.langflow_public_url || + derivedFromWindow || + "http://localhost:7860" + ).replace(/\/$/, ""); + const computed = targetFlowId ? `${base}/flow/${targetFlowId}` : base; + + const url = editUrl || computed; + + window.open(url, "_blank"); + closeDialog(); // Close immediately after opening Langflow + }; + + const handleRestoreRetrievalFlow = (closeDialog: () => void) => { + fetch(`/api/reset-flow/retrieval`, { + method: "POST", + }) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }) + .then(() => { + // Only reset form values if the API call was successful + setSystemPrompt(DEFAULT_AGENT_SETTINGS.system_prompt); + // Trigger model update to default model + handleModelChange(DEFAULT_AGENT_SETTINGS.llm_model); + closeDialog(); // Close after successful completion + }) + .catch((error) => { + console.error("Error restoring retrieval flow:", error); + closeDialog(); // Close even on error (could show error toast instead) + }); + }; + + const handleRestoreIngestFlow = (closeDialog: () => void) => { + fetch(`/api/reset-flow/ingest`, { + method: "POST", + }) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }) + .then(() => { + // Only reset form values if the API call was successful + setChunkSize(DEFAULT_KNOWLEDGE_SETTINGS.chunk_size); + setChunkOverlap(DEFAULT_KNOWLEDGE_SETTINGS.chunk_overlap); + setTableStructure(false); + setOcr(false); + setPictureDescriptions(false); + closeDialog(); // Close after successful completion + }) + .catch((error) => { + console.error("Error restoring ingest flow:", error); + closeDialog(); // Close even on error (could show error toast instead) + }); + }; + + return ( +
+ {/* Connectors Section */} +
+
+

+ Cloud Connectors +

+
+ + {/* Conditional Sync Settings or No-Auth Message */} + { + isNoAuthMode ? ( + + + + Cloud connectors require authentication + + + Add the Google OAuth variables below to your .env{" "} + then restart the OpenRAG containers. + + + +
+
+
+ + 27 + + # Google OAuth +
+
+ + 28 + + # Create credentials here: +
+
+ + 29 + + + # https://console.cloud.google.com/apis/credentials + +
+
+
+ 30 + GOOGLE_OAUTH_CLIENT_ID= +
+
+ 31 + GOOGLE_OAUTH_CLIENT_SECRET= +
+
+
+
+ ) : null + //
+ //
+ //

Sync Settings

+ //

+ // Configure how many files to sync when manually triggering a sync + //

+ //
+ //
+ //
+ // { + // setSyncAllFiles(!!checked); + // if (checked) { + // setMaxFiles(0); + // } else { + // setMaxFiles(10); + // } + // }} + // /> + // + //
+ // + //
+ // setMaxFiles(parseInt(e.target.value) || 10)} + // disabled={syncAllFiles} + // className="w-16 min-w-16 max-w-16 flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed" + // min="1" + // max="100" + // title={ + // syncAllFiles + // ? "Disabled when 'Sync all files' is checked" + // : "Leave blank or set to 0 for unlimited" + // } + // /> + //
+ //
+ //
+ } + {/* Connectors Grid */} +
+ {connectors.map((connector) => { + return ( + + +
+
+
+
+ {connector.icon} +
+
+ + {connector.name} + {connector && getStatusBadge(connector.status)} + + + {connector?.description + ? `${connector.name} is configured.` + : connector.description} + +
+
+
+ + {connector?.available ? ( +
+ {connector?.status === "connected" ? ( + <> + + {syncResults[connector.id] && ( +
+
+ Processed:{" "} + {syncResults[connector.id]?.processed || 0} +
+
+ Added: {syncResults[connector.id]?.added || 0} +
+ {syncResults[connector.id]?.errors && ( +
+ Errors: {syncResults[connector.id]?.errors} +
+ )} +
+ )} + + ) : ( + + )} +
+ ) : ( +
+

+ See our{" "} + + Cloud Connectors installation guide + {" "} + for more detail. +

+
+ )} +
+
+ ); + })} +
+
+ + {/* Model Providers Section */} +
+
+

+ Model Providers +

+
+ +
+ + {/* Agent Behavior Section */} + + +
+ Agent +
+ + Restore flow + + } + title="Restore default Agent flow" + description="This restores defaults and discards all custom settings and overrides. This can’t be undone." + confirmText="Restore" + variant="destructive" + onConfirm={handleRestoreRetrievalFlow} + /> + + + Langflow icon + + + + + Edit in Langflow + + } + title="Edit Agent flow in Langflow" + description={ + <> +

+ You're entering Langflow. You can edit the{" "} + Agent flow and other underlying flows. Manual + changes to components, wiring, or I/O can break this + experience. +

+

You can restore this flow from Settings.

+ + } + confirmText="Proceed" + confirmIcon={} + onConfirm={(closeDialog) => + handleEditInLangflow("chat", closeDialog) + } + variant="warning" + /> +
+
+ + This Agent retrieves from your knowledge and generates chat + responses. Edit in Langflow for full control. + +
+ +
+
+ + + +
+
+ +