mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/local-folder-sync
This commit is contained in:
commit
62b44889d1
66 changed files with 3359 additions and 2626 deletions
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
||||
|
|
@ -26,8 +25,6 @@ const TokenHandler = ({
|
|||
tokenParamName = "token",
|
||||
storageKey = "surfsense_bearer_token",
|
||||
}: TokenHandlerProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Always show loading for this component - spinner animation won't reset
|
||||
useGlobalLoadingEffect(true);
|
||||
|
||||
|
|
@ -35,9 +32,13 @@ const TokenHandler = ({
|
|||
// Only run on client-side
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Get tokens from URL parameters
|
||||
const token = searchParams.get(tokenParamName);
|
||||
const refreshToken = searchParams.get("refresh_token");
|
||||
// Read tokens from URL at mount time — no subscription needed.
|
||||
// TokenHandler only runs once after an auth redirect, so a stale read
|
||||
// is impossible and useSearchParams() would add a pointless subscription.
|
||||
// (Vercel Best Practice: rerender-defer-reads 5.2)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get(tokenParamName);
|
||||
const refreshToken = params.get("refresh_token");
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
|
|
@ -74,7 +75,7 @@ const TokenHandler = ({
|
|||
window.location.href = redirectPath;
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath]);
|
||||
}, [tokenParamName, storageKey, redirectPath]);
|
||||
|
||||
// Return null - the global provider handles the loading UI
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -47,7 +46,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
(_props, ref) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||
useAtomValue(currentUserAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
|
|
|
|||
|
|
@ -834,6 +834,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
|
||||
const { data: agentTools } = useAtomValue(agentToolsAtom);
|
||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||
const disabledToolsSet = useMemo(() => new Set(disabledTools), [disabledTools]);
|
||||
const toggleTool = useSetAtom(toggleToolAtom);
|
||||
const setDisabledTools = useSetAtom(disabledToolsAtom);
|
||||
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
|
||||
|
|
@ -846,18 +847,18 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
|
||||
const toggleToolGroup = useCallback(
|
||||
(toolNames: string[]) => {
|
||||
const allDisabled = toolNames.every((name) => disabledTools.includes(name));
|
||||
const allDisabled = toolNames.every((name) => disabledToolsSet.has(name));
|
||||
if (allDisabled) {
|
||||
setDisabledTools((prev) => prev.filter((t) => !toolNames.includes(t)));
|
||||
} else {
|
||||
setDisabledTools((prev) => [...new Set([...prev, ...toolNames])]);
|
||||
}
|
||||
},
|
||||
[disabledTools, setDisabledTools]
|
||||
[disabledToolsSet, setDisabledTools]
|
||||
);
|
||||
|
||||
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
|
||||
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
|
||||
const isWebSearchEnabled = hasWebSearchTool && !disabledToolsSet.has("web_search");
|
||||
const filteredTools = useMemo(
|
||||
() => agentTools?.filter((t) => t.name !== "web_search"),
|
||||
[agentTools]
|
||||
|
|
@ -957,7 +958,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
const isDisabled = disabledToolsSet.has(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
return (
|
||||
<div
|
||||
|
|
@ -989,7 +990,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const iconKey = group.connectorIcon ?? "";
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
|
||||
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
|
||||
return (
|
||||
<div
|
||||
key={group.label}
|
||||
|
|
@ -1078,7 +1079,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
const isDisabled = disabledToolsSet.has(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
const row = (
|
||||
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
||||
|
|
@ -1115,7 +1116,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const iconKey = group.connectorIcon ?? "";
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
|
||||
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
|
||||
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
|
||||
const row = (
|
||||
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ function convertDisplayToData(displayContent: string, mentions: InsertedMention[
|
|||
|
||||
const sortedMentions = [...mentions].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sortedMentions) {
|
||||
const displayPattern = new RegExp(
|
||||
`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`,
|
||||
"g"
|
||||
);
|
||||
const dataFormat = `@[${mention.id}]`;
|
||||
result = result.replace(displayPattern, dataFormat);
|
||||
const mentionPatterns = sortedMentions.map((mention) => ({
|
||||
pattern: new RegExp(`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`, "g"),
|
||||
dataFormat: `@[${mention.id}]`,
|
||||
}));
|
||||
|
||||
for (const { pattern, dataFormat } of mentionPatterns) {
|
||||
pattern.lastIndex = 0; // reset global regex state
|
||||
result = result.replace(pattern, dataFormat);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { FileQuestionMark, RefreshCw, XIcon } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Loader2, RefreshCw, XIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const PlateEditor = dynamic(
|
||||
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
|
||||
);
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
source_markdown: string;
|
||||
content_size_bytes?: number;
|
||||
chunk_count?: number;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
function EditorPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
|
|
@ -33,19 +54,6 @@ function EditorPanelSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
const PlateEditor = dynamic(
|
||||
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
source_markdown: string;
|
||||
}
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
|
||||
export function EditorPanelContent({
|
||||
documentId,
|
||||
|
|
@ -62,6 +70,7 @@ export function EditorPanelContent({
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||
const markdownRef = useRef<string>("");
|
||||
|
|
@ -69,6 +78,8 @@ export function EditorPanelContent({
|
|||
const changeCountRef = useRef(0);
|
||||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
|
@ -86,10 +97,12 @@ export function EditorPanelContent({
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET" }
|
||||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
);
|
||||
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
||||
|
||||
const response = await authenticatedFetch(url.toString(), { method: "GET" });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
|
|
@ -175,7 +188,7 @@ export function EditorPanelContent({
|
|||
}, [documentId, searchSpaceId]);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")
|
||||
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "") && !isLargeDocument
|
||||
: false;
|
||||
|
||||
return (
|
||||
|
|
@ -223,6 +236,59 @@ export function EditorPanelContent({
|
|||
<p className="text-sm text-muted-foreground">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLargeDocument ? (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const match = disposition?.match(/filename="(.+)"/);
|
||||
a.download = match?.[1] ?? `${editorDoc.title || "document"}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download document");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
{downloading ? "Preparing..." : "Download .md"}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
) : isEditableType ? (
|
||||
<PlateEditor
|
||||
key={documentId}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
<div
|
||||
key={thread.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"sidebar-item-lazy group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ export function AllSharedChatsSidebarContent({
|
|||
<div
|
||||
key={thread.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"sidebar-item-lazy group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
|
|
@ -289,15 +289,14 @@ export function InboxSidebarContent({
|
|||
[activeFilter]
|
||||
);
|
||||
|
||||
// Defer non-urgent list updates so the search input stays responsive.
|
||||
// The deferred snapshot lags one render behind the live value intentionally.
|
||||
const deferredTabItems = useDeferredValue(activeSource.items);
|
||||
const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []);
|
||||
|
||||
// Two data paths: search mode (API) or default (per-tab data source)
|
||||
const filteredItems = useMemo(() => {
|
||||
let tabItems: InboxItem[];
|
||||
|
||||
if (isSearchMode) {
|
||||
tabItems = searchResponse?.items ?? [];
|
||||
} else {
|
||||
tabItems = activeSource.items;
|
||||
}
|
||||
const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems;
|
||||
|
||||
let result = tabItems;
|
||||
if (activeFilter !== "all") {
|
||||
|
|
@ -310,8 +309,8 @@ export function InboxSidebarContent({
|
|||
return result;
|
||||
}, [
|
||||
isSearchMode,
|
||||
searchResponse,
|
||||
activeSource.items,
|
||||
deferredSearchItems,
|
||||
deferredTabItems,
|
||||
activeTab,
|
||||
activeFilter,
|
||||
selectedSource,
|
||||
|
|
@ -920,6 +919,7 @@ export function InboxSidebarContent({
|
|||
"transition-colors cursor-pointer",
|
||||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}
|
||||
>
|
||||
{isMobile ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { FileQuestionMark, PenLine, RefreshCw } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Loader2, PenLine, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
interface DocumentContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
source_markdown: string;
|
||||
content_size_bytes?: number;
|
||||
chunk_count?: number;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
function DocumentSkeleton() {
|
||||
|
|
@ -49,11 +55,14 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||
const markdownRef = useRef<string>("");
|
||||
const initialLoadDone = useRef(false);
|
||||
const changeCountRef = useRef(0);
|
||||
|
||||
const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
|
@ -72,10 +81,12 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET" }
|
||||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
);
|
||||
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
||||
|
||||
const response = await authenticatedFetch(url.toString(), { method: "GET" });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
|
|
@ -193,9 +204,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
);
|
||||
}
|
||||
|
||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
|
||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "") && !isLargeDocument;
|
||||
|
||||
if (isEditing) {
|
||||
if (isEditing && !isLargeDocument) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
|
||||
|
|
@ -256,7 +267,62 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||
<MarkdownViewer content={doc.source_markdown} />
|
||||
{isLargeDocument ? (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{doc.chunk_count ?? 0} chunks). Showing a preview below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const match = disposition?.match(/filename="(.+)"/);
|
||||
a.download = match?.[1] ?? `${doc.title || "document"}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download document");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
{downloading ? "Preparing..." : "Download .md"}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<MarkdownViewer content={doc.source_markdown} />
|
||||
</>
|
||||
) : (
|
||||
<MarkdownViewer content={doc.source_markdown} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const math = createMathPlugin({
|
|||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,8 +80,10 @@ function convertLatexDelimiters(content: string): string {
|
|||
return content;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
|
||||
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(content));
|
||||
export function MarkdownViewer({ content, className, maxLength }: MarkdownViewerProps) {
|
||||
const isTruncated = maxLength != null && content.length > maxLength;
|
||||
const displayContent = isTruncated ? content.slice(0, maxLength) : content;
|
||||
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(displayContent));
|
||||
const components: StreamdownProps["components"] = {
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="my-2" {...props}>
|
||||
|
|
@ -171,6 +174,12 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
|
|||
>
|
||||
{processedContent}
|
||||
</Streamdown>
|
||||
{isTruncated && (
|
||||
<p className="mt-4 text-sm text-muted-foreground italic">
|
||||
Content truncated ({Math.round(content.length / 1024)}KB total). Showing first{" "}
|
||||
{Math.round(maxLength / 1024)}KB.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
|
|
@ -81,6 +82,9 @@ export const DocumentMentionPicker = forwardRef<
|
|||
// Debounced search value to minimize API calls and prevent race conditions
|
||||
const search = externalSearch;
|
||||
const debouncedSearch = useDebounced(search, DEBOUNCE_MS);
|
||||
// Deferred snapshot of debouncedSearch — client-side filtering uses this so it
|
||||
// is treated as a non-urgent update, keeping the input responsive.
|
||||
const deferredSearch = useDeferredValue(debouncedSearch);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -245,12 +249,14 @@ export const DocumentMentionPicker = forwardRef<
|
|||
* Client-side filtering for single character searches.
|
||||
* Filters cached documents locally for instant feedback without additional API calls.
|
||||
* Server-side search is reserved for 2+ character queries to leverage database indexing.
|
||||
* Uses deferredSearch (a deferred snapshot of debouncedSearch) so this memo is treated
|
||||
* as non-urgent — React can interrupt it to keep the input responsive.
|
||||
*/
|
||||
const clientFilteredDocs = useMemo(() => {
|
||||
if (!isSingleCharSearch) return null;
|
||||
const searchLower = debouncedSearch.trim().toLowerCase();
|
||||
const searchLower = deferredSearch.trim().toLowerCase();
|
||||
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower));
|
||||
}, [isSingleCharSearch, debouncedSearch, accumulatedDocuments]);
|
||||
}, [isSingleCharSearch, deferredSearch, accumulatedDocuments]);
|
||||
|
||||
// Select data source based on search length: client-filtered for single char, server results for 2+
|
||||
const actualDocuments = isSingleCharSearch ? (clientFilteredDocs ?? []) : accumulatedDocuments;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Plus, Zap } from "lucide-react";
|
|||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
|
|
@ -41,15 +42,19 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
const shouldScrollRef = useRef(false);
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
|
||||
// Defer the search value so filtering is non-urgent and the input stays responsive
|
||||
const deferredSearch = useDeferredValue(externalSearch);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = prompts ?? [];
|
||||
if (!externalSearch) return list;
|
||||
return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
|
||||
}, [prompts, externalSearch]);
|
||||
if (!deferredSearch) return list;
|
||||
return list.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()));
|
||||
}, [prompts, deferredSearch]);
|
||||
|
||||
const prevSearchRef = useRef(externalSearch);
|
||||
if (prevSearchRef.current !== externalSearch) {
|
||||
prevSearchRef.current = externalSearch;
|
||||
// Reset highlight when the deferred (filtered) search changes
|
||||
const prevSearchRef = useRef(deferredSearch);
|
||||
if (prevSearchRef.current !== deferredSearch) {
|
||||
prevSearchRef.current = deferredSearch;
|
||||
if (highlightedIndex !== 0) {
|
||||
setHighlightedIndex(0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, ChevronDown, ExternalLink, FileQuestionMark, FileText, Hash, Sparkles, X } from "lucide-react";
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
|
|
@ -10,7 +21,6 @@ import { createPortal } from "react-dom";
|
|||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
|
|
@ -48,7 +58,8 @@ const formatDocumentType = (type: string) => {
|
|||
// which break auto-scroll functionality
|
||||
interface ChunkCardProps {
|
||||
chunk: { id: number; content: string };
|
||||
index: number;
|
||||
localIndex: number;
|
||||
chunkNumber: number;
|
||||
totalChunks: number;
|
||||
isCited: boolean;
|
||||
isActive: boolean;
|
||||
|
|
@ -56,51 +67,52 @@ interface ChunkCardProps {
|
|||
}
|
||||
|
||||
const ChunkCard = memo(
|
||||
forwardRef<HTMLDivElement, ChunkCardProps>(({ chunk, index, totalChunks, isCited }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-chunk-index={index}
|
||||
className={cn(
|
||||
"group relative rounded-2xl border-2 transition-all duration-300",
|
||||
isCited
|
||||
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
|
||||
: "bg-card border-border/50 hover:border-border hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
{/* Cited indicator glow effect */}
|
||||
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
|
||||
isCited
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">of {totalChunks} chunks</span>
|
||||
</div>
|
||||
{isCited && (
|
||||
<Badge variant="default" className="gap-1.5 px-3 py-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Cited Source
|
||||
</Badge>
|
||||
forwardRef<HTMLDivElement, ChunkCardProps>(
|
||||
({ chunk, localIndex, chunkNumber, totalChunks, isCited }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-chunk-index={localIndex}
|
||||
className={cn(
|
||||
"group relative rounded-2xl border-2 transition-all duration-300",
|
||||
isCited
|
||||
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
|
||||
: "bg-card border-border/50 hover:border-border hover:shadow-md"
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 overflow-hidden">
|
||||
<MarkdownViewer content={chunk.content} />
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
|
||||
isCited
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{chunkNumber}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Chunk {chunkNumber} of {totalChunks}
|
||||
</span>
|
||||
</div>
|
||||
{isCited && (
|
||||
<Badge variant="default" className="gap-1.5 px-3 py-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Cited Source
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 overflow-hidden">
|
||||
<MarkdownViewer content={chunk.content} maxLength={100_000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
ChunkCard.displayName = "ChunkCard";
|
||||
|
||||
|
|
@ -118,7 +130,6 @@ export function SourceDetailPanel({
|
|||
const t = useTranslations("dashboard");
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
|
||||
|
|
@ -140,20 +151,93 @@ export function SourceDetailPanel({
|
|||
if (isDocsChunk) {
|
||||
return documentsApiService.getSurfsenseDocByChunk(chunkId);
|
||||
}
|
||||
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
|
||||
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 5 });
|
||||
},
|
||||
enabled: !!chunkId && open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const totalChunks =
|
||||
documentData && "total_chunks" in documentData
|
||||
? (documentData.total_chunks ?? documentData.chunks.length)
|
||||
: (documentData?.chunks?.length ?? 0);
|
||||
const [beforeChunks, setBeforeChunks] = useState<
|
||||
Array<{ id: number; content: string; created_at: string }>
|
||||
>([]);
|
||||
const [afterChunks, setAfterChunks] = useState<
|
||||
Array<{ id: number; content: string; created_at: string }>
|
||||
>([]);
|
||||
const [loadingBefore, setLoadingBefore] = useState(false);
|
||||
const [loadingAfter, setLoadingAfter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBeforeChunks([]);
|
||||
setAfterChunks([]);
|
||||
}, [chunkId, open]);
|
||||
|
||||
const chunkStartIndex =
|
||||
documentData && "chunk_start_index" in documentData ? (documentData.chunk_start_index ?? 0) : 0;
|
||||
const initialChunks = documentData?.chunks ?? [];
|
||||
const allChunks = [...beforeChunks, ...initialChunks, ...afterChunks];
|
||||
const absoluteStart = chunkStartIndex - beforeChunks.length;
|
||||
const absoluteEnd = chunkStartIndex + initialChunks.length + afterChunks.length;
|
||||
const canLoadBefore = absoluteStart > 0;
|
||||
const canLoadAfter = absoluteEnd < totalChunks;
|
||||
|
||||
const EXPAND_SIZE = 10;
|
||||
|
||||
const loadBefore = useCallback(async () => {
|
||||
if (!documentData || !("search_space_id" in documentData) || !canLoadBefore) return;
|
||||
setLoadingBefore(true);
|
||||
try {
|
||||
const count = Math.min(EXPAND_SIZE, absoluteStart);
|
||||
const result = await documentsApiService.getDocumentChunks({
|
||||
document_id: documentData.id,
|
||||
page: 0,
|
||||
page_size: count,
|
||||
start_offset: absoluteStart - count,
|
||||
});
|
||||
const existingIds = new Set(allChunks.map((c) => c.id));
|
||||
const newChunks = result.items
|
||||
.filter((c) => !existingIds.has(c.id))
|
||||
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
|
||||
setBeforeChunks((prev) => [...newChunks, ...prev]);
|
||||
} catch (err) {
|
||||
console.error("Failed to load earlier chunks:", err);
|
||||
} finally {
|
||||
setLoadingBefore(false);
|
||||
}
|
||||
}, [documentData, absoluteStart, canLoadBefore, allChunks]);
|
||||
|
||||
const loadAfter = useCallback(async () => {
|
||||
if (!documentData || !("search_space_id" in documentData) || !canLoadAfter) return;
|
||||
setLoadingAfter(true);
|
||||
try {
|
||||
const result = await documentsApiService.getDocumentChunks({
|
||||
document_id: documentData.id,
|
||||
page: 0,
|
||||
page_size: EXPAND_SIZE,
|
||||
start_offset: absoluteEnd,
|
||||
});
|
||||
const existingIds = new Set(allChunks.map((c) => c.id));
|
||||
const newChunks = result.items
|
||||
.filter((c) => !existingIds.has(c.id))
|
||||
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
|
||||
setAfterChunks((prev) => [...prev, ...newChunks]);
|
||||
} catch (err) {
|
||||
console.error("Failed to load later chunks:", err);
|
||||
} finally {
|
||||
setLoadingAfter(false);
|
||||
}
|
||||
}, [documentData, absoluteEnd, canLoadAfter, allChunks]);
|
||||
|
||||
const isDirectRenderSource =
|
||||
sourceType === "TAVILY_API" ||
|
||||
sourceType === "LINKUP_API" ||
|
||||
sourceType === "SEARXNG_API" ||
|
||||
sourceType === "BAIDU_SEARCH_API";
|
||||
|
||||
// Find cited chunk index
|
||||
const citedChunkIndex = documentData?.chunks?.findIndex((chunk) => chunk.id === chunkId) ?? -1;
|
||||
const citedChunkIndex = allChunks.findIndex((chunk) => chunk.id === chunkId);
|
||||
|
||||
// Simple scroll function that scrolls to a chunk by index
|
||||
const scrollToChunkByIndex = useCallback(
|
||||
|
|
@ -336,10 +420,10 @@ export function SourceDetailPanel({
|
|||
{documentData && "document_type" in documentData
|
||||
? formatDocumentType(documentData.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
{documentData?.chunks && (
|
||||
{totalChunks > 0 && (
|
||||
<span className="ml-2">
|
||||
• {documentData.chunks.length} chunk
|
||||
{documentData.chunks.length !== 1 ? "s" : ""}
|
||||
• {totalChunks} chunk{totalChunks !== 1 ? "s" : ""}
|
||||
{allChunks.length < totalChunks && ` (showing ${allChunks.length})`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -450,7 +534,7 @@ export function SourceDetailPanel({
|
|||
{!isDirectRenderSource && documentData && (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Chunk Navigation Sidebar */}
|
||||
{documentData.chunks.length > 1 && (
|
||||
{allChunks.length > 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
|
|
@ -459,7 +543,8 @@ export function SourceDetailPanel({
|
|||
>
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div className="p-2 pt-3 flex flex-col gap-1.5">
|
||||
{documentData.chunks.map((chunk, idx) => {
|
||||
{allChunks.map((chunk, idx) => {
|
||||
const absNum = absoluteStart + idx + 1;
|
||||
const isCited = chunk.id === chunkId;
|
||||
const isActive = activeChunkIndex === idx;
|
||||
return (
|
||||
|
|
@ -478,9 +563,9 @@ export function SourceDetailPanel({
|
|||
? "bg-muted text-foreground"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={isCited ? `Chunk ${idx + 1} (Cited)` : `Chunk ${idx + 1}`}
|
||||
title={isCited ? `Chunk ${absNum} (Cited)` : `Chunk ${absNum}`}
|
||||
>
|
||||
{idx + 1}
|
||||
{absNum}
|
||||
{isCited && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
|
||||
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
|
||||
|
|
@ -524,44 +609,11 @@ export function SourceDetailPanel({
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Summary Collapsible */}
|
||||
{documentData.content && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between p-5 rounded-2xl bg-linear-to-r from-muted/50 to-muted/30 border hover:from-muted/70 hover:to-muted/50 transition-all duration-200">
|
||||
<span className="font-semibold flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Document Summary
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: summaryOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-3 p-5 bg-muted/20 rounded-2xl border"
|
||||
>
|
||||
<MarkdownViewer content={documentData.content} />
|
||||
</motion.div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Chunks Header */}
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Hash className="h-4 w-4" />
|
||||
Content Chunks
|
||||
Chunks {absoluteStart + 1}–{absoluteEnd} of {totalChunks}
|
||||
</h3>
|
||||
{citedChunkIndex !== -1 && (
|
||||
<Button
|
||||
|
|
@ -576,24 +628,70 @@ export function SourceDetailPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Load Earlier */}
|
||||
{canLoadBefore && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadBefore}
|
||||
disabled={loadingBefore}
|
||||
className="gap-2"
|
||||
>
|
||||
{loadingBefore ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{loadingBefore
|
||||
? "Loading..."
|
||||
: `Load ${Math.min(EXPAND_SIZE, absoluteStart)} earlier chunks`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunks */}
|
||||
<div className="space-y-4">
|
||||
{documentData.chunks.map((chunk, idx) => {
|
||||
{allChunks.map((chunk, idx) => {
|
||||
const isCited = chunk.id === chunkId;
|
||||
const chunkNumber = absoluteStart + idx + 1;
|
||||
return (
|
||||
<ChunkCard
|
||||
key={chunk.id}
|
||||
ref={isCited ? citedChunkRefCallback : undefined}
|
||||
chunk={chunk}
|
||||
index={idx}
|
||||
totalChunks={documentData.chunks.length}
|
||||
localIndex={idx}
|
||||
chunkNumber={chunkNumber}
|
||||
totalChunks={totalChunks}
|
||||
isCited={isCited}
|
||||
isActive={activeChunkIndex === idx}
|
||||
disableLayoutAnimation={documentData.chunks.length > 30}
|
||||
disableLayoutAnimation={allChunks.length > 30}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load Later */}
|
||||
{canLoadAfter && (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAfter}
|
||||
disabled={loadingAfter}
|
||||
className="gap-2"
|
||||
>
|
||||
{loadingAfter ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{loadingAfter
|
||||
? "Loading..."
|
||||
: `Load ${Math.min(EXPAND_SIZE, totalChunks - absoluteEnd)} later chunks`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Copy } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,7 +15,6 @@ interface PublicChatFooterProps {
|
|||
|
||||
export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const hasAutoCloned = useRef(false);
|
||||
|
||||
|
|
@ -36,9 +35,11 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
|||
}
|
||||
}, [shareToken, router]);
|
||||
|
||||
// Auto-trigger clone if user just logged in with action=clone
|
||||
// Auto-trigger clone if user just logged in with action=clone.
|
||||
// Read from window.location.search on mount — no subscription needed since
|
||||
// this is a one-time post-login check. (Vercel Best Practice: rerender-defer-reads 5.2)
|
||||
useEffect(() => {
|
||||
const action = searchParams.get("action");
|
||||
const action = new URLSearchParams(window.location.search).get("action");
|
||||
const token = getBearerToken();
|
||||
|
||||
// Only auto-clone once, if authenticated and action=clone is present
|
||||
|
|
@ -46,7 +47,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
|||
hasAutoCloned.current = true;
|
||||
triggerClone();
|
||||
}
|
||||
}, [searchParams, isCloning, triggerClone]);
|
||||
}, [isCloning, triggerClone]);
|
||||
|
||||
const handleCopyAndContinue = async () => {
|
||||
const token = getBearerToken();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useAtom } from "jotai";
|
|||
import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { type ChangeEvent, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
|
|
@ -59,6 +59,7 @@ const commonTypes = {
|
|||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
|
||||
"text/html": [".html", ".htm"],
|
||||
"text/csv": [".csv"],
|
||||
"text/tab-separated-values": [".tsv"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
"image/png": [".png"],
|
||||
"image/bmp": [".bmp"],
|
||||
|
|
@ -84,7 +85,6 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
|
|||
"application/rtf": [".rtf"],
|
||||
"application/xml": [".xml"],
|
||||
"application/epub+zip": [".epub"],
|
||||
"text/tab-separated-values": [".tsv"],
|
||||
"text/html": [".html", ".htm", ".web"],
|
||||
"image/gif": [".gif"],
|
||||
"image/svg+xml": [".svg"],
|
||||
|
|
@ -110,7 +110,6 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
|
|||
"application/vnd.ms-powerpoint": [".ppt"],
|
||||
"text/x-rst": [".rst"],
|
||||
"application/rtf": [".rtf"],
|
||||
"text/tab-separated-values": [".tsv"],
|
||||
"application/vnd.ms-excel": [".xls"],
|
||||
"application/xml": [".xml"],
|
||||
...audioFileTypes,
|
||||
|
|
@ -126,6 +125,9 @@ const MAX_FILES = 50;
|
|||
const MAX_TOTAL_SIZE_MB = 200;
|
||||
const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const MAX_FILE_SIZE_MB = 500;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const toggleRowClass = "flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3";
|
||||
|
||||
export function DocumentUploadTab({
|
||||
|
|
@ -141,6 +143,7 @@ export function DocumentUploadTab({
|
|||
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
|
||||
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
|
||||
const [watchFolder, setWatchFolder] = useState(true);
|
||||
|
|
@ -157,41 +160,48 @@ export function DocumentUploadTab({
|
|||
[acceptedFileTypes]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setSelectedFolder(null);
|
||||
const supportedExtensionsSet = useMemo(
|
||||
() => new Set(supportedExtensions.map((ext) => ext.toLowerCase())),
|
||||
[supportedExtensions]
|
||||
);
|
||||
|
||||
const addFiles = useCallback(
|
||||
(incoming: File[]) => {
|
||||
const oversized = incoming.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
|
||||
if (oversized.length > 0) {
|
||||
toast.error(t("file_too_large"), {
|
||||
description: t("file_too_large_desc", {
|
||||
name: oversized[0].name,
|
||||
maxMB: MAX_FILE_SIZE_MB,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const valid = incoming.filter((f) => f.size <= MAX_FILE_SIZE_BYTES);
|
||||
if (valid.length === 0) return;
|
||||
|
||||
setFiles((prev) => {
|
||||
const newEntries = acceptedFiles.map((f) => ({
|
||||
const newEntries = valid.map((f) => ({
|
||||
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
||||
file: f,
|
||||
}));
|
||||
const newFiles = [...prev, ...newEntries];
|
||||
|
||||
if (newFiles.length > MAX_FILES) {
|
||||
toast.error(t("max_files_exceeded"), {
|
||||
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0);
|
||||
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
|
||||
toast.error(t("max_size_exceeded"), {
|
||||
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
|
||||
return newFiles;
|
||||
return [...prev, ...newEntries];
|
||||
});
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setSelectedFolder(null);
|
||||
addFiles(acceptedFiles);
|
||||
},
|
||||
[addFiles]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: acceptedFileTypes,
|
||||
maxSize: 50 * 1024 * 1024,
|
||||
maxSize: MAX_FILE_SIZE_BYTES,
|
||||
noClick: isElectron,
|
||||
disabled: files.length >= MAX_FILES,
|
||||
});
|
||||
|
|
@ -245,6 +255,28 @@ export function DocumentUploadTab({
|
|||
setWatchFolder(true);
|
||||
}, []);
|
||||
|
||||
const handleFolderChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
|
||||
const folderFiles = Array.from(fileList).filter((f) => {
|
||||
const ext = f.name.includes(".") ? `.${f.name.split(".").pop()?.toLowerCase()}` : "";
|
||||
return ext !== "" && supportedExtensionsSet.has(ext);
|
||||
});
|
||||
|
||||
if (folderFiles.length === 0) {
|
||||
toast.error(t("no_supported_files_in_folder"));
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
addFiles(folderFiles);
|
||||
e.target.value = "";
|
||||
},
|
||||
[addFiles, supportedExtensionsSet, t]
|
||||
);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
|
|
@ -398,7 +430,7 @@ export function DocumentUploadTab({
|
|||
|
||||
return (
|
||||
<div className="space-y-2 w-full mx-auto">
|
||||
{/* Hidden file input for mobile browse */}
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
{...getInputProps()}
|
||||
ref={fileInputRef}
|
||||
|
|
@ -406,6 +438,16 @@ export function DocumentUploadTab({
|
|||
onClick={handleFileInputClick}
|
||||
/>
|
||||
|
||||
{/* Hidden folder input for web folder browsing */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFolderChange}
|
||||
multiple
|
||||
{...({ webkitdirectory: "", directory: "" } as React.InputHTMLAttributes<HTMLInputElement>)}
|
||||
/>
|
||||
|
||||
{/* MOBILE DROP ZONE */}
|
||||
<div className="sm:hidden">
|
||||
{hasContent ? (
|
||||
|
|
@ -447,6 +489,35 @@ export function DocumentUploadTab({
|
|||
{renderBrowseButton({ fullWidth: true })}
|
||||
</div>
|
||||
)}
|
||||
{!isElectron && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{t("browse_files")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
folderInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||
{t("browse_folder")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue