mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 21:02:40 +02:00
feat: various UI fixes, prompt optimizations, and allowing duplicate docs
- Updated `content_hash` in the `Document` model to remove global uniqueness, allowing identical content across different paths. - Enhanced `_create_document` function to handle path uniqueness and prevent session-poisoning from `IntegrityError`. - Added detailed comments for clarity on the changes and their implications. - Introduced new citation handling in the editor for improved user experience with citation jumps. - Updated package dependencies in the frontend for better functionality.
This commit is contained in:
parent
e6433f78c4
commit
b9a66cb417
26 changed files with 1540 additions and 852 deletions
|
|
@ -1,26 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { FileText } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingChunkHighlightAtom } from "@/atoms/document-viewer/pending-chunk-highlight.atom";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface InlineCitationProps {
|
||||
chunkId: number;
|
||||
isDocsChunk?: boolean;
|
||||
}
|
||||
|
||||
const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
|
||||
|
||||
/**
|
||||
* Inline citation for knowledge-base chunks (numeric chunk IDs).
|
||||
* Renders a clickable badge showing the actual chunk ID that opens the SourceDetailPanel.
|
||||
* Negative chunk IDs indicate anonymous/synthetic uploads and render as a static badge.
|
||||
* Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
|
||||
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
|
||||
* a static "doc" pill (anonymous/synthetic uploads).
|
||||
*
|
||||
* Numeric KB chunks: clicking resolves the parent document via
|
||||
* `getDocumentByChunk`, opens the document in the right side panel (alongside
|
||||
* the chat — does not replace it), and stages the cited chunk text in
|
||||
* `pendingChunkHighlightAtom` so `EditorPanelContent` can scroll to and softly
|
||||
* highlight it inside the rendered markdown.
|
||||
*
|
||||
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
|
||||
* lazily fetches and previews the cited chunk inline, since those docs aren't
|
||||
* indexed into the user's search space and have no tab to open.
|
||||
*/
|
||||
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (chunkId < 0) {
|
||||
return (
|
||||
<Tooltip>
|
||||
|
|
@ -38,26 +57,185 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
);
|
||||
}
|
||||
|
||||
if (isDocsChunk) {
|
||||
return <SurfsenseDocCitation chunkId={chunkId} />;
|
||||
}
|
||||
|
||||
return <NumericChunkCitation chunkId={chunkId} />;
|
||||
};
|
||||
|
||||
const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const setPendingHighlight = useSetAtom(pendingChunkHighlightAtom);
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (resolving) return;
|
||||
setResolving(true);
|
||||
console.log("[citation:click] start", { chunkId });
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
// Local key with explicit window. The shared `cacheKeys.documents.byChunk`
|
||||
// is window-agnostic (latent footgun); namespace the call to avoid
|
||||
// reusing a different-window cached result.
|
||||
queryKey: ["documents", "by-chunk", chunkId, "w0"] as const,
|
||||
queryFn: () =>
|
||||
documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 0 }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const cited = data.chunks.find((c) => c.id === chunkId) ?? data.chunks[0];
|
||||
console.log("[citation:click] fetched doc-by-chunk", {
|
||||
docId: data.id,
|
||||
docTitle: data.title,
|
||||
chunksReturned: data.chunks.length,
|
||||
citedChunkId: cited?.id,
|
||||
citedChunkContentLen: cited?.content?.length ?? 0,
|
||||
citedChunkPreview:
|
||||
cited?.content && cited.content.length > 120
|
||||
? `${cited.content.slice(0, 120)}…(+${cited.content.length - 120})`
|
||||
: (cited?.content ?? ""),
|
||||
});
|
||||
// Stage the highlight BEFORE opening the panel so `EditorPanelContent`
|
||||
// already sees the pending intent on its very first render — avoids a
|
||||
// "fetch → render → no-pending → next-tick render with pending" race.
|
||||
setPendingHighlight({
|
||||
documentId: data.id,
|
||||
chunkId,
|
||||
chunkText: cited?.content ?? "",
|
||||
});
|
||||
openEditorPanel({
|
||||
documentId: data.id,
|
||||
searchSpaceId: data.search_space_id,
|
||||
title: data.title,
|
||||
});
|
||||
console.log("[citation:click] staged highlight + opened editor panel", {
|
||||
documentId: data.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[citation:click] failed", err);
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't open cited document");
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [chunkId, openEditorPanel, queryClient, resolving, setPendingHighlight]);
|
||||
|
||||
return (
|
||||
<SourceDetailPanel
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
|
||||
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
|
||||
description=""
|
||||
url=""
|
||||
isDocsChunk={isDocsChunk}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={resolving}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:cursor-progress disabled:opacity-70"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
aria-label={`Jump to cited chunk ${chunkId}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
{resolving ? <Spinner size="xs" /> : chunkId}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
cancelClose();
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
closeTimerRef.current = null;
|
||||
}, POPOVER_HOVER_CLOSE_DELAY_MS);
|
||||
}, [cancelClose]);
|
||||
|
||||
useEffect(() => () => cancelClose(), [cancelClose]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
|
||||
queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
onFocus={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={scheduleClose}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm transition-colors hover:bg-primary/15 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
aria-label={`Show Surfsense documentation chunk ${chunkId}`}
|
||||
title="Surfsense documentation"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
doc
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{chunkId}
|
||||
</button>
|
||||
</SourceDetailPanel>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{data?.title ?? "Surfsense documentation"}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">Chunk #{chunkId}</p>
|
||||
</div>
|
||||
{data?.source && (
|
||||
<a
|
||||
href={data.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-primary/10"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Open
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto px-3 py-2 text-sm">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs">Loading…</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="py-4 text-xs text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load chunk"}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && !error && citedChunk?.content && (
|
||||
<MarkdownViewer content={citedChunk.content} maxLength={1500} />
|
||||
)}
|
||||
{!isLoading && !error && !citedChunk?.content && (
|
||||
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { FindReplacePlugin } from "@platejs/find-replace";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
Check,
|
||||
|
|
@ -14,17 +15,21 @@ import {
|
|||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingChunkHighlightAtom } from "@/atoms/document-viewer/pending-chunk-highlight.atom";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||
import type { PlateEditorInstance } from "@/components/editor/plate-editor";
|
||||
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
||||
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 { CITATION_HIGHLIGHT_CLASS } from "@/components/ui/search-highlight-node";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { buildCitationSearchCandidates } from "@/lib/citation-search";
|
||||
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
|
||||
|
||||
const PlateEditor = dynamic(
|
||||
|
|
@ -32,7 +37,10 @@ const PlateEditor = dynamic(
|
|||
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
|
||||
);
|
||||
|
||||
type CitationHighlightStatus = "exact" | "miss";
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
const CITATION_MAX_LENGTH = 16 * 1024 * 1024; // 16MB on-demand cap for citation jumps
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
|
|
@ -136,6 +144,61 @@ export function EditorPanelContent({
|
|||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
const isLocalFileMode = kind === "local_file";
|
||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||
|
||||
// --- Citation-jump highlight wiring ----------------------------------
|
||||
// `EditorPanelContent` is the consumer of `pendingChunkHighlightAtom`: when
|
||||
// a citation badge is clicked, the badge stages `{documentId, chunkId,
|
||||
// chunkText}` and opens this panel. We drive Plate's `FindReplacePlugin`
|
||||
// (registered in every preset) to highlight the cited text natively via
|
||||
// Slate decorations — no DOM walking, no Range gymnastics. The state
|
||||
// machine below escalates the document fetch from 2MB → 16MB once if no
|
||||
// candidate snippet matched in the preview, and surfaces miss outcomes
|
||||
// via an inline alert.
|
||||
const pending = useAtomValue(pendingChunkHighlightAtom);
|
||||
const setPendingHighlight = useSetAtom(pendingChunkHighlightAtom);
|
||||
const [fetchKey, setFetchKey] = useState(0);
|
||||
const [maxLengthOverride, setMaxLengthOverride] = useState<number | null>(null);
|
||||
const [highlightResult, setHighlightResult] = useState<CitationHighlightStatus | null>(null);
|
||||
const editorRef = useRef<PlateEditorInstance | null>(null);
|
||||
const escalatedForRef = useRef<number | null>(null);
|
||||
const lastAppliedChunkIdRef = useRef<number | null>(null);
|
||||
// Tracks whether a citation highlight is currently decorated in the
|
||||
// editor. We use a ref (not state) because the click-to-dismiss handler
|
||||
// runs in a stable callback that would otherwise close over stale state.
|
||||
const isHighlightActiveRef = useRef(false);
|
||||
// Once a citation jump targets this doc we have to keep `PlateEditor`
|
||||
// mounted for the *rest of the doc session* — even after the highlight
|
||||
// effect clears `pendingChunkHighlightAtom` (which it does as soon as
|
||||
// the decoration is applied, so a follow-up citation on the same chunk
|
||||
// can re-trigger). Without this latch, non-editable docs would re-render
|
||||
// back into `MarkdownViewer` the instant `pending` is released, tearing
|
||||
// down the Plate decorations and dropping the highlight after a frame.
|
||||
const [stickyPlateMode, setStickyPlateMode] = useState(false);
|
||||
|
||||
const clearCitationSearch = useCallback(() => {
|
||||
isHighlightActiveRef.current = false;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
try {
|
||||
editor.setOption(FindReplacePlugin, "search", "");
|
||||
editor.api.redecorate();
|
||||
} catch (err) {
|
||||
console.warn("[EditorPanelContent] clearCitationSearch failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dismiss the highlight when the user interacts with the editor surface.
|
||||
// `onPointerDown` fires before focus / selection changes so the click
|
||||
// itself feels responsive — the highlight clears in the same event tick
|
||||
// that places the cursor. No-op when nothing is highlighted, so we don't
|
||||
// thrash `redecorate` on every click in normal editing.
|
||||
const handleEditorPointerDown = useCallback(() => {
|
||||
if (!isHighlightActiveRef.current) return;
|
||||
clearCitationSearch();
|
||||
setHighlightResult(null);
|
||||
}, [clearCitationSearch]);
|
||||
|
||||
const isCitationTarget = !!pending && !isLocalFileMode && pending.documentId === documentId;
|
||||
const resolveLocalVirtualPath = useCallback(
|
||||
async (candidatePath: string): Promise<string> => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
|
|
@ -155,6 +218,8 @@ export function EditorPanelContent({
|
|||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
// `fetchKey` is an explicit re-fetch trigger (escalation bumps it to force
|
||||
// a new request even when documentId/searchSpaceId haven't changed).
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
|
|
@ -166,6 +231,12 @@ export function EditorPanelContent({
|
|||
setIsEditing(false);
|
||||
initialLoadDone.current = false;
|
||||
changeCountRef.current = 0;
|
||||
// Clear any in-flight FindReplacePlugin search before the editor
|
||||
// re-mounts on new content (a fresh editor key is generated below
|
||||
// from documentId + isEditing, so the previous editor + its
|
||||
// decorations are about to be discarded anyway, but we belt-and-
|
||||
// brace here for the case where only `fetchKey` changed).
|
||||
clearCitationSearch();
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
|
|
@ -210,7 +281,11 @@ export function EditorPanelContent({
|
|||
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));
|
||||
url.searchParams.set("max_length", String(maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD));
|
||||
// `fetchKey` participates here so biome's noUnusedVariables sees it
|
||||
// as consumed; bumping it forces a fresh request even when the URL
|
||||
// is otherwise identical.
|
||||
if (fetchKey > 0) url.searchParams.set("_n", String(fetchKey));
|
||||
|
||||
const response = await authenticatedFetch(url.toString(), { method: "GET" });
|
||||
|
||||
|
|
@ -256,8 +331,259 @@ export function EditorPanelContent({
|
|||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
title,
|
||||
fetchKey,
|
||||
maxLengthOverride,
|
||||
clearCitationSearch,
|
||||
]);
|
||||
|
||||
// Reset citation-jump bookkeeping whenever the panel switches to a different
|
||||
// document (or local file). Body only writes setters — the deps are the
|
||||
// real triggers we want to react to.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: documentId/localFilePath are intentional triggers.
|
||||
useEffect(() => {
|
||||
clearCitationSearch();
|
||||
escalatedForRef.current = null;
|
||||
lastAppliedChunkIdRef.current = null;
|
||||
setHighlightResult(null);
|
||||
setMaxLengthOverride(null);
|
||||
setFetchKey(0);
|
||||
// Drop sticky Plate mode when the panel moves to a different doc
|
||||
// — the next doc starts in its preferred render mode (Plate for
|
||||
// editable, MarkdownViewer for everything else) until/unless a
|
||||
// citation jump targets it.
|
||||
setStickyPlateMode(false);
|
||||
}, [documentId, localFilePath, clearCitationSearch]);
|
||||
|
||||
// Latch sticky Plate mode the first time a citation jump targets this
|
||||
// doc. We keep it sticky for the remainder of this doc session so the
|
||||
// highlight effect's `setPendingHighlight(null)` doesn't unmount the
|
||||
// editor mid-flight (see comment on `stickyPlateMode` declaration).
|
||||
useEffect(() => {
|
||||
if (isCitationTarget) setStickyPlateMode(true);
|
||||
}, [isCitationTarget]);
|
||||
|
||||
// `isEditorReady` is what `useEffect` actually depends on — `editorRef`
|
||||
// is a ref so changes don't trigger re-runs. We flip this to `true` once
|
||||
// `PlateEditor` calls back with its live editor instance (its
|
||||
// `usePlateEditor` value-init runs synchronously, so by the time this
|
||||
// flips true the markdown is already deserialized into the Slate tree).
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const handleEditorReady = useCallback((editor: PlateEditorInstance | null) => {
|
||||
console.log("[citation:editor] handleEditorReady", { ready: !!editor });
|
||||
editorRef.current = editor;
|
||||
setIsEditorReady(!!editor);
|
||||
}, []);
|
||||
|
||||
// --- Citation jump highlight effect -----------------------------------
|
||||
// Drives Plate's FindReplacePlugin to highlight the cited chunk:
|
||||
// 1. Build candidate snippets from the chunk text (first sentence,
|
||||
// first 8 words, full chunk if short). Plate's decorate runs per-
|
||||
// block and won't cross block boundaries, so the shorter
|
||||
// candidates exist to give us something that fits in one
|
||||
// paragraph / heading.
|
||||
// 2. For each candidate: setOption('search', ...) → redecorate →
|
||||
// wait two animation frames for React to flush → query the editor
|
||||
// DOM for `.${CITATION_HIGHLIGHT_CLASS}`. First hit wins.
|
||||
//
|
||||
// Why a className and not a `data-*` attribute? Plate's
|
||||
// `PlateLeaf` runs its props through `useNodeAttributes`, which
|
||||
// only forwards `attributes`, `className`, `ref`, and `style` —
|
||||
// arbitrary `data-*` attributes are silently dropped. `className`
|
||||
// is the only escape hatch guaranteed to survive into the DOM.
|
||||
// 3. On hit: smooth-scroll the first match into view, mark the
|
||||
// highlight active (so a click inside the editor can dismiss it),
|
||||
// release the pending atom.
|
||||
// 4. On terminal miss: if the doc was truncated and we haven't
|
||||
// escalated yet, bump the fetch's `max_length` to the citation
|
||||
// cap and re-fetch — the post-refetch render will re-run this
|
||||
// effect against the larger preview. Otherwise, release the
|
||||
// atom and show the miss alert.
|
||||
useEffect(() => {
|
||||
console.log("[citation:effect] fired", {
|
||||
isCitationTarget,
|
||||
pendingDocId: pending?.documentId,
|
||||
pendingChunkId: pending?.chunkId,
|
||||
pendingChunkTextLen: pending?.chunkText?.length,
|
||||
documentId,
|
||||
isLocalFileMode,
|
||||
isEditing,
|
||||
hasMarkdown: !!editorDoc?.source_markdown,
|
||||
markdownLen: editorDoc?.source_markdown?.length,
|
||||
truncated: editorDoc?.truncated,
|
||||
isEditorReady,
|
||||
editorRefSet: !!editorRef.current,
|
||||
maxLengthOverride,
|
||||
});
|
||||
if (!isCitationTarget || !pending) {
|
||||
console.log("[citation:effect] guard ✗ no citation target / no pending");
|
||||
return;
|
||||
}
|
||||
if (isLocalFileMode || isEditing) {
|
||||
console.log("[citation:effect] guard ✗ localFileMode/editing");
|
||||
return;
|
||||
}
|
||||
if (!editorDoc?.source_markdown) {
|
||||
console.log("[citation:effect] guard ✗ source_markdown not ready");
|
||||
return;
|
||||
}
|
||||
if (!isEditorReady) {
|
||||
console.log("[citation:effect] guard ✗ editor not ready yet");
|
||||
return;
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
console.log("[citation:effect] guard ✗ editorRef.current is null");
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastAppliedChunkIdRef.current !== pending.chunkId) {
|
||||
lastAppliedChunkIdRef.current = pending.chunkId;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const finishMiss = () => {
|
||||
console.log("[citation:effect] terminal miss — no candidate matched");
|
||||
try {
|
||||
editor.setOption(FindReplacePlugin, "search", "");
|
||||
editor.api.redecorate();
|
||||
} catch (err) {
|
||||
console.warn("[EditorPanelContent] reset search after miss failed:", err);
|
||||
}
|
||||
const canEscalate =
|
||||
editorDoc.truncated === true &&
|
||||
(maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD) < CITATION_MAX_LENGTH &&
|
||||
escalatedForRef.current !== pending.chunkId;
|
||||
console.log("[citation:effect] miss decision", {
|
||||
truncated: editorDoc.truncated,
|
||||
currentMaxLength: maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD,
|
||||
canEscalate,
|
||||
});
|
||||
if (canEscalate) {
|
||||
escalatedForRef.current = pending.chunkId;
|
||||
setMaxLengthOverride(CITATION_MAX_LENGTH);
|
||||
setFetchKey((k) => k + 1);
|
||||
// Keep the atom set so the post-refetch render re-runs.
|
||||
return;
|
||||
}
|
||||
setHighlightResult("miss");
|
||||
setPendingHighlight(null);
|
||||
};
|
||||
|
||||
const tryCandidates = async () => {
|
||||
const candidates = buildCitationSearchCandidates(pending.chunkText);
|
||||
console.log("[citation:effect] candidates built", {
|
||||
count: candidates.length,
|
||||
previews: candidates.map((c) => c.slice(0, 60)),
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
if (!cancelled) finishMiss();
|
||||
return;
|
||||
}
|
||||
// Resolve the editor's rendered DOM root via Slate's stable
|
||||
// `[data-slate-editor="true"]` attribute (set by slate-react's
|
||||
// `<Editable>`). Scoping queries to this root prevents
|
||||
// `<mark>` elements rendered elsewhere on the page (e.g. chat
|
||||
// search-highlight leaves in another mounted PlateEditor) from
|
||||
// being mistaken for citation hits.
|
||||
const editorRoot = document.querySelector<HTMLElement>('[data-slate-editor="true"]');
|
||||
console.log("[citation:effect] editor root", {
|
||||
hasRoot: !!editorRoot,
|
||||
});
|
||||
const root: ParentNode = editorRoot ?? document;
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const candidate = candidates[i];
|
||||
if (cancelled) return;
|
||||
try {
|
||||
editor.setOption(FindReplacePlugin, "search", candidate);
|
||||
editor.api.redecorate();
|
||||
console.log(`[citation:effect] try #${i} setOption + redecorate`, {
|
||||
len: candidate.length,
|
||||
preview: candidate.slice(0, 80),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[EditorPanelContent] setOption/redecorate failed:", err);
|
||||
continue;
|
||||
}
|
||||
// Two rAFs: first lets Slate flush its onChange, second lets
|
||||
// React commit the decoration leaves into the DOM.
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
);
|
||||
if (cancelled) return;
|
||||
// Primary probe: by our stable class on the rendered <mark>.
|
||||
let el = root.querySelector<HTMLElement>(`.${CITATION_HIGHLIGHT_CLASS}`);
|
||||
const classMarkCount = root.querySelectorAll(`.${CITATION_HIGHLIGHT_CLASS}`).length;
|
||||
// Diagnostic fallback: any <mark> inside the editor root.
|
||||
// If we ever see allMarks > 0 but classMarkCount === 0,
|
||||
// the className was stripped again and we need to revisit
|
||||
// `useNodeAttributes` filtering.
|
||||
const allMarkCount = root.querySelectorAll("mark").length;
|
||||
if (!el && allMarkCount > 0) {
|
||||
el = root.querySelector<HTMLElement>("mark");
|
||||
}
|
||||
console.log(`[citation:effect] try #${i} DOM probe`, {
|
||||
foundEl: !!el,
|
||||
classMarkCount,
|
||||
allMarkCount,
|
||||
usedFallback: !!el && classMarkCount === 0,
|
||||
});
|
||||
if (el) {
|
||||
try {
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
} catch {
|
||||
el.scrollIntoView();
|
||||
}
|
||||
isHighlightActiveRef.current = true;
|
||||
setHighlightResult("exact");
|
||||
console.log(`[citation:effect] ✓ exact via candidate #${i} — atom released`);
|
||||
// No auto-clear timer — the highlight is intentionally
|
||||
// permanent until the user clicks inside the editor (see
|
||||
// `handleEditorPointerDown`) or another dismissal trigger
|
||||
// fires (doc switch, edit-mode toggle, panel unmount,
|
||||
// next citation jump). Sticky Plate mode keeps the
|
||||
// editor mounted after the atom clears.
|
||||
setPendingHighlight(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!cancelled) finishMiss();
|
||||
};
|
||||
|
||||
void tryCandidates();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isCitationTarget,
|
||||
pending,
|
||||
documentId,
|
||||
editorDoc?.source_markdown,
|
||||
editorDoc?.truncated,
|
||||
isLocalFileMode,
|
||||
isEditing,
|
||||
isEditorReady,
|
||||
maxLengthOverride,
|
||||
clearCitationSearch,
|
||||
setPendingHighlight,
|
||||
]);
|
||||
|
||||
// Cleanup any active highlight on unmount.
|
||||
useEffect(() => {
|
||||
return () => clearCitationSearch();
|
||||
}, [clearCitationSearch]);
|
||||
|
||||
// Toggling into edit mode swaps Plate out of readOnly. Clear the citation
|
||||
// search so stale leaves don't linger in the editing surface.
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
clearCitationSearch();
|
||||
setHighlightResult(null);
|
||||
}
|
||||
}, [isEditing, clearCitationSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetTimeoutRef.current) {
|
||||
|
|
@ -367,6 +693,15 @@ export function EditorPanelContent({
|
|||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||
!isLargeDocument
|
||||
: false;
|
||||
// Use PlateEditor for any of:
|
||||
// - Editable doc types (FILE/NOTE) — existing editing UX.
|
||||
// - Active citation jump in flight (`isCitationTarget`) — covers the
|
||||
// mount in the very first render where the atom is set but the
|
||||
// sticky effect hasn't fired yet.
|
||||
// - Sticky Plate mode latched on a previous citation jump — keeps
|
||||
// the editor mounted (with its decorations) after the highlight
|
||||
// effect clears the atom. Resets when the doc changes.
|
||||
const renderInPlateEditor = isEditableType || isCitationTarget || stickyPlateMode;
|
||||
const hasUnsavedChanges = editedMarkdown !== null;
|
||||
const showDesktopHeader = !!onClose;
|
||||
const showEditingActions = isEditableType && isEditing;
|
||||
|
|
@ -381,6 +716,90 @@ export function EditorPanelContent({
|
|||
setIsEditing(false);
|
||||
}, [editorDoc?.source_markdown]);
|
||||
|
||||
const handleDownloadMarkdown = useCallback(async () => {
|
||||
if (!searchSpaceId || !documentId) return;
|
||||
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);
|
||||
}
|
||||
}, [documentId, editorDoc?.title, searchSpaceId]);
|
||||
|
||||
// We no longer surface an "approximate" status — Plate's FindReplacePlugin
|
||||
// either decorates an exact match or it doesn't, and the candidate snippet
|
||||
// strategy (first sentence → first 8 words → full chunk) means we either
|
||||
// land on the citation start or fall through to the miss alert.
|
||||
const showMissAlert = isCitationTarget && highlightResult === "miss";
|
||||
|
||||
const citationAlerts = showMissAlert && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<FileQuestionMark className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>Cited section couldn't be located in this view.</span>
|
||||
{editorDoc?.truncated && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={handleDownloadMarkdown}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && (
|
||||
<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="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={handleDownloadMarkdown}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDesktopHeader ? (
|
||||
|
|
@ -565,61 +984,6 @@ export function EditorPanelContent({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLargeDocument && !isLocalFileMode ? (
|
||||
<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="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
if (!searchSpaceId || !documentId) {
|
||||
throw new Error("Missing document context");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
) : editorRenderMode === "source_code" ? (
|
||||
<div className="h-full overflow-hidden">
|
||||
<SourceCodeEditor
|
||||
|
|
@ -638,20 +1002,46 @@ export function EditorPanelContent({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isEditableType ? (
|
||||
<PlateEditor
|
||||
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
|
||||
preset="full"
|
||||
markdown={editorDoc.source_markdown}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
readOnly={!isEditing}
|
||||
placeholder="Start writing..."
|
||||
editorVariant="default"
|
||||
allowModeToggle={false}
|
||||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||
/>
|
||||
) : isLargeDocument && !isLocalFileMode && !isCitationTarget ? (
|
||||
// Large doc, no active citation — fast Streamdown preview
|
||||
// + download CTA. We only fall back to MarkdownViewer here
|
||||
// because Plate is heavy on multi-MB docs and the user
|
||||
// isn't waiting on a specific citation to render.
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
{largeDocAlert}
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
) : renderInPlateEditor ? (
|
||||
// Editable doc (FILE/NOTE) OR active citation jump (any
|
||||
// doc type). The citation path uses Plate's
|
||||
// FindReplacePlugin for native, decoration-based
|
||||
// highlighting — see the citation-jump highlight effect
|
||||
// above for how `editorRef` and `handleEditorReady` are
|
||||
// wired.
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{(citationAlerts || (isLargeDocument && isCitationTarget && !isLocalFileMode)) && (
|
||||
<div className="shrink-0 px-5 pt-4">
|
||||
{isLargeDocument && isCitationTarget && largeDocAlert}
|
||||
{citationAlerts}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden" onPointerDown={handleEditorPointerDown}>
|
||||
<PlateEditor
|
||||
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
|
||||
preset="full"
|
||||
markdown={editorDoc.source_markdown}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
readOnly={!isEditing}
|
||||
placeholder="Start writing..."
|
||||
editorVariant="default"
|
||||
allowModeToggle={false}
|
||||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||
onEditorReady={handleEditorReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ import { type EditorPreset, presetMap } from "@/components/editor/presets";
|
|||
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
|
||||
import { Editor, EditorContainer } from "@/components/ui/editor";
|
||||
|
||||
/** Live editor instance returned by `usePlateEditor`. Exposed via the
|
||||
* `onEditorReady` prop so callers (e.g. `EditorPanelContent`) can drive
|
||||
* plugin options imperatively — most notably setting
|
||||
* `FindReplacePlugin`'s `search` option for citation-jump highlights. */
|
||||
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
|
||||
|
||||
export interface PlateEditorProps {
|
||||
/** Markdown string to load as initial content */
|
||||
markdown?: string;
|
||||
|
|
@ -62,6 +68,15 @@ export interface PlateEditorProps {
|
|||
* without modifying the core editor component.
|
||||
*/
|
||||
extraPlugins?: AnyPluginConfig[];
|
||||
/**
|
||||
* Called whenever the live editor instance (re)mounts, with `null` on
|
||||
* unmount. Used by callers that need to drive plugin options imperatively
|
||||
* — e.g. `EditorPanelContent` setting `FindReplacePlugin`'s `search`
|
||||
* option for citation-jump highlights. The callback is invoked exactly
|
||||
* once per editor lifetime (the parent's `key` prop forces a fresh
|
||||
* editor when needed, e.g. on edit-mode toggle).
|
||||
*/
|
||||
onEditorReady?: (editor: PlateEditorInstance | null) => void;
|
||||
}
|
||||
|
||||
function PlateEditorContent({
|
||||
|
|
@ -100,6 +115,7 @@ export function PlateEditor({
|
|||
defaultEditing = false,
|
||||
preset = "full",
|
||||
extraPlugins = [],
|
||||
onEditorReady,
|
||||
}: PlateEditorProps) {
|
||||
const lastMarkdownRef = useRef(markdown);
|
||||
const lastHtmlRef = useRef(html);
|
||||
|
|
@ -156,6 +172,21 @@ export function PlateEditor({
|
|||
: undefined,
|
||||
});
|
||||
|
||||
// Expose the live editor instance to imperative callers (e.g. citation
|
||||
// jump highlights). We deliberately don't depend on `onEditorReady`
|
||||
// itself in the cleanup closure — callers commonly pass an arrow that
|
||||
// closes over a stable ref setter, but if they pass a freshly-bound
|
||||
// callback per render, the `onEditorReady?.(editor)` re-fires which is
|
||||
// idempotent for ref-style setters.
|
||||
const onEditorReadyRef = useRef(onEditorReady);
|
||||
useEffect(() => {
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
}, [onEditorReady]);
|
||||
useEffect(() => {
|
||||
onEditorReadyRef.current?.(editor);
|
||||
return () => onEditorReadyRef.current?.(null);
|
||||
}, [editor]);
|
||||
|
||||
// Update editor content when html prop changes externally
|
||||
useEffect(() => {
|
||||
if (html !== undefined && html !== lastHtmlRef.current) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { FindReplacePlugin } from "@platejs/find-replace";
|
||||
import type { AnyPluginConfig } from "platejs";
|
||||
import { TrailingBlockPlugin } from "platejs";
|
||||
|
||||
|
|
@ -17,6 +18,30 @@ import { SelectionKit } from "@/components/editor/plugins/selection-kit";
|
|||
import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
|
||||
import { TableKit } from "@/components/editor/plugins/table-kit";
|
||||
import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
|
||||
import { SearchHighlightLeaf } from "@/components/ui/search-highlight-node";
|
||||
|
||||
/**
|
||||
* Citation-jump highlighter. Re-uses Plate's built-in `FindReplacePlugin`
|
||||
* (decorate-only, no editing surface) to drive the "scroll-to-cited-text"
|
||||
* UX in `EditorPanelContent`. We register it in every preset because:
|
||||
* - Decorate is a no-op when `search` is empty (single getOptions() check
|
||||
* per block), so cost is effectively zero for non-citation viewers.
|
||||
* - Keeping it preset-agnostic means citations work whether the doc is
|
||||
* opened in editable (`full`) or pure-viewer (`readonly`) modes.
|
||||
*
|
||||
* The parent component drives `setOption(FindReplacePlugin, 'search', ...)`
|
||||
* + `editor.api.redecorate()` to trigger highlights, then queries the
|
||||
* editor DOM for `.citation-highlight-leaf` to scroll the first match
|
||||
* into view. (We can't use a `data-*` attribute here — Plate's
|
||||
* `PlateLeaf` runs props through `useNodeAttributes`, which only forwards
|
||||
* `attributes`, `className`, `ref`, `style`; arbitrary `data-*` props are
|
||||
* silently dropped.) See `components/ui/search-highlight-node.tsx` for
|
||||
* the leaf component and `CITATION_HIGHLIGHT_CLASS` constant.
|
||||
*/
|
||||
const CitationFindReplacePlugin = FindReplacePlugin.configure({
|
||||
options: { search: "" },
|
||||
render: { node: SearchHighlightLeaf },
|
||||
});
|
||||
|
||||
/**
|
||||
* Full preset – every plugin kit enabled.
|
||||
|
|
@ -38,6 +63,7 @@ export const fullPreset: AnyPluginConfig[] = [
|
|||
...AutoformatKit,
|
||||
...DndKit,
|
||||
TrailingBlockPlugin,
|
||||
CitationFindReplacePlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -52,6 +78,7 @@ export const minimalPreset: AnyPluginConfig[] = [
|
|||
...LinkKit,
|
||||
...AutoformatKit,
|
||||
TrailingBlockPlugin,
|
||||
CitationFindReplacePlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -68,6 +95,7 @@ export const readonlyPreset: AnyPluginConfig[] = [
|
|||
...CalloutKit,
|
||||
...ToggleKit,
|
||||
...MathKit,
|
||||
CitationFindReplacePlugin,
|
||||
];
|
||||
|
||||
/** All available preset names */
|
||||
|
|
|
|||
|
|
@ -1,719 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
import { forwardRef, memo, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GetDocumentByChunkResponse,
|
||||
GetSurfsenseDocsByChunkResponse,
|
||||
} from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
|
||||
|
||||
interface SourceDetailPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chunkId: number;
|
||||
sourceType: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
children?: ReactNode;
|
||||
isDocsChunk?: boolean;
|
||||
}
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
if (!type) return "";
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Chunk card component
|
||||
// For large documents (>30 chunks), we disable animation to prevent layout shifts
|
||||
// which break auto-scroll functionality
|
||||
interface ChunkCardProps {
|
||||
chunk: { id: number; content: string };
|
||||
localIndex: number;
|
||||
chunkNumber: number;
|
||||
totalChunks: number;
|
||||
isCited: boolean;
|
||||
isActive: boolean;
|
||||
disableLayoutAnimation?: boolean;
|
||||
}
|
||||
|
||||
const ChunkCard = memo(
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
ChunkCard.displayName = "ChunkCard";
|
||||
|
||||
export function SourceDetailPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
chunkId,
|
||||
sourceType,
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
children,
|
||||
isDocsChunk = false,
|
||||
}: SourceDetailPanelProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
|
||||
const scrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: documentData,
|
||||
isLoading: isDocumentByChunkFetching,
|
||||
error: documentByChunkFetchingError,
|
||||
} = useQuery<DocumentData>({
|
||||
queryKey: isDocsChunk
|
||||
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
|
||||
: cacheKeys.documents.byChunk(chunkId.toString()),
|
||||
queryFn: async () => {
|
||||
if (isDocsChunk) {
|
||||
return documentsApiService.getSurfsenseDocByChunk(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";
|
||||
|
||||
const citedChunkIndex = allChunks.findIndex((chunk) => chunk.id === chunkId);
|
||||
|
||||
// Simple scroll function that scrolls to a chunk by index
|
||||
const scrollToChunkByIndex = useCallback(
|
||||
(chunkIndex: number, smooth = true) => {
|
||||
const scrollContainer = scrollAreaRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const viewport = scrollContainer.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement | null;
|
||||
if (!viewport) return;
|
||||
|
||||
const chunkElement = scrollContainer.querySelector(
|
||||
`[data-chunk-index="${chunkIndex}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!chunkElement) return;
|
||||
|
||||
// Get positions using getBoundingClientRect for accuracy
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const chunkRect = chunkElement.getBoundingClientRect();
|
||||
|
||||
// Calculate where to scroll to center the chunk
|
||||
const currentScrollTop = viewport.scrollTop;
|
||||
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
|
||||
const scrollTarget =
|
||||
chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
|
||||
|
||||
viewport.scrollTo({
|
||||
top: Math.max(0, scrollTarget),
|
||||
behavior: smooth && !shouldReduceMotion ? "smooth" : "auto",
|
||||
});
|
||||
|
||||
setActiveChunkIndex(chunkIndex);
|
||||
},
|
||||
[shouldReduceMotion]
|
||||
);
|
||||
|
||||
// Callback ref for the cited chunk - scrolls when the element mounts
|
||||
const citedChunkRefCallback = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node && !hasScrolledRef.current && open) {
|
||||
hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls
|
||||
|
||||
// Store the node reference for the delayed scroll
|
||||
const scrollToCitedChunk = () => {
|
||||
const scrollContainer = scrollAreaRef.current;
|
||||
if (!scrollContainer || !node.isConnected) return false;
|
||||
|
||||
const viewport = scrollContainer.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement | null;
|
||||
if (!viewport) return false;
|
||||
|
||||
// Get positions
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const chunkRect = node.getBoundingClientRect();
|
||||
|
||||
// Calculate scroll position to center the chunk
|
||||
const currentScrollTop = viewport.scrollTop;
|
||||
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
|
||||
const scrollTarget =
|
||||
chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
|
||||
|
||||
viewport.scrollTo({
|
||||
top: Math.max(0, scrollTarget),
|
||||
behavior: "auto", // Instant scroll for initial positioning
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Scroll multiple times with delays to handle progressive content rendering
|
||||
// Each subsequent scroll will correct for any layout shifts
|
||||
const scrollAttempts = [50, 150, 300, 600, 1000];
|
||||
|
||||
scrollAttempts.forEach((delay) => {
|
||||
scrollTimersRef.current.push(
|
||||
setTimeout(() => {
|
||||
scrollToCitedChunk();
|
||||
}, delay)
|
||||
);
|
||||
});
|
||||
|
||||
// After final attempt, mark the cited chunk as active
|
||||
scrollTimersRef.current.push(
|
||||
setTimeout(
|
||||
() => {
|
||||
setActiveChunkIndex(citedChunkIndex);
|
||||
},
|
||||
scrollAttempts[scrollAttempts.length - 1] + 50
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[open, citedChunkIndex]
|
||||
);
|
||||
|
||||
// Reset scroll state when panel closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
scrollTimersRef.current.forEach(clearTimeout);
|
||||
scrollTimersRef.current = [];
|
||||
hasScrolledRef.current = false;
|
||||
setActiveChunkIndex(null);
|
||||
}
|
||||
return () => {
|
||||
scrollTimersRef.current.forEach(clearTimeout);
|
||||
scrollTimersRef.current = [];
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(clickUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const scrollToChunk = useCallback(
|
||||
(index: number) => {
|
||||
scrollToChunkByIndex(index, true);
|
||||
},
|
||||
[scrollToChunkByIndex]
|
||||
);
|
||||
|
||||
const panelContent = (
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
key="panel"
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}}
|
||||
className="fixed inset-3 sm:inset-6 md:inset-10 lg:inset-16 z-50 flex flex-col bg-background rounded-3xl shadow-2xl border overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="flex items-center justify-between px-6 py-5 border-b bg-linear-to-r from-muted/50 to-muted/30"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold truncate">
|
||||
{documentData?.title || title || "Source Document"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{documentData && "document_type" in documentData
|
||||
? formatDocumentType(documentData.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
{totalChunks > 0 && (
|
||||
<span className="ml-2">
|
||||
• {totalChunks} chunk{totalChunks !== 1 ? "s" : ""}
|
||||
{allChunks.length < totalChunks && ` (showing ${allChunks.length})`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="hidden sm:flex gap-2 rounded-xl"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open Source
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8 rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Loading State */}
|
||||
{!isDirectRenderSource && isDocumentByChunkFetching && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{t("loading_document")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{!isDirectRenderSource && documentByChunkFetchingError && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4 text-center px-6"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-md">
|
||||
{documentByChunkFetchingError.message ||
|
||||
"An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="mt-2">
|
||||
Close Panel
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for web search providers */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
{url && (
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="w-full mb-6 sm:hidden rounded-xl"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-6 bg-muted/50 rounded-2xl border"
|
||||
>
|
||||
<h3 className="text-base font-semibold mb-4 flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Source Information
|
||||
</h3>
|
||||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
||||
{title || "Untitled"}
|
||||
</div>
|
||||
<div className="text-sm text-foreground leading-relaxed">
|
||||
{description || "No content available"}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* API-fetched document content */}
|
||||
{!isDirectRenderSource && documentData && (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Chunk Navigation Sidebar */}
|
||||
{allChunks.length > 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="hidden lg:flex flex-col w-16 border-r bg-muted/10 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div className="p-2 pt-3 flex flex-col gap-1.5">
|
||||
{allChunks.map((chunk, idx) => {
|
||||
const absNum = absoluteStart + idx + 1;
|
||||
const isCited = chunk.id === chunkId;
|
||||
const isActive = activeChunkIndex === idx;
|
||||
return (
|
||||
<motion.button
|
||||
key={chunk.id}
|
||||
type="button"
|
||||
onClick={() => scrollToChunk(idx)}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: Math.min(idx * 0.02, 0.2) }}
|
||||
className={cn(
|
||||
"relative w-11 h-9 mx-auto rounded-lg text-xs font-semibold transition-all duration-200 flex items-center justify-center",
|
||||
isCited
|
||||
? "bg-primary text-primary-foreground shadow-md"
|
||||
: isActive
|
||||
? "bg-muted text-foreground"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={isCited ? `Chunk ${absNum} (Cited)` : `Chunk ${absNum}`}
|
||||
>
|
||||
{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" />
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
|
||||
{/* Document Metadata */}
|
||||
{"document_metadata" in documentData &&
|
||||
documentData.document_metadata &&
|
||||
Object.keys(documentData.document_metadata).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="p-5 bg-muted/30 rounded-2xl border"
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Document Information
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
{Object.entries(documentData.document_metadata).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<dt className="font-medium text-muted-foreground capitalize text-xs">
|
||||
{key.replace(/_/g, " ")}
|
||||
</dt>
|
||||
<dd className="text-foreground wrap-break-word">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Chunks Header */}
|
||||
<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" />
|
||||
Chunks {absoluteStart + 1}–{absoluteEnd} of {totalChunks}
|
||||
</h3>
|
||||
{citedChunkIndex !== -1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => scrollToChunk(citedChunkIndex)}
|
||||
className="gap-2 text-primary hover:text-primary"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Jump to cited
|
||||
</Button>
|
||||
)}
|
||||
</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">
|
||||
{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}
|
||||
localIndex={idx}
|
||||
chunkNumber={chunkNumber}
|
||||
totalChunks={totalChunks}
|
||||
isCited={isCited}
|
||||
isActive={activeChunkIndex === idx}
|
||||
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>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (!mounted) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{createPortal(panelContent, globalThis.document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,9 +67,6 @@ const DesktopShortcutsContent = dynamic(
|
|||
import(
|
||||
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
|
||||
).then((m) => ({ default: m.DesktopShortcutsContent })),
|
||||
import(
|
||||
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
|
||||
).then((m) => ({ default: m.DesktopShortcutsContent })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const MemoryContent = dynamic(
|
||||
|
|
|
|||
45
surfsense_web/components/ui/search-highlight-node.tsx
Normal file
45
surfsense_web/components/ui/search-highlight-node.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import type { PlateLeafProps } from "platejs/react";
|
||||
import { PlateLeaf } from "platejs/react";
|
||||
|
||||
/**
|
||||
* Stable class name used to identify Plate-rendered citation highlight
|
||||
* leaves in the DOM. We can't use a `data-*` attribute here — Plate's
|
||||
* `PlateLeaf` runs its props through `useNodeAttributes`, which only
|
||||
* forwards `attributes`, `className`, `ref`, and `style` to the rendered
|
||||
* element; arbitrary `data-*` props are silently dropped (verified
|
||||
* against `@platejs/core/dist/react/index.js` v52). So `className` is
|
||||
* the only escape hatch that's guaranteed to survive into the DOM.
|
||||
*/
|
||||
export const CITATION_HIGHLIGHT_CLASS = "citation-highlight-leaf";
|
||||
|
||||
/**
|
||||
* Leaf rendered for ranges decorated by `@platejs/find-replace`'s
|
||||
* `FindReplacePlugin`. We re-purpose that plugin to drive the citation-jump
|
||||
* highlight: when a citation is staged, the parent sets the plugin's `search`
|
||||
* option to a snippet of the chunk text and Plate decorates every match with
|
||||
* `searchHighlight: true`. This component renders those decorations as a
|
||||
* `<mark>` tagged with `CITATION_HIGHLIGHT_CLASS` so the parent can:
|
||||
* 1. Query the first match in DOM order to scroll it into view.
|
||||
* 2. Detect the active-highlight state without a separate React ref.
|
||||
*
|
||||
* The highlight is **persistent** — it does not auto-fade. The parent in
|
||||
* `EditorPanelContent` clears it by setting the plugin's `search` option
|
||||
* back to "" when one of: (a) the user clicks anywhere inside the editor,
|
||||
* (b) the panel switches to a different document, (c) the user toggles
|
||||
* into edit mode, (d) another citation jump is staged, (e) the panel
|
||||
* unmounts. We use a brief entrance pulse (`citation-flash-in`, see
|
||||
* `globals.css`) purely to draw the eye after `scrollIntoView` lands.
|
||||
*/
|
||||
export function SearchHighlightLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf
|
||||
{...props}
|
||||
as="mark"
|
||||
className={`${CITATION_HIGHLIGHT_CLASS} bg-primary/15 ring-1 ring-primary/40 rounded-sm px-0.5 text-inherit animate-[citation-flash-in_400ms_ease-out]`}
|
||||
>
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue