mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
feat: integrate Streamdown for markdown rendering and enhance citation handling
This commit is contained in:
parent
947087452f
commit
3906ba52e0
8 changed files with 1807 additions and 224 deletions
|
|
@ -158,3 +158,4 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||||
|
@source '../node_modules/streamdown/dist/*.js';
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,22 @@
|
||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SheetTrigger } from "@/components/ui/sheet";
|
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
|
||||||
import { SourceDetailSheet } from "@/components/chat/SourceDetailSheet";
|
|
||||||
|
|
||||||
interface InlineCitationProps {
|
interface InlineCitationProps {
|
||||||
chunkId: number;
|
chunkId: number;
|
||||||
|
citationNumber: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline citation component for the new chat.
|
* Inline citation component for the new chat.
|
||||||
* Renders a clickable badge that opens the SourceDetailSheet with document chunk details.
|
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
|
||||||
*/
|
*/
|
||||||
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId }) => {
|
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SourceDetailSheet
|
<SourceDetailPanel
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
chunkId={chunkId}
|
chunkId={chunkId}
|
||||||
|
|
@ -26,22 +26,17 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId }) => {
|
||||||
description=""
|
description=""
|
||||||
url=""
|
url=""
|
||||||
>
|
>
|
||||||
<SheetTrigger asChild>
|
<span
|
||||||
<span
|
onClick={() => setIsOpen(true)}
|
||||||
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
|
onKeyDown={(e) => e.key === "Enter" && setIsOpen(true)}
|
||||||
title={`View source (chunk ${chunkId})`}
|
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full min-w-4 h-4 px-1 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
|
||||||
>
|
title={`View source #${citationNumber}`}
|
||||||
<svg
|
role="button"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
tabIndex={0}
|
||||||
viewBox="0 0 16 16"
|
>
|
||||||
fill="currentColor"
|
{citationNumber}
|
||||||
className="w-2.5 h-2.5"
|
</span>
|
||||||
>
|
</SourceDetailPanel>
|
||||||
<path d="M6.22 8.72a.75.75 0 0 0 1.06 1.06l5.22-5.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5a.75.75 0 0 0 0 1.5h1.69L6.22 8.72Z" />
|
|
||||||
<path d="M3.5 6.75c0-.69.56-1.25 1.25-1.25H7A.75.75 0 0 0 7 4H4.75A2.75 2.75 0 0 0 2 6.75v4.5A2.75 2.75 0 0 0 4.75 14h4.5A2.75 2.75 0 0 0 12 11.25V9a.75.75 0 0 0-1.5 0v2.25c0 .69-.56 1.25-1.25 1.25h-4.5c-.69 0-1.25-.56-1.25-1.25v-4.5Z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</SheetTrigger>
|
|
||||||
</SourceDetailSheet>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,29 @@ import { cn } from "@/lib/utils";
|
||||||
// Citation pattern: [citation:CHUNK_ID]
|
// Citation pattern: [citation:CHUNK_ID]
|
||||||
const CITATION_REGEX = /\[citation:(\d+)\]/g;
|
const CITATION_REGEX = /\[citation:(\d+)\]/g;
|
||||||
|
|
||||||
|
// Track chunk IDs to citation numbers mapping for consistent numbering
|
||||||
|
// This map is reset when a new message starts rendering
|
||||||
|
let chunkIdToCitationNumber: Map<number, number> = new Map();
|
||||||
|
let nextCitationNumber = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the citation counter - should be called at the start of each message
|
||||||
|
*/
|
||||||
|
export function resetCitationCounter() {
|
||||||
|
chunkIdToCitationNumber = new Map();
|
||||||
|
nextCitationNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or assigns a citation number for a chunk ID
|
||||||
|
*/
|
||||||
|
function getCitationNumber(chunkId: number): number {
|
||||||
|
if (!chunkIdToCitationNumber.has(chunkId)) {
|
||||||
|
chunkIdToCitationNumber.set(chunkId, nextCitationNumber++);
|
||||||
|
}
|
||||||
|
return chunkIdToCitationNumber.get(chunkId)!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
|
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
|
||||||
*/
|
*/
|
||||||
|
|
@ -26,7 +49,7 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
let citationIndex = 0;
|
let instanceIndex = 0;
|
||||||
|
|
||||||
// Reset regex state
|
// Reset regex state
|
||||||
CITATION_REGEX.lastIndex = 0;
|
CITATION_REGEX.lastIndex = 0;
|
||||||
|
|
@ -39,12 +62,17 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
||||||
|
|
||||||
// Add the citation component
|
// Add the citation component
|
||||||
const chunkId = Number.parseInt(match[1], 10);
|
const chunkId = Number.parseInt(match[1], 10);
|
||||||
|
const citationNumber = getCitationNumber(chunkId);
|
||||||
parts.push(
|
parts.push(
|
||||||
<InlineCitation key={`citation-${chunkId}-${citationIndex}`} chunkId={chunkId} />
|
<InlineCitation
|
||||||
|
key={`citation-${chunkId}-${instanceIndex}`}
|
||||||
|
chunkId={chunkId}
|
||||||
|
citationNumber={citationNumber}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
citationIndex++;
|
instanceIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last citation
|
// Add any remaining text after the last citation
|
||||||
|
|
@ -56,6 +84,10 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownTextImpl = () => {
|
const MarkdownTextImpl = () => {
|
||||||
|
// Reset citation counter at the start of each render
|
||||||
|
// This ensures consistent numbering as the message streams in
|
||||||
|
resetCitationCounter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarkdownTextPrimitive
|
<MarkdownTextPrimitive
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import { Check, Copy } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTheme } from "next-themes";
|
import { Streamdown } from "streamdown";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import type { Components } from "react-markdown";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
||||||
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
|
||||||
import rehypeRaw from "rehype-raw";
|
|
||||||
import rehypeSanitize from "rehype-sanitize";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface MarkdownViewerProps {
|
interface MarkdownViewerProps {
|
||||||
|
|
@ -17,203 +9,100 @@ interface MarkdownViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
|
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const components: Components = {
|
||||||
// Memoize the markdown components to prevent unnecessary re-renders
|
// Define custom components for markdown elements
|
||||||
const components = useMemo(() => {
|
p: ({ children, ...props }) => (
|
||||||
return {
|
<p className="my-2" {...props}>
|
||||||
// Define custom components for markdown elements
|
{children}
|
||||||
p: ({ node, children, ...props }: any) => (
|
</p>
|
||||||
<p className="my-2" {...props}>
|
),
|
||||||
{children}
|
a: ({ children, ...props }) => (
|
||||||
</p>
|
<a
|
||||||
),
|
className="text-primary hover:underline"
|
||||||
a: ({ node, children, ...props }: any) => (
|
target="_blank"
|
||||||
<a
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
{...props}
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
{children}
|
||||||
{...props}
|
</a>
|
||||||
>
|
),
|
||||||
{children}
|
li: ({ children, ...props }) => <li {...props}>{children}</li>,
|
||||||
</a>
|
ul: ({ ...props }) => <ul className="list-disc pl-5 my-2" {...props} />,
|
||||||
),
|
ol: ({ ...props }) => <ol className="list-decimal pl-5 my-2" {...props} />,
|
||||||
li: ({ node, children, ...props }: any) => <li {...props}>{children}</li>,
|
h1: ({ children, ...props }) => (
|
||||||
ul: ({ node, ...props }: any) => <ul className="list-disc pl-5 my-2" {...props} />,
|
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
|
||||||
ol: ({ node, ...props }: any) => <ol className="list-decimal pl-5 my-2" {...props} />,
|
{children}
|
||||||
h1: ({ node, children, ...props }: any) => (
|
</h1>
|
||||||
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
|
),
|
||||||
{children}
|
h2: ({ children, ...props }) => (
|
||||||
</h1>
|
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
|
||||||
),
|
{children}
|
||||||
h2: ({ node, children, ...props }: any) => (
|
</h2>
|
||||||
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
|
),
|
||||||
{children}
|
h3: ({ children, ...props }) => (
|
||||||
</h2>
|
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
|
||||||
),
|
{children}
|
||||||
h3: ({ node, children, ...props }: any) => (
|
</h3>
|
||||||
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
|
),
|
||||||
{children}
|
h4: ({ children, ...props }) => (
|
||||||
</h3>
|
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
|
||||||
),
|
{children}
|
||||||
h4: ({ node, children, ...props }: any) => (
|
</h4>
|
||||||
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
|
),
|
||||||
{children}
|
blockquote: ({ ...props }) => (
|
||||||
</h4>
|
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
|
||||||
),
|
),
|
||||||
blockquote: ({ node, ...props }: any) => (
|
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
|
||||||
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
|
img: ({ src, alt, width: _w, height: _h, ...props }) => (
|
||||||
),
|
<Image
|
||||||
hr: ({ node, ...props }: any) => <hr className="my-4 border-muted" {...props} />,
|
className="max-w-full h-auto my-4 rounded"
|
||||||
img: ({ node, ...props }: any) => (
|
alt={alt || "markdown image"}
|
||||||
<Image
|
height={100}
|
||||||
className="max-w-full h-auto my-4 rounded"
|
width={100}
|
||||||
alt="markdown image"
|
src={typeof src === "string" ? src : ""}
|
||||||
height={100}
|
{...props}
|
||||||
width={100}
|
/>
|
||||||
{...props}
|
),
|
||||||
/>
|
table: ({ ...props }) => (
|
||||||
),
|
<div className="overflow-x-auto my-4">
|
||||||
table: ({ node, ...props }: any) => (
|
<table className="min-w-full divide-y divide-border" {...props} />
|
||||||
<div className="overflow-x-auto my-4">
|
</div>
|
||||||
<table className="min-w-full divide-y divide-border" {...props} />
|
),
|
||||||
</div>
|
th: ({ ...props }) => (
|
||||||
),
|
<th className="px-3 py-2 text-left font-medium bg-muted" {...props} />
|
||||||
th: ({ node, ...props }: any) => (
|
),
|
||||||
<th className="px-3 py-2 text-left font-medium bg-muted" {...props} />
|
td: ({ ...props }) => (
|
||||||
),
|
<td className="px-3 py-2 border-t border-border" {...props} />
|
||||||
td: ({ node, ...props }: any) => (
|
),
|
||||||
<td className="px-3 py-2 border-t border-border" {...props} />
|
code: ({ className, children, ...props }) => {
|
||||||
),
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
code: ({ node, className, children, ...props }: any) => {
|
const isInline = !match;
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
|
||||||
const language = match ? match[1] : "";
|
|
||||||
const isInline = !match;
|
|
||||||
|
|
||||||
if (isInline) {
|
if (isInline) {
|
||||||
return (
|
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For code blocks, add syntax highlighting and copy functionality
|
|
||||||
return (
|
return (
|
||||||
<CodeBlock language={language} {...props}>
|
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
|
||||||
{String(children).replace(/\n$/, "")}
|
{children}
|
||||||
</CodeBlock>
|
</code>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
};
|
|
||||||
}, []);
|
// For code blocks, let Streamdown handle syntax highlighting
|
||||||
|
return (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("prose prose-sm dark:prose-invert max-w-none", className)} ref={ref}>
|
<div className={cn("prose prose-sm dark:prose-invert max-w-none overflow-hidden [&_pre]:overflow-x-auto [&_code]:wrap-break-word [&_table]:block [&_table]:overflow-x-auto", className)}>
|
||||||
<ReactMarkdown
|
<Streamdown
|
||||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={components}
|
components={components}
|
||||||
|
shikiTheme={["github-light", "github-dark"]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</Streamdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code block component with syntax highlighting and copy functionality
|
|
||||||
const CodeBlock = ({ children, language }: { children: string; language: string }) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const { resolvedTheme, theme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
// Prevent hydration issues
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
await navigator.clipboard.writeText(children);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Choose theme based on current system/user preference
|
|
||||||
const isDarkTheme = mounted && (resolvedTheme === "dark" || theme === "dark");
|
|
||||||
const syntaxTheme = isDarkTheme ? oneDark : oneLight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative my-4 group">
|
|
||||||
<div className="absolute right-2 top-2 z-10">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1.5 rounded-md bg-background/80 hover:bg-background border border-border flex items-center justify-center transition-colors"
|
|
||||||
aria-label="Copy code"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check size={14} className="text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy size={14} className="text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{mounted ? (
|
|
||||||
<SyntaxHighlighter
|
|
||||||
language={language || "text"}
|
|
||||||
style={{
|
|
||||||
...syntaxTheme,
|
|
||||||
'pre[class*="language-"]': {
|
|
||||||
...syntaxTheme['pre[class*="language-"]'],
|
|
||||||
margin: 0,
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
background: "var(--syntax-bg)",
|
|
||||||
},
|
|
||||||
'code[class*="language-"]': {
|
|
||||||
...syntaxTheme['code[class*="language-"]'],
|
|
||||||
border: "none",
|
|
||||||
background: "var(--syntax-bg)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1.5rem",
|
|
||||||
backgroundColor: "var(--syntax-bg)",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
codeTagProps={{
|
|
||||||
className: "font-mono",
|
|
||||||
style: {
|
|
||||||
border: "none",
|
|
||||||
background: "var(--syntax-bg)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
showLineNumbers={false}
|
|
||||||
wrapLines={false}
|
|
||||||
lineProps={{
|
|
||||||
style: {
|
|
||||||
wordBreak: "break-all",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
border: "none",
|
|
||||||
borderBottom: "none",
|
|
||||||
paddingLeft: 0,
|
|
||||||
paddingRight: 0,
|
|
||||||
margin: "0.25rem 0",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
PreTag="div"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
) : (
|
|
||||||
<div className="bg-muted p-4 rounded-md">
|
|
||||||
<pre className="m-0 p-0 border-0">
|
|
||||||
<code className="text-xs font-mono border-0 leading-6">{children}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
546
surfsense_web/components/new-chat/source-detail-panel.tsx
Normal file
546
surfsense_web/components/new-chat/source-detail-panel.tsx
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
BookOpen,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SourceDetailPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
chunkId: number;
|
||||||
|
sourceType: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
url?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDocumentType = (type: string) => {
|
||||||
|
if (!type) return "";
|
||||||
|
return type
|
||||||
|
.split("_")
|
||||||
|
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chunk card component with enhanced animations
|
||||||
|
const ChunkCard = ({
|
||||||
|
chunk,
|
||||||
|
index,
|
||||||
|
totalChunks,
|
||||||
|
isCited,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
chunk: { id: number; content: string };
|
||||||
|
index: number;
|
||||||
|
totalChunks: number;
|
||||||
|
isCited: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}) => {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
data-chunk-index={index}
|
||||||
|
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 15,
|
||||||
|
delay: shouldReduceMotion ? 0 : Math.min(index * 0.05, 0.3),
|
||||||
|
}}
|
||||||
|
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 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 15, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Badge variant="default" className="gap-1.5 px-3 py-1">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
Cited Source
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-5 overflow-hidden">
|
||||||
|
<MarkdownViewer content={chunk.content} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SourceDetailPanel({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
chunkId,
|
||||||
|
sourceType,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
children,
|
||||||
|
}: SourceDetailPanelProps) {
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||||
|
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: documentData,
|
||||||
|
isLoading: isDocumentByChunkFetching,
|
||||||
|
error: documentByChunkFetchingError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
|
||||||
|
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
|
||||||
|
enabled: !!chunkId && open,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Auto-scroll to cited chunk when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentData?.chunks && citedChunkIndex !== -1 && !hasScrolledToCited && open) {
|
||||||
|
// Wait for animations to complete then scroll
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const chunkElement = scrollAreaRef.current?.querySelector(
|
||||||
|
`[data-chunk-index="${citedChunkIndex}"]`
|
||||||
|
);
|
||||||
|
if (chunkElement) {
|
||||||
|
chunkElement.scrollIntoView({
|
||||||
|
behavior: shouldReduceMotion ? "auto" : "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
setHasScrolledToCited(true);
|
||||||
|
setActiveChunkIndex(citedChunkIndex);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [documentData, citedChunkIndex, hasScrolledToCited, open, shouldReduceMotion]);
|
||||||
|
|
||||||
|
// Reset scroll state when panel closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setHasScrolledToCited(false);
|
||||||
|
setActiveChunkIndex(null);
|
||||||
|
}
|
||||||
|
}, [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) => {
|
||||||
|
setActiveChunkIndex(index);
|
||||||
|
const chunkElement = scrollAreaRef.current?.querySelector(
|
||||||
|
`[data-chunk-index="${index}"]`
|
||||||
|
);
|
||||||
|
chunkElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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
|
||||||
|
? formatDocumentType(documentData.document_type)
|
||||||
|
: sourceType && formatDocumentType(sourceType)}
|
||||||
|
{documentData?.chunks && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• {documentData.chunks.length} chunk{documentData.chunks.length !== 1 ? "s" : ""}
|
||||||
|
</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="rounded-xl h-10 w-10 hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">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-destructive/10 flex items-center justify-center">
|
||||||
|
<X className="h-10 w-10 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-destructive text-lg">Failed to load document</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 */}
|
||||||
|
{documentData.chunks.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"
|
||||||
|
>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2 pt-3 flex flex-col gap-1.5">
|
||||||
|
{documentData.chunks.map((chunk, idx) => {
|
||||||
|
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 ${idx + 1} (Cited)` : `Chunk ${idx + 1}`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
{isCited && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-background">
|
||||||
|
<Sparkles className="h-2 w-2 text-primary-foreground absolute top-0.5 left-0.5" />
|
||||||
|
</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 */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Chunks */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{documentData.chunks.map((chunk, idx) => (
|
||||||
|
<ChunkCard
|
||||||
|
key={chunk.id}
|
||||||
|
chunk={chunk}
|
||||||
|
index={idx}
|
||||||
|
totalChunks={documentData.chunks.length}
|
||||||
|
isCited={chunk.id === chunkId}
|
||||||
|
isActive={activeChunkIndex === idx}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{createPortal(panelContent, globalThis.document.body)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
"streamdown": "^1.6.10",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
|
|
|
||||||
1118
surfsense_web/pnpm-lock.yaml
generated
1118
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -33,3 +33,4 @@
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue