From be8e13a173206a72a55dc0d805def0dd1b34a531 Mon Sep 17 00:00:00 2001 From: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Date: Wed, 24 Sep 2025 07:27:59 -0600 Subject: [PATCH] feat: add knowledge status (#53) * feat: add status handling and visual indicators for file statuses * refactor: comment out status field and related rendering logic in SearchPage * format * add timeout on mutation delete document * make file fields be optional * fetch task files and display them on knowledge page * add tasks to files inside task context * added failed to status badge * added files on get all tasks on backend * Changed models to get parameters by settings if not existent * changed settings page to get models when is no ajth mode * fixed openai allowing validation even when value is not present * removed unused console log --------- Co-authored-by: Lucas Oliveira Co-authored-by: Mike Fortman --- .../app/api/mutations/useDeleteDocument.ts | 8 +- .../src/app/api/queries/useGetModelsQuery.ts | 2 +- .../src/app/api/queries/useGetSearchQuery.ts | 27 ++-- frontend/src/app/knowledge/page.tsx | 77 +++++++--- frontend/src/app/settings/page.tsx | 110 +++++++++---- .../ui/animated-processing-icon.tsx | 49 ++++++ frontend/src/components/ui/status-badge.tsx | 58 +++++++ frontend/src/contexts/task-context.tsx | 145 ++++++++++++++++-- src/api/models.py | 97 ++++++++++-- src/services/task_service.py | 66 ++++++-- 10 files changed, 529 insertions(+), 110 deletions(-) create mode 100644 frontend/src/components/ui/animated-processing-icon.tsx create mode 100644 frontend/src/components/ui/status-badge.tsx diff --git a/frontend/src/app/api/mutations/useDeleteDocument.ts b/frontend/src/app/api/mutations/useDeleteDocument.ts index 78985498..47b852b1 100644 --- a/frontend/src/app/api/mutations/useDeleteDocument.ts +++ b/frontend/src/app/api/mutations/useDeleteDocument.ts @@ -14,7 +14,7 @@ interface DeleteDocumentResponse { } const deleteDocument = async ( - data: DeleteDocumentRequest + data: DeleteDocumentRequest, ): Promise => { const response = await fetch("/api/documents/delete-by-filename", { method: "POST", @@ -37,9 +37,11 @@ export const useDeleteDocument = () => { return useMutation({ mutationFn: deleteDocument, - onSuccess: () => { + onSettled: () => { // Invalidate and refetch search queries to update the UI - queryClient.invalidateQueries({ queryKey: ["search"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["search"] }); + }, 1000); }, }); }; diff --git a/frontend/src/app/api/queries/useGetModelsQuery.ts b/frontend/src/app/api/queries/useGetModelsQuery.ts index cd24131b..4ce55bd3 100644 --- a/frontend/src/app/api/queries/useGetModelsQuery.ts +++ b/frontend/src/app/api/queries/useGetModelsQuery.ts @@ -54,7 +54,7 @@ export const useGetOpenAIModelsQuery = ( queryKey: ["models", "openai", params], queryFn: getOpenAIModels, retry: 2, - enabled: options?.enabled !== false, // Allow enabling/disabling from options + enabled: !!params?.apiKey, staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache results ...options, diff --git a/frontend/src/app/api/queries/useGetSearchQuery.ts b/frontend/src/app/api/queries/useGetSearchQuery.ts index 9928af3d..37798ce5 100644 --- a/frontend/src/app/api/queries/useGetSearchQuery.ts +++ b/frontend/src/app/api/queries/useGetSearchQuery.ts @@ -34,21 +34,28 @@ export interface ChunkResult { export interface File { filename: string; mimetype: string; - chunkCount: number; - avgScore: number; + chunkCount?: number; + avgScore?: number; source_url: string; - owner: string; - owner_name: string; - owner_email: string; + owner?: string; + owner_name?: string; + owner_email?: string; size: number; connector_type: string; - chunks: ChunkResult[]; + status?: + | "processing" + | "active" + | "unavailable" + | "failed" + | "hidden" + | "sync"; + chunks?: ChunkResult[]; } export const useGetSearchQuery = ( query: string, queryData?: ParsedQueryData | null, - options?: Omit + options?: Omit, ) => { const queryClient = useQueryClient(); @@ -149,7 +156,7 @@ export const useGetSearchQuery = ( } }); - const files: File[] = Array.from(fileMap.values()).map(file => ({ + const files: File[] = Array.from(fileMap.values()).map((file) => ({ filename: file.filename, mimetype: file.mimetype, chunkCount: file.chunks.length, @@ -173,11 +180,11 @@ export const useGetSearchQuery = ( const queryResult = useQuery( { queryKey: ["search", effectiveQuery], - placeholderData: prev => prev, + placeholderData: (prev) => prev, queryFn: getFiles, ...options, }, - queryClient + queryClient, ); return queryResult; diff --git a/frontend/src/app/knowledge/page.tsx b/frontend/src/app/knowledge/page.tsx index ee116a71..5155f4e2 100644 --- a/frontend/src/app/knowledge/page.tsx +++ b/frontend/src/app/knowledge/page.tsx @@ -1,16 +1,10 @@ "use client"; -import { - Building2, - Cloud, - HardDrive, - Search, - Trash2, - X, -} from "lucide-react"; -import { AgGridReact, CustomCellRendererProps } from "ag-grid-react"; -import { useCallback, useState, useRef, ChangeEvent } from "react"; +import type { ColDef } from "ag-grid-community"; +import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react"; +import { Building2, Cloud, HardDrive, Search, Trash2, X } from "lucide-react"; import { useRouter } from "next/navigation"; +import { type ChangeEvent, useCallback, useRef, useState } from "react"; import { SiGoogledrive } from "react-icons/si"; import { TbBrandOnedrive } from "react-icons/tb"; import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; @@ -19,13 +13,13 @@ 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 { ColDef } from "ag-grid-community"; import "@/components/AgGrid/registerAgGridModules"; import "@/components/AgGrid/agGridStyles.css"; +import { toast } from "sonner"; import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown"; +import { StatusBadge } from "@/components/ui/status-badge"; import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog"; import { useDeleteDocument } from "../api/mutations/useDeleteDocument"; -import { toast } from "sonner"; // Function to get the appropriate icon for a connector type function getSourceIcon(connectorType?: string) { @@ -51,7 +45,7 @@ function getSourceIcon(connectorType?: string) { function SearchPage() { const router = useRouter(); - const { isMenuOpen } = useTask(); + const { isMenuOpen, files: taskFiles } = useTask(); const { selectedFilter, setSelectedFilter, parsedFilterData, isPanelOpen } = useKnowledgeFilter(); const [selectedRows, setSelectedRows] = useState([]); @@ -61,14 +55,38 @@ function SearchPage() { const { data = [], isFetching } = useGetSearchQuery( parsedFilterData?.query || "*", - parsedFilterData + parsedFilterData, ); const handleTableSearch = (e: ChangeEvent) => { gridRef.current?.api.setGridOption("quickFilterText", e.target.value); }; - const fileResults = data as File[]; + // 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, + }; + }); + + const backendFiles = data as File[]; + + 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); @@ -82,13 +100,14 @@ function SearchPage() { minWidth: 220, cellRenderer: ({ data, value }: CustomCellRendererProps) => { return ( -
{ router.push( `/knowledge/chunks?filename=${encodeURIComponent( - data?.filename ?? "" - )}` + data?.filename ?? "", + )}`, ); }} > @@ -96,7 +115,7 @@ function SearchPage() { {value} -
+ ); }, }, @@ -119,6 +138,7 @@ function SearchPage() { { field: "chunkCount", headerName: "Chunks", + valueFormatter: (params) => params.data?.chunkCount?.toString() || "-", }, { field: "avgScore", @@ -127,11 +147,20 @@ function SearchPage() { cellRenderer: ({ value }: CustomCellRendererProps) => { return ( - {value.toFixed(2)} + {value?.toFixed(2) ?? "-"} ); }, }, + { + field: "status", + headerName: "Status", + cellRenderer: ({ data }: CustomCellRendererProps) => { + // Default to 'active' status if no status is provided + const status = data?.status || "active"; + return ; + }, + }, { cellRenderer: ({ data }: CustomCellRendererProps) => { return ; @@ -172,7 +201,7 @@ function SearchPage() { try { // Delete each file individually since the API expects one filename at a time const deletePromises = selectedRows.map((row) => - deleteDocumentMutation.mutateAsync({ filename: row.filename }) + deleteDocumentMutation.mutateAsync({ filename: row.filename }), ); await Promise.all(deletePromises); @@ -180,7 +209,7 @@ function SearchPage() { toast.success( `Successfully deleted ${selectedRows.length} document${ selectedRows.length > 1 ? "s" : "" - }` + }`, ); setSelectedRows([]); setShowBulkDeleteDialog(false); @@ -193,7 +222,7 @@ function SearchPage() { toast.error( error instanceof Error ? error.message - : "Failed to delete some documents" + : "Failed to delete some documents", ); } }; diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index eea555c2..f49ff393 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -4,11 +4,13 @@ import { Loader2, PlugZap, RefreshCw } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useState } from "react"; import { useUpdateFlowSettingMutation } from "@/app/api/mutations/useUpdateFlowSettingMutation"; +import { + useGetIBMModelsQuery, + useGetOllamaModelsQuery, + useGetOpenAIModelsQuery, +} from "@/app/api/queries/useGetModelsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; -import { useGetOpenAIModelsQuery, useGetOllamaModelsQuery, useGetIBMModelsQuery } from "@/app/api/queries/useGetModelsQuery"; import { ConfirmationDialog } from "@/components/confirmation-dialog"; -import { ModelSelectItems } from "./helpers/model-select-item"; -import { getFallbackModels, type ModelProvider } from "./helpers/model-helpers"; import { ProtectedRoute } from "@/components/protected-route"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -33,6 +35,8 @@ import { Textarea } from "@/components/ui/textarea"; import { useAuth } from "@/contexts/auth-context"; import { useTask } from "@/contexts/task-context"; import { useDebounce } from "@/lib/debounce"; +import { getFallbackModels, type ModelProvider } from "./helpers/model-helpers"; +import { ModelSelectItems } from "./helpers/model-select-item"; const MAX_SYSTEM_PROMPT_CHARS = 2000; @@ -105,42 +109,46 @@ function KnowledgeSourcesPage() { // Fetch settings using React Query const { data: settings = {} } = useGetSettingsQuery({ - enabled: isAuthenticated, + enabled: isAuthenticated || isNoAuthMode, }); // Get the current provider from settings - const currentProvider = (settings.provider?.model_provider || 'openai') as ModelProvider; + const currentProvider = (settings.provider?.model_provider || + "openai") as ModelProvider; // Fetch available models based on provider const { data: openaiModelsData } = useGetOpenAIModelsQuery( undefined, // Let backend use stored API key from configuration { - enabled: isAuthenticated && currentProvider === 'openai', - } + enabled: + (isAuthenticated || isNoAuthMode) && currentProvider === "openai", + }, ); const { data: ollamaModelsData } = useGetOllamaModelsQuery( undefined, // No params for now, could be extended later { - enabled: isAuthenticated && currentProvider === 'ollama', - } + enabled: + (isAuthenticated || isNoAuthMode) && currentProvider === "ollama", + }, ); const { data: ibmModelsData } = useGetIBMModelsQuery( undefined, // No params for now, could be extended later { - enabled: isAuthenticated && currentProvider === 'ibm', - } + enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "ibm", + }, ); // Select the appropriate models data based on provider - const modelsData = currentProvider === 'openai' - ? openaiModelsData - : currentProvider === 'ollama' - ? ollamaModelsData - : currentProvider === 'ibm' - ? ibmModelsData - : openaiModelsData; // fallback to openai + const modelsData = + currentProvider === "openai" + ? openaiModelsData + : currentProvider === "ollama" + ? ollamaModelsData + : currentProvider === "ibm" + ? ibmModelsData + : openaiModelsData; // fallback to openai // Mutations const updateFlowSettingMutation = useUpdateFlowSettingMutation({ @@ -152,7 +160,6 @@ function KnowledgeSourcesPage() { }, }); - // Debounced update function const debouncedUpdate = useDebounce( (variables: Parameters[0]) => { @@ -224,7 +231,6 @@ function KnowledgeSourcesPage() { debouncedUpdate({ doclingPresets: mode }); }; - // Helper function to get connector icon const getConnectorIcon = useCallback((iconName: string) => { const iconMap: { [key: string]: React.ReactElement } = { @@ -613,7 +619,11 @@ function KnowledgeSourcesPage() { Language Model m.default)?.value || "text-embedding-ada-002" + settings.knowledge?.embedding_model || + modelsData?.embedding_models?.find((m) => m.default)?.value || + "text-embedding-ada-002" } onValueChange={handleEmbeddingModelChange} > @@ -746,7 +771,9 @@ function KnowledgeSourcesPage() { @@ -807,7 +834,10 @@ function KnowledgeSourcesPage() {
-