- {connector.sources?.slice(0, INITIAL_SOURCES_DISPLAY)?.map((source: any, index: number) => (
-
-
-
- {getConnectorIcon(connector.type)}
-
-
-
{source.title}
-
{source.description}
-
-
-
-
- ))}
+ if (message.role === "assistant") {
+ return (
+
+
+
+
+ Answer
+
+
+
+ {/* Status Messages Section */}
+
+
+
+
+
setTerminalExpanded(false)}
+ >
+
+
setTerminalExpanded(true)}
+ >
+
+
+ surfsense-research-terminal
+
+
+
- {connector.sources?.length > INITIAL_SOURCES_DISPLAY && (
-
- )}
-
-
- ))}
-
- );
- })()}
-
+
+
+ Last login: {currentDate} {currentTime}
+
+
+
+ researcher@surfsense
+
+ :
+ ~/research
+ $
+ surfsense-researcher
+
- {/* Answer Section */}
-
- {
-
- {message.annotations && (() => {
- // Get all ANSWER annotations
- const answerAnnotations = (message.annotations as any[])
- .filter(a => a.type === 'ANSWER');
+ {renderTerminalContent(message)}
- // Get the latest ANSWER annotation
- const latestAnswer = answerAnnotations.length > 0
- ? answerAnnotations[answerAnnotations.length - 1]
- : null;
+
+
+ [00:13]
+
+
+ researcher@surfsense
+
+
:
+
~/research
+
$
+
+
- // If we have a latest ANSWER annotation with content, render it
- if (latestAnswer?.content && latestAnswer.content.length > 0) {
- return (
-
getCitationSource(id, index)}
- />
- );
- }
+ {/* Terminal scroll button */}
+
+
+
+
+ {/* Sources Section with Connector Tabs */}
+
+
+
+ Sources
+
+
+ {(() => {
+ // Get sources for this specific message
+ const messageConnectorSources =
+ getMessageConnectorSources(message);
+
+ if (messageConnectorSources.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // Use these message-specific sources for the Tabs component
+ return (
+
0
+ ? messageConnectorSources[0].type
+ : undefined
+ }
+ className="w-full"
+ >
+
+
+
+
+
+
+
+ {messageConnectorSources.map(
+ (connector) => (
+
+ {getConnectorIcon(connector.type)}
+
+ {connector.name.split(" ")[0]}
+
+
+ {connector.sources?.length || 0}
+
+
+ ),
+ )}
+
+
+
+
+
+
+
+
+ {messageConnectorSources.map((connector) => (
+
+
+ {connector.sources
+ ?.slice(0, INITIAL_SOURCES_DISPLAY)
+ ?.map((source: any, index: number) => (
+
+
+
+ {getConnectorIcon(connector.type)}
+
+
+
+ {source.title}
+
+
+ {source.description}
+
+
+
+
+
+ ))}
+
+ {connector.sources?.length >
+ INITIAL_SOURCES_DISPLAY && (
+
+ )}
+
+
+ ))}
+
+ );
+ })()}
+
+
+ {/* Answer Section */}
+
+ {
+
+ {message.annotations &&
+ (() => {
+ // Get all ANSWER annotations
+ const answerAnnotations = (
+ message.annotations as any[]
+ ).filter((a) => a.type === "ANSWER");
+
+ // Get the latest ANSWER annotation
+ const latestAnswer =
+ answerAnnotations.length > 0
+ ? answerAnnotations[
+ answerAnnotations.length - 1
+ ]
+ : null;
+
+ // If we have a latest ANSWER annotation with content, render it
+ if (
+ latestAnswer?.content &&
+ latestAnswer.content.length > 0
+ ) {
+ return (
+
+ getCitationSource(id, index)
+ }
+ type="ai"
+ />
+ );
+ }
// Fallback to the message content if no ANSWER annotation is available
return getCitationSource(id, index)}
+ type="ai"
/>;
})()}
@@ -1276,518 +1456,618 @@ const ChatPage = () => {
);
}
- return null;
- })}
+ return null;
+ })}
- {/* New Chat Input Form */}
-
-
-
-
- {/* Enhanced Document Selection Dialog */}
-
- >
- );
+ {/* Research Mode Control */}
+
+
+
+
+ {/* Fast LLM Selector */}
+
+
+
+
+
+
+
+ {/* Reference for auto-scrolling */}
+
+
+ >
+ );
};
-export default ChatPage;
\ No newline at end of file
+export default ChatPage;
diff --git a/surfsense_web/components/copy-button.tsx b/surfsense_web/components/copy-button.tsx
new file mode 100644
index 0000000..7842f6a
--- /dev/null
+++ b/surfsense_web/components/copy-button.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { useEffect, useRef, useState } from "react";
+import type { RefObject } from "react";
+import { Button } from "./ui/button";
+import { Copy, CopyCheck } from "lucide-react";
+
+export default function CopyButton({
+ ref,
+}: {
+ ref: RefObject
;
+}) {
+ const [copy, setCopy] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleClick = () => {
+ if (ref.current) {
+ const text = ref.current.innerText;
+ navigator.clipboard.writeText(text);
+
+ setCopy(true);
+ timeoutRef.current = setTimeout(() => {
+ setCopy(false);
+ }, 2000);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx
index f4bebf9..2e75e77 100644
--- a/surfsense_web/components/markdown-viewer.tsx
+++ b/surfsense_web/components/markdown-viewer.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState, useEffect } from "react";
+import React, { useMemo, useState, useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
@@ -7,267 +7,350 @@ import { cn } from "@/lib/utils";
import { Citation } from "./chat/Citation";
import { Source } from "./chat/types";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
-import { oneLight, oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
+import {
+ oneLight,
+ oneDark,
+} from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Check, Copy } from "lucide-react";
import { useTheme } from "next-themes";
+import CopyButton from "./copy-button";
interface MarkdownViewerProps {
- content: string;
- className?: string;
- getCitationSource?: (id: number) => Source | null;
+ content: string;
+ className?: string;
+ getCitationSource?: (id: number) => Source | null;
+ type?: "user" | "ai";
}
-export function MarkdownViewer({ content, className, getCitationSource }: MarkdownViewerProps) {
- // Memoize the markdown components to prevent unnecessary re-renders
- const components = useMemo(() => {
- return {
- // Define custom components for markdown elements
- p: ({node, children, ...props}: any) => {
- // If there's no getCitationSource function, just render normally
- if (!getCitationSource) {
- return {children}
;
- }
-
- // Process citations within paragraph content
- return {processCitationsInReactChildren(children, getCitationSource)}
;
- },
- a: ({node, children, ...props}: any) => {
- // Process citations within link content if needed
- const processedChildren = getCitationSource
- ? processCitationsInReactChildren(children, getCitationSource)
- : children;
- return {processedChildren};
- },
- li: ({node, children, ...props}: any) => {
- // Process citations within list item content
- const processedChildren = getCitationSource
- ? processCitationsInReactChildren(children, getCitationSource)
- : children;
- return {processedChildren};
- },
- ul: ({node, ...props}: any) => ,
- ol: ({node, ...props}: any) =>
,
- h1: ({node, children, ...props}: any) => {
- const processedChildren = getCitationSource
- ? processCitationsInReactChildren(children, getCitationSource)
- : children;
- return {processedChildren}
;
- },
- h2: ({node, children, ...props}: any) => {
- const processedChildren = getCitationSource
- ? processCitationsInReactChildren(children, getCitationSource)
- : children;
- return {processedChildren}
;
- },
- h3: ({node, children, ...props}: any) => {
- const processedChildren = getCitationSource
- ? processCitationsInReactChildren(children, getCitationSource)
- : children;
- return {processedChildren}
;
- },
- h4: ({node, children, ...props}: any) => {
- const processedChildren = getCitationSource
- ? processCitationsInReactChildren(children, getCitationSource)
- : children;
- return {processedChildren}
;
- },
- blockquote: ({node, ...props}: any) => ,
- hr: ({node, ...props}: any) =>
,
- img: ({node, ...props}: any) =>
,
- table: ({node, ...props}: any) => ,
- th: ({node, ...props}: any) => | ,
- td: ({node, ...props}: any) => | ,
- code: ({node, className, children, ...props}: any) => {
- const match = /language-(\w+)/.exec(className || '');
- const language = match ? match[1] : '';
- const isInline = !match;
-
- if (isInline) {
- return {children}
;
- }
-
- // For code blocks, add syntax highlighting and copy functionality
- return (
-
- {String(children).replace(/\n$/, '')}
-
- );
- }
- };
- }, [getCitationSource]);
+export function MarkdownViewer({
+ content,
+ className,
+ getCitationSource,
+ type = "user",
+}: MarkdownViewerProps) {
+ const ref = useRef(null);
+ // Memoize the markdown components to prevent unnecessary re-renders
+ const components = useMemo(() => {
+ return {
+ // Define custom components for markdown elements
+ p: ({ node, children, ...props }: any) => {
+ // If there's no getCitationSource function, just render normally
+ if (!getCitationSource) {
+ return (
+
+ {children}
+
+ );
+ }
- return (
-
-
- {content}
-
-
- );
+ // Process citations within paragraph content
+ return (
+
+ {processCitationsInReactChildren(children, getCitationSource)}
+
+ );
+ },
+ a: ({ node, children, ...props }: any) => {
+ // Process citations within link content if needed
+ const processedChildren = getCitationSource
+ ? processCitationsInReactChildren(children, getCitationSource)
+ : children;
+ return (
+
+ {processedChildren}
+
+ );
+ },
+ li: ({ node, children, ...props }: any) => {
+ // Process citations within list item content
+ const processedChildren = getCitationSource
+ ? processCitationsInReactChildren(children, getCitationSource)
+ : children;
+ return {processedChildren};
+ },
+ ul: ({ node, ...props }: any) => (
+
+ ),
+ ol: ({ node, ...props }: any) => (
+
+ ),
+ h1: ({ node, children, ...props }: any) => {
+ const processedChildren = getCitationSource
+ ? processCitationsInReactChildren(children, getCitationSource)
+ : children;
+ return (
+
+ {processedChildren}
+
+ );
+ },
+ h2: ({ node, children, ...props }: any) => {
+ const processedChildren = getCitationSource
+ ? processCitationsInReactChildren(children, getCitationSource)
+ : children;
+ return (
+
+ {processedChildren}
+
+ );
+ },
+ h3: ({ node, children, ...props }: any) => {
+ const processedChildren = getCitationSource
+ ? processCitationsInReactChildren(children, getCitationSource)
+ : children;
+ return (
+
+ {processedChildren}
+
+ );
+ },
+ h4: ({ node, children, ...props }: any) => {
+ const processedChildren = getCitationSource
+ ? processCitationsInReactChildren(children, getCitationSource)
+ : children;
+ return (
+
+ {processedChildren}
+
+ );
+ },
+ blockquote: ({ node, ...props }: any) => (
+
+ ),
+ hr: ({ node, ...props }: any) => (
+
+ ),
+ img: ({ node, ...props }: any) => (
+
+ ),
+ table: ({ node, ...props }: any) => (
+
+ ),
+ th: ({ node, ...props }: any) => (
+ |
+ ),
+ td: ({ node, ...props }: any) => (
+ |
+ ),
+ code: ({ node, className, children, ...props }: any) => {
+ const match = /language-(\w+)/.exec(className || "");
+ const language = match ? match[1] : "";
+ const isInline = !match;
+
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ // For code blocks, add syntax highlighting and copy functionality
+ return (
+
+ {String(children).replace(/\n$/, "")}
+
+ );
+ },
+ };
+ }, [getCitationSource]);
+
+ return (
+
+
+ {content}
+
+ {type === "ai" && }
+
+ );
}
// 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);
+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);
- }, []);
+ // Prevent hydration issues
+ useEffect(() => {
+ setMounted(true);
+ }, []);
- const handleCopy = async () => {
- await navigator.clipboard.writeText(children);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
+ 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;
+ // Choose theme based on current system/user preference
+ const isDarkTheme = mounted && (resolvedTheme === "dark" || theme === "dark");
+ const syntaxTheme = isDarkTheme ? oneDark : oneLight;
- return (
-
-
-
-
- {mounted ? (
-
- {children}
-
- ) : (
-
- )}
-
- );
+ return (
+
+
+
+
+ {mounted ? (
+
+ {children}
+
+ ) : (
+
+ )}
+
+ );
};
// Helper function to process citations within React children
-const processCitationsInReactChildren = (children: React.ReactNode, getCitationSource: (id: number) => Source | null): React.ReactNode => {
- // If children is not an array or string, just return it
- if (!children || (typeof children !== 'string' && !Array.isArray(children))) {
- return children;
- }
-
- // Handle string content directly - this is where we process citation references
- if (typeof children === 'string') {
- return processCitationsInText(children, getCitationSource);
- }
-
- // Handle arrays of children recursively
- if (Array.isArray(children)) {
- return React.Children.map(children, child => {
- if (typeof child === 'string') {
- return processCitationsInText(child, getCitationSource);
- }
- return child;
- });
- }
-
- return children;
+const processCitationsInReactChildren = (
+ children: React.ReactNode,
+ getCitationSource: (id: number) => Source | null,
+): React.ReactNode => {
+ // If children is not an array or string, just return it
+ if (!children || (typeof children !== "string" && !Array.isArray(children))) {
+ return children;
+ }
+
+ // Handle string content directly - this is where we process citation references
+ if (typeof children === "string") {
+ return processCitationsInText(children, getCitationSource);
+ }
+
+ // Handle arrays of children recursively
+ if (Array.isArray(children)) {
+ return React.Children.map(children, (child) => {
+ if (typeof child === "string") {
+ return processCitationsInText(child, getCitationSource);
+ }
+ return child;
+ });
+ }
+
+ return children;
};
// Process citation references in text content
-const processCitationsInText = (text: string, getCitationSource: (id: number) => Source | null): React.ReactNode[] => {
- // Use improved regex to catch citation numbers more reliably
- // This will match patterns like [1], [42], etc. including when they appear at the end of a line or sentence
- const citationRegex = /\[(\d+)\]/g;
- const parts: React.ReactNode[] = [];
- let lastIndex = 0;
- let match;
- let position = 0;
-
- while ((match = citationRegex.exec(text)) !== null) {
- // Add text before the citation
- if (match.index > lastIndex) {
- parts.push(text.substring(lastIndex, match.index));
- }
-
- // Add the citation component
- const citationId = parseInt(match[1], 10);
- const source = getCitationSource(citationId);
-
- parts.push(
-
- );
-
- lastIndex = match.index + match[0].length;
- position++;
- }
-
- // Add any remaining text after the last citation
- if (lastIndex < text.length) {
- parts.push(text.substring(lastIndex));
- }
-
- return parts;
-};
\ No newline at end of file
+const processCitationsInText = (
+ text: string,
+ getCitationSource: (id: number) => Source | null,
+): React.ReactNode[] => {
+ // Use improved regex to catch citation numbers more reliably
+ // This will match patterns like [1], [42], etc. including when they appear at the end of a line or sentence
+ const citationRegex = /\[(\d+)\]/g;
+ const parts: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match;
+ let position = 0;
+
+ while ((match = citationRegex.exec(text)) !== null) {
+ // Add text before the citation
+ if (match.index > lastIndex) {
+ parts.push(text.substring(lastIndex, match.index));
+ }
+
+ // Add the citation component
+ const citationId = parseInt(match[1], 10);
+ const source = getCitationSource(citationId);
+
+ parts.push(
+ ,
+ );
+
+ lastIndex = match.index + match[0].length;
+ position++;
+ }
+
+ // Add any remaining text after the last citation
+ if (lastIndex < text.length) {
+ parts.push(text.substring(lastIndex));
+ }
+
+ return parts;
+};
diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml
index 6848cb3..5effec5 100644
--- a/surfsense_web/pnpm-lock.yaml
+++ b/surfsense_web/pnpm-lock.yaml
@@ -4354,8 +4354,8 @@ packages:
tailwind-merge@3.2.0:
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
- tailwind-merge@3.3.0:
- resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
+ tailwind-merge@3.3.1:
+ resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
@@ -6869,7 +6869,7 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
- tailwind-merge: 3.3.0
+ tailwind-merge: 3.3.1
tsup: 6.7.0(postcss@8.5.3)(typescript@5.8.2)
transitivePeerDependencies:
- '@swc/core'
@@ -9411,7 +9411,7 @@ snapshots:
tailwind-merge@3.2.0: {}
- tailwind-merge@3.3.0: {}
+ tailwind-merge@3.3.1: {}
tailwindcss-animate@1.0.7(tailwindcss@4.0.9):
dependencies: