diff --git a/frontend/components/docling-health-banner.tsx b/frontend/components/docling-health-banner.tsx new file mode 100644 index 00000000..c65a93cc --- /dev/null +++ b/frontend/components/docling-health-banner.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { AlertTriangle, ExternalLink, Copy } from "lucide-react"; +import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; +import { Banner, BannerIcon, BannerTitle, BannerAction } from "@/components/ui/banner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; + +interface DoclingHealthBannerProps { + className?: string; +} + +// DoclingSetupDialog component +interface DoclingSetupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + className?: string; +} + +function DoclingSetupDialog({ + open, + onOpenChange, + className +}: DoclingSetupDialogProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText("uv run openrag"); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + + + + docling-serve is stopped. Knowledge ingest is unavailable. + + + Start docling-serve by running: + + + +
+
+ + uv run openrag + + +
+ + + Then, select Start Native Services in the TUI. Once docling-serve is running, refresh OpenRAG. + +
+ + + + +
+
+ ); +} + +export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) { + const { data: health, isLoading, isError } = useDoclingHealthQuery(); + const [showDialog, setShowDialog] = useState(false); + + const isHealthy = health?.status === "healthy" && !isError; + const isUnhealthy = health?.status === "unhealthy" || isError; + + // Only show banner when service is unhealthy + if (isLoading || isHealthy) { + return null; + } + + if (isUnhealthy) { + return ( + <> + + + + docling-serve native service is stopped. Knowledge ingest is unavailable. + + setShowDialog(true)} + className="bg-foreground text-background hover:bg-primary/90" + > + Setup Docling Serve + + + + + + + ); + } + + return null; +} \ No newline at end of file diff --git a/frontend/components/ui/banner.tsx b/frontend/components/ui/banner.tsx new file mode 100644 index 00000000..3a1ea9f5 --- /dev/null +++ b/frontend/components/ui/banner.tsx @@ -0,0 +1,141 @@ +'use client'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import { type LucideIcon, XIcon } from 'lucide-react'; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + type MouseEventHandler, + useContext, +} from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type BannerContextProps = { + show: boolean; + setShow: (show: boolean) => void; +}; + +export const BannerContext = createContext({ + show: true, + setShow: () => {}, +}); + +export type BannerProps = HTMLAttributes & { + visible?: boolean; + defaultVisible?: boolean; + onClose?: () => void; + inset?: boolean; +}; + +export const Banner = ({ + children, + visible, + defaultVisible = true, + onClose, + className, + inset = false, + ...props +}: BannerProps) => { + const [show, setShow] = useControllableState({ + defaultProp: defaultVisible, + prop: visible, + onChange: onClose, + }); + + if (!show) { + return null; + } + + return ( + +
+ {children} +
+
+ ); +}; + +export type BannerIconProps = HTMLAttributes & { + icon: LucideIcon; +}; + +export const BannerIcon = ({ + icon: Icon, + className, + ...props +}: BannerIconProps) => ( +
+ +
+); + +export type BannerTitleProps = HTMLAttributes; + +export const BannerTitle = ({ className, ...props }: BannerTitleProps) => ( +

+); + +export type BannerActionProps = ComponentProps; + +export const BannerAction = ({ + variant = 'outline', + size = 'sm', + className, + ...props +}: BannerActionProps) => ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/app/api/queries/useDoclingHealthQuery.ts b/frontend/src/app/api/queries/useDoclingHealthQuery.ts new file mode 100644 index 00000000..16ffc6c5 --- /dev/null +++ b/frontend/src/app/api/queries/useDoclingHealthQuery.ts @@ -0,0 +1,55 @@ +import { + type UseQueryOptions, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +export interface DoclingHealthResponse { + status: "healthy" | "unhealthy"; + message?: string; +} + +export const useDoclingHealthQuery = ( + options?: Omit, "queryKey" | "queryFn">, +) => { + const queryClient = useQueryClient(); + + async function checkDoclingHealth(): Promise { + try { + const response = await fetch("http://127.0.0.1:5001/health", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + return { status: "healthy" }; + } else { + return { + status: "unhealthy", + message: `Health check failed with status: ${response.status}`, + }; + } + } catch (error) { + return { + status: "unhealthy", + message: error instanceof Error ? error.message : "Connection failed", + }; + } + } + + const queryResult = useQuery( + { + queryKey: ["docling-health"], + queryFn: checkDoclingHealth, + retry: 1, + refetchInterval: 30000, // Check every 30 seconds + staleTime: 25000, // Consider data stale after 25 seconds + ...options, + }, + queryClient, + ); + + return queryResult; +}; \ No newline at end of file diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 072567ba..bcb9d75f 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/auth-context"; import { type EndpointType, useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useLayout } from "@/contexts/layout-context"; import { useTask } from "@/contexts/task-context"; import { useLoadingStore } from "@/stores/loadingStore"; import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; @@ -140,6 +141,7 @@ function ChatPage() { const streamIdRef = useRef(0); const lastLoadedConversationRef = useRef(null); const { addTask, isMenuOpen } = useTask(); + const { totalTopOffset } = useLayout(); const { selectedFilter, parsedFilterData, isPanelOpen, setSelectedFilter } = useKnowledgeFilter(); @@ -1891,7 +1893,7 @@ function ChatPage() { return (

{/* Debug header - only show in debug mode */} {isDebugMode && ( diff --git a/frontend/src/app/knowledge/chunks/page.tsx b/frontend/src/app/knowledge/chunks/page.tsx index cdc9fcc3..73af7e95 100644 --- a/frontend/src/app/knowledge/chunks/page.tsx +++ b/frontend/src/app/knowledge/chunks/page.tsx @@ -12,6 +12,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { ProtectedRoute } from "@/components/protected-route"; import { Button } from "@/components/ui/button"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useLayout } from "@/contexts/layout-context"; import { useTask } from "@/contexts/task-context"; import { type ChunkResult, @@ -33,6 +34,7 @@ function ChunksPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const { isMenuOpen } = useTask(); + const { totalTopOffset } = useLayout(); const { parsedFilterData, isPanelOpen } = useKnowledgeFilter(); const filename = searchParams.get("filename"); @@ -132,7 +134,7 @@ function ChunksPageContent() { return (
{/* Header */} diff --git a/frontend/src/app/knowledge/page.tsx b/frontend/src/app/knowledge/page.tsx index 5155f4e2..dc275396 100644 --- a/frontend/src/app/knowledge/page.tsx +++ b/frontend/src/app/knowledge/page.tsx @@ -11,6 +11,7 @@ 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 { useLayout } from "@/contexts/layout-context"; import { useTask } from "@/contexts/task-context"; import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; import "@/components/AgGrid/registerAgGridModules"; @@ -46,6 +47,7 @@ function getSourceIcon(connectorType?: string) { function SearchPage() { const router = useRouter(); const { isMenuOpen, files: taskFiles } = useTask(); + const { totalTopOffset } = useLayout(); const { selectedFilter, setSelectedFilter, parsedFilterData, isPanelOpen } = useKnowledgeFilter(); const [selectedRows, setSelectedRows] = useState([]); @@ -229,7 +231,7 @@ function SearchPage() { return (
diff --git a/frontend/src/components/layout-wrapper.tsx b/frontend/src/components/layout-wrapper.tsx index 5c2f0e13..5b598eb9 100644 --- a/frontend/src/components/layout-wrapper.tsx +++ b/frontend/src/components/layout-wrapper.tsx @@ -16,6 +16,9 @@ import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; // import { GitHubStarButton } from "@/components/github-star-button" // import { DiscordLink } from "@/components/discord-link" import { useTask } from "@/contexts/task-context"; +import { DoclingHealthBanner } from "@/components/docling-health-banner"; +import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; +import { LayoutProvider } from "@/contexts/layout-context"; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -31,6 +34,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({ enabled: isAuthenticated || isNoAuthMode, }); + const { data: health, isLoading: isHealthLoading, isError } = useDoclingHealthQuery(); // Only fetch conversations on chat page const isOnChatPage = pathname === "/" || pathname === "/chat"; @@ -56,6 +60,15 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { task.status === "processing", ); + const isUnhealthy = health?.status === "unhealthy" || isError; + const isBannerVisible = !isHealthLoading && isUnhealthy; + + // Dynamic height calculations based on banner visibility + const headerHeight = 53; + const bannerHeight = 52; // Approximate banner height + const totalTopOffset = isBannerVisible ? headerHeight + bannerHeight : headerHeight; + const mainContentHeight = `calc(100vh - ${totalTopOffset}px)`; + // Show loading state when backend isn't ready if (isLoading || isSettingsLoading) { return ( @@ -76,6 +89,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { // For all other pages, render with Langflow-styled navigation and task menu return (
+
{/* Logo/Title */} @@ -118,7 +132,10 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
-
+
-
{children}
+ +
{children}
+
diff --git a/frontend/src/contexts/layout-context.tsx b/frontend/src/contexts/layout-context.tsx new file mode 100644 index 00000000..f40ea28c --- /dev/null +++ b/frontend/src/contexts/layout-context.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { createContext, useContext } from "react"; + +interface LayoutContextType { + headerHeight: number; + totalTopOffset: number; +} + +const LayoutContext = createContext(undefined); + +export function useLayout() { + const context = useContext(LayoutContext); + if (context === undefined) { + throw new Error("useLayout must be used within a LayoutProvider"); + } + return context; +} + +export function LayoutProvider({ + children, + headerHeight, + totalTopOffset +}: { + children: React.ReactNode; + headerHeight: number; + totalTopOffset: number; +}) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 9954b463..282f73d0 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -32,7 +32,7 @@ class EnvConfig: langflow_superuser: str = "admin" langflow_superuser_password: str = "" langflow_chat_flow_id: str = "1098eea1-6649-4e1d-aed1-b77249fb8dd0" - langflow_ingest_flow_id: str = "5488df7c-b93f-4f87-a446-b67028bc0813" + langflow_ingest_flow_id: str = "1402618b-e6d1-4ff2-9a11-d6ce71186915" # OAuth settings google_oauth_client_id: str = ""