Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions frontend/app/api/mutations/useRenameDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMutation } from "@tanstack/react-query";

interface RenameDocumentParams {
oldFilename: string;
newFilename: string;
}

interface RenameDocumentResult {
success: boolean;
updated_chunks: number;
old_filename: string;
new_filename: string;
}

export function useRenameDocument() {
return useMutation<RenameDocumentResult, Error, RenameDocumentParams>({
mutationFn: async ({ oldFilename, newFilename }) => {
const response = await fetch("/documents/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_filename: oldFilename,
new_filename: newFilename,
}),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to rename document");
}

return response.json();
},
});
}
79 changes: 63 additions & 16 deletions frontend/app/knowledge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import OneDriveIcon from "../../components/icons/one-drive-logo";
import SharePointIcon from "../../components/icons/share-point-logo";
import { useDeleteDocument } from "../api/mutations/useDeleteDocument";
import { useRefreshOpenragDocs } from "../api/mutations/useRefreshOpenragDocs";
import { useRenameDocument } from "../api/mutations/useRenameDocument";
import { useSyncAllConnectors } from "../api/mutations/useSyncConnector";

// Function to get the appropriate icon for a connector type
Expand Down Expand Up @@ -96,7 +97,12 @@ function SearchPage() {
const hasInitializedFailedFilesRef = useRef(false);
const seenFailedFileKeysRef = useRef<Set<string>>(new Set());

// Rename state
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");

const deleteDocumentMutation = useDeleteDocument();
const renameDocumentMutation = useRenameDocument();
const syncAllConnectorsMutation = useSyncAllConnectors();
const refreshOpenragDocsMutation = useRefreshOpenragDocs();

Expand Down Expand Up @@ -157,7 +163,6 @@ function SearchPage() {
);

// Auto-open unified task panel only when a NEW task file transitions to failed
// (skip initial failed files that already existed on page load).
useEffect(() => {
const failedFiles = taskFiles.filter((file) => file.status === "failed");
const seenKeys = seenFailedFileKeysRef.current;
Expand Down Expand Up @@ -285,7 +290,6 @@ function SearchPage() {
if (isError && error) {
const errorMessage =
error instanceof Error ? error.message : "Search failed";
// Avoid showing duplicate toasts for the same error
if (lastErrorRef.current !== errorMessage) {
lastErrorRef.current = errorMessage;
toast.error("Search error", {
Expand All @@ -294,10 +298,34 @@ function SearchPage() {
});
}
} else if (!isError) {
// Reset when query succeeds
lastErrorRef.current = null;
}
}, [isError, error]);

// Rename handler
const handleRename = useCallback(
async (oldFilename: string, newFilename: string) => {
setEditingFilename(null);
const trimmed = newFilename.trim();
if (!trimmed || trimmed === oldFilename) return;

try {
await renameDocumentMutation.mutateAsync({
oldFilename,
newFilename: trimmed,
});
await queryClient.invalidateQueries({ queryKey: ["search"] });
await queryClient.refetchQueries({ queryKey: ["search"] });
toast.success(`Renamed to "${trimmed}"`);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to rename document",
);
}
},
[renameDocumentMutation, queryClient],
);

const fileResults = buildKnowledgeTableRows(
searchData as File[],
taskFiles,
Expand Down Expand Up @@ -331,11 +359,30 @@ function SearchPage() {
initialFlex: 2,
minWidth: 220,
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
// Read status directly from data on each render
const status = data?.status || "active";
const isActive = status === "active";
const showOpenragSourceAnimation =
isOpenragDocsRow(data) && hasOpenragRefreshCue;
const isEditing = editingFilename === value;

if (isEditing) {
return (
<div className="flex items-center w-full px-1">
<input
autoFocus
className="border border-border rounded px-2 py-0.5 text-sm w-full bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => handleRename(value, editValue)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(value, editValue);
if (e.key === "Escape") setEditingFilename(null);
}}
/>
</div>
);
}

return (
<div className="flex items-center overflow-hidden w-full">
<div
Expand All @@ -347,15 +394,18 @@ function SearchPage() {
type="button"
className="flex items-center gap-2 cursor-pointer hover:text-blue-600 transition-colors text-left flex-1 overflow-hidden"
onClick={() => {
if (!isActive) {
return;
}
if (!isActive) return;
router.push(
`/knowledge/chunks?filename=${encodeURIComponent(
data?.filename ?? "",
)}`,
);
}}
onDoubleClick={() => {
if (!isActive) return;
setEditingFilename(value);
setEditValue(value);
}}
>
{getSourceIcon(data?.connector_type)}
<Tooltip>
Expand All @@ -371,7 +421,7 @@ function SearchPage() {
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{value}
{value} — double-click to rename
</TooltipContent>
</Tooltip>
</button>
Expand Down Expand Up @@ -505,6 +555,10 @@ function SearchPage() {
<KnowledgeActionsDropdown
filename={data?.filename || ""}
connectorType={data?.connector_type}
onRename={(filename) => {
setEditingFilename(filename);
setEditValue(filename);
}}
/>
);
},
Expand Down Expand Up @@ -542,7 +596,6 @@ function SearchPage() {
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 }),
);
Expand Down Expand Up @@ -582,7 +635,6 @@ function SearchPage() {
setSelectedRows([]);
setShowBulkDeleteDialog(false);

// Clear selection in the grid
if (gridRef.current) {
gridRef.current.api.deselectAll();
}
Expand All @@ -596,13 +648,8 @@ function SearchPage() {
}
};

// enables pagination in the grid
const pagination = true;

// sets 25 rows per page (default is 100)
const paginationPageSize = 25;

// allows the user to select the page size from a predefined list of page sizes
const paginationPageSizeSelector = [10, 25, 50, 100];

return (
Expand Down Expand Up @@ -759,4 +806,4 @@ export default function ProtectedSearchPage() {
<SearchPage />
</ProtectedRoute>
);
}
}
13 changes: 10 additions & 3 deletions frontend/components/knowledge-actions-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { AlertCircle, EllipsisVertical, RefreshCw } from "lucide-react";
import { AlertCircle, EllipsisVertical, Pencil, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
Expand All @@ -27,6 +27,7 @@ import { Button } from "./ui/button";
interface KnowledgeActionsDropdownProps {
filename: string;
connectorType?: string;
onRename?: (filename: string) => void;
}

// Cloud connector types that support sync
Expand All @@ -39,6 +40,7 @@ const CLOUD_CONNECTOR_TYPES = new Set([
export const KnowledgeActionsDropdown = ({
filename,
connectorType,
onRename,
}: KnowledgeActionsDropdownProps) => {
const { refreshTasks } = useTask();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
Expand All @@ -47,10 +49,8 @@ export const KnowledgeActionsDropdown = ({
const { data: connectors = [] } = useGetConnectorsQuery();
const router = useRouter();

// Check if this file is from a cloud connector (can be synced)
const isCloudFile = connectorType && CLOUD_CONNECTOR_TYPES.has(connectorType);

// Check if the connector is connected
const isConnected = useMemo(() => {
if (!connectorType) return false;
const connector = connectors.find((c) => c.type === connectorType);
Expand Down Expand Up @@ -120,6 +120,13 @@ export const KnowledgeActionsDropdown = ({
>
View chunks
</DropdownMenuItem>
<DropdownMenuItem
className="text-primary focus:text-primary cursor-pointer"
onClick={() => onRename?.(filename)}
>
<Pencil className="h-4 w-4 mr-2" />
Rename
</DropdownMenuItem>
{isCloudFile && (
<TooltipProvider>
<Tooltip delayDuration={0}>
Expand Down
77 changes: 76 additions & 1 deletion src/api/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ class DeleteDocumentBody(BaseModel):
filename: str


class RenameDocumentBody(BaseModel):
old_filename: str
new_filename: str


async def delete_documents_by_filename_core(
filename: str,
session_manager,
Expand Down Expand Up @@ -171,10 +176,80 @@ async def delete_documents_by_filename(
user: User = Depends(get_current_user),
):
"""Delete all documents with a specific filename"""
payload, status_code =await delete_documents_by_filename_core(
payload, status_code = await delete_documents_by_filename_core(
filename=body.filename,
session_manager=session_manager,
user_id=user.user_id,
jwt_token=user.jwt_token,
)
return JSONResponse(payload, status_code=status_code)


async def rename_document(
body: RenameDocumentBody,
session_manager=Depends(get_session_manager),
user: User = Depends(get_current_user),
):
"""Rename all document chunks with a specific filename"""
from config.settings import get_index_name

jwt_token = user.jwt_token
old_filename = (body.old_filename or "").strip()
new_filename = (body.new_filename or "").strip()

if not old_filename or not new_filename:
return JSONResponse(
{"error": "old_filename and new_filename are required"}, status_code=400
)

try:
opensearch_client = session_manager.get_user_opensearch_client(
user.user_id, jwt_token
)

update_query = {
"script": {
"source": "ctx._source.filename = params.new_filename",
"lang": "painless",
"params": {"new_filename": new_filename},
},
"query": {"term": {"filename": old_filename}},
}

result = await opensearch_client.update_by_query(
index=get_index_name(),
body=update_query,
conflicts="proceed",
)

updated_count = result.get("updated", 0)

if updated_count == 0:
return JSONResponse(
{"success": False, "error": "No matching document chunks found."},
status_code=404,
)

logger.info(
f"Renamed {updated_count} chunks from '{old_filename}' to '{new_filename}'",
user_id=user.user_id,
)

return JSONResponse(
{
"success": True,
"updated_chunks": updated_count,
"old_filename": old_filename,
"new_filename": new_filename,
},
status_code=200,
)

except Exception as e:
logger.error("Error renaming document", old_filename=old_filename, error=str(e))
error_str = str(e)
status_code = 403 if "AuthenticationException" in error_str else 500
return JSONResponse(
{"success": False, "error": "Access denied" if status_code == 403 else "Rename failed"},
status_code=status_code,
)
Loading
Loading