mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-24 21:38:09 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/replace-logs
This commit is contained in:
commit
99bd2df463
59 changed files with 2788 additions and 1579 deletions
|
|
@ -47,7 +47,7 @@ export function DocumentsFilters({
|
|||
columnVisibility,
|
||||
onToggleColumn,
|
||||
}: {
|
||||
typeCounts: Record<DocumentTypeEnum, number>;
|
||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||
selectedIds: Set<number>;
|
||||
onSearch: (v: string) => void;
|
||||
searchValue: string;
|
||||
|
|
|
|||
|
|
@ -79,17 +79,25 @@ export function DocumentsTableShell({
|
|||
[documents, sortKey, sortDesc]
|
||||
);
|
||||
|
||||
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id));
|
||||
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
||||
// Filter out SURFSENSE_DOCS for selection purposes
|
||||
const selectableDocs = React.useMemo(
|
||||
() => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"),
|
||||
[sorted]
|
||||
);
|
||||
|
||||
const allSelectedOnPage =
|
||||
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
|
||||
const someSelectedOnPage =
|
||||
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (checked)
|
||||
sorted.forEach((d) => {
|
||||
selectableDocs.forEach((d) => {
|
||||
next.add(d.id);
|
||||
});
|
||||
else
|
||||
sorted.forEach((d) => {
|
||||
selectableDocs.forEach((d) => {
|
||||
next.delete(d.id);
|
||||
});
|
||||
setSelectedIds(next);
|
||||
|
|
@ -230,9 +238,10 @@ export function DocumentsTableShell({
|
|||
const icon = getDocumentTypeIcon(doc.document_type);
|
||||
const title = doc.title;
|
||||
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
|
||||
const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS";
|
||||
return (
|
||||
<motion.tr
|
||||
key={doc.id}
|
||||
key={`${doc.document_type}-${doc.id}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
|
|
@ -249,8 +258,9 @@ export function DocumentsTableShell({
|
|||
>
|
||||
<TableCell className="px-4 py-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(doc.id)}
|
||||
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
|
||||
checked={selectedIds.has(doc.id) && !isSurfsenseDoc}
|
||||
onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)}
|
||||
disabled={isSurfsenseDoc}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import type { Document } from "./types";
|
|||
// Only FILE and NOTE document types can be edited
|
||||
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
|
||||
|
||||
// SURFSENSE_DOCS are system-managed and cannot be deleted
|
||||
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
|
||||
|
||||
export function RowActions({
|
||||
document,
|
||||
deleteDocument,
|
||||
|
|
@ -48,6 +51,10 @@ export function RowActions({
|
|||
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
||||
);
|
||||
|
||||
const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
|
|
@ -120,29 +127,31 @@ export function RowActions({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
{isDeletable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions Dropdown */}
|
||||
|
|
@ -165,13 +174,15 @@ export function RowActions({
|
|||
<FileText className="mr-2 h-4 w-4" />
|
||||
<span>Metadata</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
{isDeletable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, SquarePlus, Upload } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
|
@ -17,7 +18,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { DocumentsFilters } from "./components/DocumentsFilters";
|
||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
||||
import { PaginationControls } from "./components/PaginationControls";
|
||||
import type { ColumnVisibility } from "./components/types";
|
||||
import type { ColumnVisibility, Document } from "./components/types";
|
||||
|
||||
function useDebounced<T>(value: T, delay = 250) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
|
@ -32,7 +33,13 @@ export default function DocumentsTable() {
|
|||
const t = useTranslations("documents");
|
||||
const id = useId();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
|
||||
const handleNewNote = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 250);
|
||||
|
|
@ -48,33 +55,42 @@ export default function DocumentsTable() {
|
|||
const [sortKey, setSortKey] = useState<SortKey>("title");
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom);
|
||||
const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
|
||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||
|
||||
// Build query parameters for fetching documents
|
||||
// Filter out SURFSENSE_DOCS from active types for regular documents API
|
||||
const regularDocumentTypes = useMemo(
|
||||
() => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"),
|
||||
[activeTypes]
|
||||
);
|
||||
|
||||
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
|
||||
const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
|
||||
|
||||
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
search_space_id: searchSpaceId,
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
||||
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
|
||||
}),
|
||||
[searchSpaceId, pageIndex, pageSize, activeTypes]
|
||||
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes]
|
||||
);
|
||||
|
||||
// Build search query parameters
|
||||
// Build search query parameters (excluding SURFSENSE_DOCS type)
|
||||
const searchQueryParams = useMemo(
|
||||
() => ({
|
||||
search_space_id: searchSpaceId,
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
title: debouncedSearch.trim(),
|
||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
||||
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
|
||||
}),
|
||||
[searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch]
|
||||
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch]
|
||||
);
|
||||
|
||||
// Use query for fetching documents
|
||||
// Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected)
|
||||
const {
|
||||
data: documentsResponse,
|
||||
isLoading: isDocumentsLoading,
|
||||
|
|
@ -84,10 +100,10 @@ export default function DocumentsTable() {
|
|||
queryKey: cacheKeys.documents.globalQueryParams(queryParams),
|
||||
queryFn: () => documentsApiService.getDocuments({ queryParams }),
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
enabled: !!searchSpaceId && !debouncedSearch.trim(),
|
||||
enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected,
|
||||
});
|
||||
|
||||
// Use query for searching documents
|
||||
// Use query for searching documents (disabled when only SURFSENSE_DOCS is selected)
|
||||
const {
|
||||
data: searchResponse,
|
||||
isLoading: isSearchLoading,
|
||||
|
|
@ -97,16 +113,111 @@ export default function DocumentsTable() {
|
|||
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
|
||||
enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected,
|
||||
});
|
||||
|
||||
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
|
||||
const showSurfsenseDocs =
|
||||
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
|
||||
|
||||
// Use query for fetching SurfSense docs
|
||||
const {
|
||||
data: surfsenseDocsResponse,
|
||||
isLoading: isSurfsenseDocsLoading,
|
||||
refetch: refetchSurfsenseDocs,
|
||||
} = useQuery({
|
||||
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
|
||||
queryFn: () =>
|
||||
documentsApiService.getSurfsenseDocs({
|
||||
queryParams: {
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
title: debouncedSearch.trim() || undefined,
|
||||
},
|
||||
}),
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
enabled: showSurfsenseDocs,
|
||||
});
|
||||
|
||||
// Transform SurfSense docs to match the Document type
|
||||
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
|
||||
if (!surfsenseDocsResponse?.items) return [];
|
||||
return surfsenseDocsResponse.items.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: "SURFSENSE_DOCS",
|
||||
document_metadata: { source: doc.source },
|
||||
content: doc.content,
|
||||
created_at: doc.created_at || doc.updated_at || new Date().toISOString(),
|
||||
search_space_id: -1, // Special value for global docs
|
||||
}));
|
||||
}, [surfsenseDocsResponse]);
|
||||
|
||||
// Merge type counts with SURFSENSE_DOCS count
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts = { ...(rawTypeCounts || {}) };
|
||||
if (surfsenseDocsResponse?.total) {
|
||||
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
|
||||
}
|
||||
return counts;
|
||||
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
|
||||
|
||||
// Extract documents and total based on search state
|
||||
const documents = debouncedSearch.trim()
|
||||
const regularDocuments = debouncedSearch.trim()
|
||||
? searchResponse?.items || []
|
||||
: documentsResponse?.items || [];
|
||||
const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0;
|
||||
const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
|
||||
const error = debouncedSearch.trim() ? searchError : documentsError;
|
||||
const regularTotal = debouncedSearch.trim()
|
||||
? searchResponse?.total || 0
|
||||
: documentsResponse?.total || 0;
|
||||
|
||||
// Merge regular documents with SurfSense docs
|
||||
const documents = useMemo(() => {
|
||||
// If filtering by type and not including SURFSENSE_DOCS, only show regular docs
|
||||
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
|
||||
return regularDocuments;
|
||||
}
|
||||
// If filtering only by SURFSENSE_DOCS, only show surfsense docs
|
||||
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
|
||||
return surfsenseDocsAsDocuments;
|
||||
}
|
||||
// Otherwise, merge both (surfsense docs first)
|
||||
return [...surfsenseDocsAsDocuments, ...regularDocuments];
|
||||
}, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
|
||||
return regularTotal;
|
||||
}
|
||||
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
|
||||
return surfsenseDocsResponse?.total || 0;
|
||||
}
|
||||
return regularTotal + (surfsenseDocsResponse?.total || 0);
|
||||
}, [regularTotal, surfsenseDocsResponse?.total, activeTypes]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
// If only SURFSENSE_DOCS selected, only check surfsense loading
|
||||
if (onlySurfsenseDocsSelected) {
|
||||
return isSurfsenseDocsLoading;
|
||||
}
|
||||
// Otherwise check both regular docs and surfsense docs loading
|
||||
const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
|
||||
return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading);
|
||||
}, [
|
||||
onlySurfsenseDocsSelected,
|
||||
isSurfsenseDocsLoading,
|
||||
debouncedSearch,
|
||||
isSearchLoading,
|
||||
isDocumentsLoading,
|
||||
showSurfsenseDocs,
|
||||
]);
|
||||
|
||||
const error = useMemo(() => {
|
||||
// If only SURFSENSE_DOCS selected, no regular docs errors
|
||||
if (onlySurfsenseDocsSelected) {
|
||||
return null;
|
||||
}
|
||||
return debouncedSearch.trim() ? searchError : documentsError;
|
||||
}, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]);
|
||||
|
||||
// Display server-filtered results directly
|
||||
const displayDocs = documents || [];
|
||||
|
|
@ -129,16 +240,33 @@ export default function DocumentsTable() {
|
|||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
if (debouncedSearch.trim()) {
|
||||
await refetchSearch();
|
||||
} else {
|
||||
await refetchDocuments();
|
||||
const refetchPromises: Promise<unknown>[] = [];
|
||||
// Only refetch regular documents if not in "only surfsense docs" mode
|
||||
if (!onlySurfsenseDocsSelected) {
|
||||
if (debouncedSearch.trim()) {
|
||||
refetchPromises.push(refetchSearch());
|
||||
} else {
|
||||
refetchPromises.push(refetchDocuments());
|
||||
}
|
||||
}
|
||||
if (showSurfsenseDocs) {
|
||||
refetchPromises.push(refetchSurfsenseDocs());
|
||||
}
|
||||
await Promise.all(refetchPromises);
|
||||
toast.success(t("refresh_success") || "Documents refreshed");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
|
||||
}, [
|
||||
debouncedSearch,
|
||||
refetchSearch,
|
||||
refetchDocuments,
|
||||
refetchSurfsenseDocs,
|
||||
showSurfsenseDocs,
|
||||
onlySurfsenseDocsSelected,
|
||||
t,
|
||||
isRefreshing,
|
||||
]);
|
||||
|
||||
// Create a delete function for single document deletion
|
||||
const deleteDocument = useCallback(
|
||||
|
|
@ -212,10 +340,20 @@ export default function DocumentsTable() {
|
|||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={openUploadDialog} variant="default" size="sm">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("upload_documents")}
|
||||
</Button>
|
||||
<Button onClick={handleNewNote} variant="outline" size="sm">
|
||||
<SquarePlus className="w-4 h-4 mr-2" />
|
||||
{t("create_shared_note")}
|
||||
</Button>
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<DocumentsFilters
|
||||
|
|
|
|||
|
|
@ -267,21 +267,8 @@ export default function EditorPage() {
|
|||
setHasUnsavedChanges(false);
|
||||
toast.success("Note created successfully! Reindexing in background...");
|
||||
|
||||
// Invalidate notes query to refresh the sidebar
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["notes", String(searchSpaceId)],
|
||||
});
|
||||
|
||||
// Update URL to reflect the new document ID without navigation
|
||||
window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
|
||||
// Update document state to reflect the new ID
|
||||
setDocument({
|
||||
document_id: note.id,
|
||||
title: title,
|
||||
document_type: "NOTE",
|
||||
blocknote_document: editorContent,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
// Redirect to documents page after successful save
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
} else {
|
||||
// Existing document - save normally
|
||||
if (!editorContent) {
|
||||
|
|
@ -310,12 +297,8 @@ export default function EditorPage() {
|
|||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Invalidate notes query when updating notes to refresh the sidebar
|
||||
if (isNote) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["notes", String(searchSpaceId)],
|
||||
});
|
||||
}
|
||||
// Redirect to documents page after successful save
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
|
|
@ -336,7 +319,7 @@ export default function EditorPage() {
|
|||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -346,12 +329,12 @@ export default function EditorPage() {
|
|||
setGlobalHasUnsavedChanges(false);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// If there's a pending navigation (from sidebar), use that; otherwise go back to chat
|
||||
// If there's a pending navigation (from sidebar), use that; otherwise go back to documents
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -392,7 +375,7 @@ export default function EditorPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)}
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -40,9 +40,12 @@ import {
|
|||
} from "@/lib/chat/podcast-state";
|
||||
import {
|
||||
appendMessage,
|
||||
type ChatVisibility,
|
||||
createThread,
|
||||
getThreadFull,
|
||||
getThreadMessages,
|
||||
type MessageRecord,
|
||||
type ThreadRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import {
|
||||
trackChatCreated,
|
||||
|
|
@ -217,6 +220,7 @@ export default function NewChatPage() {
|
|||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
// Store thinking steps per message ID - kept separate from content to avoid
|
||||
|
|
@ -264,19 +268,31 @@ export default function NewChatPage() {
|
|||
// Reset all state when switching between chats to prevent stale data
|
||||
setMessages([]);
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
setMessageThinkingSteps(new Map());
|
||||
setMentionedDocumentIds([]);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setMentionedDocuments([]);
|
||||
setMessageDocumentsMap({});
|
||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
// Thread exists - load messages
|
||||
// Thread exists - load thread data and messages
|
||||
setThreadId(urlChatId);
|
||||
const response = await getThreadMessages(urlChatId);
|
||||
if (response.messages && response.messages.length > 0) {
|
||||
const loadedMessages = response.messages.map(convertToThreadMessage);
|
||||
|
||||
// Load thread data (for visibility info) and messages in parallel
|
||||
const [threadData, messagesResponse] = await Promise.all([
|
||||
getThreadFull(urlChatId),
|
||||
getThreadMessages(urlChatId),
|
||||
]);
|
||||
|
||||
setCurrentThread(threadData);
|
||||
|
||||
if (messagesResponse.messages && messagesResponse.messages.length > 0) {
|
||||
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
|
||||
setMessages(loadedMessages);
|
||||
|
||||
// Extract and restore thinking steps from persisted messages
|
||||
|
|
@ -284,7 +300,7 @@ export default function NewChatPage() {
|
|||
// Extract and restore mentioned documents from persisted messages
|
||||
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
||||
|
||||
for (const msg of response.messages) {
|
||||
for (const msg of messagesResponse.messages) {
|
||||
if (msg.role === "assistant") {
|
||||
const steps = extractThinkingSteps(msg.content);
|
||||
if (steps.length > 0) {
|
||||
|
|
@ -320,6 +336,7 @@ export default function NewChatPage() {
|
|||
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||
// that will cause 404 errors on subsequent API calls
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
toast.error("Failed to load chat. Please try again.");
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
|
|
@ -346,6 +363,19 @@ export default function NewChatPage() {
|
|||
setIsRunning(false);
|
||||
}, []);
|
||||
|
||||
// Handle visibility change from ChatShareButton
|
||||
const handleVisibilityChange = useCallback(
|
||||
(newVisibility: ChatVisibility) => {
|
||||
setCurrentThread((prev) => (prev ? { ...prev, visibility: newVisibility } : null));
|
||||
// Refetch all thread queries so sidebar reflects the change immediately
|
||||
// Use predicate to match any query that starts with "threads"
|
||||
queryClient.refetchQueries({
|
||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
// Handle new message from user
|
||||
const onNew = useCallback(
|
||||
async (message: AppendMessage) => {
|
||||
|
|
@ -429,7 +459,9 @@ export default function NewChatPage() {
|
|||
// Track message sent
|
||||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||
hasAttachments: messageAttachments.length > 0,
|
||||
hasMentionedDocuments: mentionedDocumentIds.length > 0,
|
||||
hasMentionedDocuments:
|
||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||
mentionedDocumentIds.document_ids.length > 0,
|
||||
messageLength: userQuery.length,
|
||||
});
|
||||
|
||||
|
|
@ -627,12 +659,16 @@ export default function NewChatPage() {
|
|||
// Extract attachment content to send with the request
|
||||
const attachments = extractAttachmentContent(messageAttachments);
|
||||
|
||||
// Get mentioned document IDs for context
|
||||
const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined;
|
||||
// Get mentioned document IDs for context (separate fields for backend)
|
||||
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||
|
||||
// Clear mentioned documents after capturing them
|
||||
if (mentionedDocumentIds.length > 0) {
|
||||
setMentionedDocumentIds([]);
|
||||
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setMentionedDocuments([]);
|
||||
}
|
||||
|
||||
|
|
@ -648,7 +684,10 @@ export default function NewChatPage() {
|
|||
search_space_id: searchSpaceId,
|
||||
messages: messageHistory,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
mentioned_document_ids: documentIds,
|
||||
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
|
||||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -916,7 +955,13 @@ export default function NewChatPage() {
|
|||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
header={
|
||||
<ChatHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
thread={currentThread}
|
||||
onThreadVisibilityChange={handleVisibilityChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||
|
|
@ -23,28 +24,28 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
interface SettingsNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const settingsNavItems: SettingsNavItem[] = [
|
||||
{
|
||||
id: "models",
|
||||
label: "Agent Configs",
|
||||
description: "LLM models with prompts & citations",
|
||||
labelKey: "nav_agent_configs",
|
||||
descriptionKey: "nav_agent_configs_desc",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
id: "roles",
|
||||
label: "Role Assignments",
|
||||
description: "Assign configs to agent roles",
|
||||
labelKey: "nav_role_assignments",
|
||||
descriptionKey: "nav_role_assignments_desc",
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
id: "prompts",
|
||||
label: "System Instructions",
|
||||
description: "SearchSpace-wide AI instructions",
|
||||
labelKey: "nav_system_instructions",
|
||||
descriptionKey: "nav_system_instructions_desc",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
];
|
||||
|
|
@ -62,6 +63,8 @@ function SettingsSidebar({
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations("searchSpaceSettings");
|
||||
|
||||
const handleNavClick = (sectionId: string) => {
|
||||
onSectionChange(sectionId);
|
||||
onClose(); // Close sidebar on mobile after selection
|
||||
|
|
@ -94,22 +97,28 @@ function SettingsSidebar({
|
|||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
{/* Header with back button */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBackToApp}
|
||||
className="flex-1 justify-start gap-3 h-11 px-3 hover:bg-muted group"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">Back to app</span>
|
||||
</Button>
|
||||
{/* Mobile close button */}
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
{/* Header with title */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBackToApp}
|
||||
className="justify-start gap-3 h-11 px-3 hover:bg-muted group"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{t("back_to_app")}</span>
|
||||
</Button>
|
||||
{/* Mobile close button */}
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Settings Title */}
|
||||
<div className="px-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
|
|
@ -159,9 +168,11 @@ function SettingsSidebar({
|
|||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate">
|
||||
{t(item.descriptionKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate">{item.description}</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
|
|
@ -175,11 +186,6 @@ function SettingsSidebar({
|
|||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
|
@ -194,6 +200,7 @@ function SettingsContent({
|
|||
searchSpaceId: number;
|
||||
onMenuClick: () => void;
|
||||
}) {
|
||||
const t = useTranslations("searchSpaceSettings");
|
||||
const activeItem = settingsNavItems.find((item) => item.id === activeSection);
|
||||
const Icon = activeItem?.icon || Settings;
|
||||
|
||||
|
|
@ -236,7 +243,7 @@ function SettingsContent({
|
|||
</motion.div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
|
||||
{activeItem?.label}
|
||||
{activeItem ? t(activeItem.labelKey) : ""}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,20 +75,27 @@ function UserSettingsSidebar({
|
|||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBackToApp}
|
||||
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
|
||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{t("back_to_app")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
{/* Header with title */}
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBackToApp}
|
||||
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
|
||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{t("back_to_app")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Settings Title */}
|
||||
<div className="px-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
||||
|
|
@ -153,10 +160,6 @@ function UserSettingsSidebar({
|
|||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue