mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add documentsSidebarOpenAtom for managing sidebar state and integrate it into LayoutDataProvider and Composer components
This commit is contained in:
parent
86a9512021
commit
95a0e35393
3 changed files with 24 additions and 299 deletions
|
|
@ -18,19 +18,17 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
Dot,
|
||||
DownloadIcon,
|
||||
FileWarning,
|
||||
Paperclip,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
|
|
@ -63,11 +61,9 @@ import {
|
|||
} from "@/components/new-chat/document-mention-picker";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Placeholder texts that cycle in new chats when input is empty */
|
||||
|
|
@ -80,23 +76,6 @@ const CYCLING_PLACEHOLDERS = [
|
|||
"Check if this week's Slack messages reference any GitHub issues.",
|
||||
];
|
||||
|
||||
const CHAT_UPLOAD_ACCEPT =
|
||||
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
|
||||
|
||||
const CHAT_MAX_FILES = 10;
|
||||
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
|
||||
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
|
||||
|
||||
type UploadState = "pending" | "processing" | "ready" | "failed";
|
||||
|
||||
interface UploadedMentionDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: Document["document_type"];
|
||||
state: UploadState;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface ThreadProps {
|
||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||
header?: React.ReactNode;
|
||||
|
|
@ -252,14 +231,8 @@ const Composer: FC = () => {
|
|||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
|
||||
Record<number, UploadedMentionDoc>
|
||||
>({});
|
||||
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const isFileDialogOpenRef = useRef(false);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
|
|
@ -401,28 +374,9 @@ const Composer: FC = () => {
|
|||
[showDocumentPopover]
|
||||
);
|
||||
|
||||
const uploadedMentionedDocs = useMemo(
|
||||
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
|
||||
[mentionedDocuments, uploadedMentionDocs]
|
||||
);
|
||||
|
||||
const blockingUploadedMentions = useMemo(
|
||||
() =>
|
||||
uploadedMentionedDocs.filter((doc) => {
|
||||
const state = uploadedMentionDocs[doc.id]?.state;
|
||||
return state === "pending" || state === "processing" || state === "failed";
|
||||
}),
|
||||
[uploadedMentionedDocs, uploadedMentionDocs]
|
||||
);
|
||||
|
||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (
|
||||
isThreadRunning ||
|
||||
isBlockedByOtherUser ||
|
||||
isUploadingDocs ||
|
||||
blockingUploadedMentions.length > 0
|
||||
) {
|
||||
if (isThreadRunning || isBlockedByOtherUser) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
|
|
@ -438,8 +392,6 @@ const Composer: FC = () => {
|
|||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
isUploadingDocs,
|
||||
blockingUploadedMentions.length,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
|
|
@ -460,11 +412,6 @@ const Composer: FC = () => {
|
|||
});
|
||||
return updated;
|
||||
});
|
||||
setUploadedMentionDocs((prev) => {
|
||||
if (!(docId in prev)) return prev;
|
||||
const { [docId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
|
@ -503,168 +450,6 @@ const Composer: FC = () => {
|
|||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
const refreshUploadedDocStatuses = useCallback(
|
||||
async (documentIds: number[]) => {
|
||||
if (!search_space_id || documentIds.length === 0) return;
|
||||
const statusResponse = await documentsApiService.getDocumentsStatus({
|
||||
queryParams: {
|
||||
search_space_id: Number(search_space_id),
|
||||
document_ids: documentIds,
|
||||
},
|
||||
});
|
||||
|
||||
setUploadedMentionDocs((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of statusResponse.items) {
|
||||
next[item.id] = {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
document_type: item.document_type,
|
||||
state: item.status.state,
|
||||
reason: item.status.reason,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
handleDocumentsMention(
|
||||
statusResponse.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
document_type: item.document_type,
|
||||
}))
|
||||
);
|
||||
},
|
||||
[search_space_id, handleDocumentsMention]
|
||||
);
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
if (isFileDialogOpenRef.current) return;
|
||||
isFileDialogOpenRef.current = true;
|
||||
uploadInputRef.current?.click();
|
||||
// Reset after a delay to handle cancellation (which doesn't fire the change event).
|
||||
setTimeout(() => {
|
||||
isFileDialogOpenRef.current = false;
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const handleUploadInputChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
isFileDialogOpenRef.current = false;
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
if (files.length === 0 || !search_space_id) return;
|
||||
|
||||
if (files.length > CHAT_MAX_FILES) {
|
||||
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
|
||||
toast.error(
|
||||
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
totalSize += file.size;
|
||||
}
|
||||
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
|
||||
toast.error(
|
||||
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingDocs(true);
|
||||
try {
|
||||
const uploadResponse = await documentsApiService.uploadDocument({
|
||||
files,
|
||||
search_space_id: Number(search_space_id),
|
||||
});
|
||||
const uploadedIds = uploadResponse.document_ids ?? [];
|
||||
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
|
||||
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
|
||||
if (idsToMention.length === 0) {
|
||||
toast.warning("No documents were created or matched from selected files.");
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshUploadedDocStatuses(idsToMention);
|
||||
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
|
||||
toast.success(
|
||||
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
|
||||
);
|
||||
} else if (uploadedIds.length > 0) {
|
||||
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
|
||||
} else {
|
||||
toast.success(
|
||||
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
toast.error(`Upload failed: ${message}`);
|
||||
} finally {
|
||||
setIsUploadingDocs(false);
|
||||
}
|
||||
},
|
||||
[search_space_id, refreshUploadedDocStatuses]
|
||||
);
|
||||
|
||||
// Poll status for uploaded mentioned documents until all are ready or removed.
|
||||
useEffect(() => {
|
||||
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
|
||||
const needsPolling = trackedIds.some((id) => {
|
||||
const state = uploadedMentionDocs[id]?.state;
|
||||
return state === "pending" || state === "processing";
|
||||
});
|
||||
if (!needsPolling) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshUploadedDocStatuses(trackedIds).catch((error) => {
|
||||
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
|
||||
|
||||
// Push upload status directly onto mention chips (instead of separate status rows).
|
||||
useEffect(() => {
|
||||
for (const doc of uploadedMentionedDocs) {
|
||||
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
|
||||
const statusLabel =
|
||||
state === "ready"
|
||||
? null
|
||||
: state === "failed"
|
||||
? "failed"
|
||||
: state === "processing"
|
||||
? "indexing"
|
||||
: "queued";
|
||||
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
|
||||
}
|
||||
}, [uploadedMentionedDocs, uploadedMentionDocs]);
|
||||
|
||||
// Prune upload status entries that are no longer mentioned in the composer.
|
||||
useEffect(() => {
|
||||
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
|
||||
setUploadedMentionDocs((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<number, UploadedMentionDoc> = {};
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
const id = Number(key);
|
||||
if (activeIds.has(id)) {
|
||||
next[id] = value;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [mentionedDocuments]);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
|
|
@ -688,15 +473,6 @@ const Composer: FC = () => {
|
|||
className="min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={CHAT_UPLOAD_ACCEPT}
|
||||
onChange={handleUploadInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
||||
{showDocumentPopover &&
|
||||
typeof document !== "undefined" &&
|
||||
|
|
@ -724,12 +500,6 @@ const Composer: FC = () => {
|
|||
)}
|
||||
<ComposerAction
|
||||
isBlockedByOtherUser={isBlockedByOtherUser}
|
||||
onUploadClick={handleUploadClick}
|
||||
isUploadingDocs={isUploadingDocs}
|
||||
blockingUploadedMentionsCount={blockingUploadedMentions.length}
|
||||
hasFailedUploadedMentions={blockingUploadedMentions.some(
|
||||
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
|
|
@ -738,29 +508,20 @@ const Composer: FC = () => {
|
|||
|
||||
interface ComposerActionProps {
|
||||
isBlockedByOtherUser?: boolean;
|
||||
onUploadClick: () => void;
|
||||
isUploadingDocs: boolean;
|
||||
blockingUploadedMentionsCount: number;
|
||||
hasFailedUploadedMentions: boolean;
|
||||
}
|
||||
|
||||
const ComposerAction: FC<ComposerActionProps> = ({
|
||||
isBlockedByOtherUser = false,
|
||||
onUploadClick,
|
||||
isUploadingDocs,
|
||||
blockingUploadedMentionsCount,
|
||||
hasFailedUploadedMentions,
|
||||
}) => {
|
||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||
|
||||
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
|
||||
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||
|
||||
// Check if a model is configured
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
|
@ -770,8 +531,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||
|
||||
// Check if the configured model actually exists
|
||||
// Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs
|
||||
if (agentLlmId <= 0) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
|
|
@ -781,57 +540,26 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
const isSendDisabled =
|
||||
isComposerEmpty ||
|
||||
!hasModelConfigured ||
|
||||
isBlockedByOtherUser ||
|
||||
isUploadingDocs ||
|
||||
blockingUploadedMentionsCount > 0;
|
||||
isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
isUploadingDocs ? (
|
||||
"Uploading documents..."
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">Upload and mention files</span>
|
||||
<span className="text-xs text-muted-foreground flex items-center">
|
||||
Max 10 files <Dot className="size-3" /> 50 MB each
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
tooltip="Upload"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Upload files"
|
||||
onClick={onUploadClick}
|
||||
disabled={isUploadingDocs}
|
||||
aria-label="Open documents"
|
||||
onClick={() => setDocumentsSidebarOpen(true)}
|
||||
>
|
||||
{isUploadingDocs ? (
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
) : (
|
||||
<Paperclip className="size-4" />
|
||||
)}
|
||||
<PlusIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
<ConnectorIndicator />
|
||||
</div>
|
||||
|
||||
{blockingUploadedMentionsCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
|
||||
<span>
|
||||
{hasFailedUploadedMentions
|
||||
? "Remove or retry failed uploads"
|
||||
: "Waiting for uploaded files to finish indexing"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning when no model is configured */}
|
||||
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
|
||||
{!hasModelConfigured && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
|
|
@ -844,17 +572,11 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
tooltip={
|
||||
isBlockedByOtherUser
|
||||
? "Wait for AI to finish responding"
|
||||
: hasFailedUploadedMentions
|
||||
? "Remove or retry failed uploads before sending"
|
||||
: blockingUploadedMentionsCount > 0
|
||||
? "Waiting for uploaded files to finish indexing"
|
||||
: isUploadingDocs
|
||||
? "Uploading documents..."
|
||||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue