From bf22156664dc59ab00cecbb6d8dfacc1b89f8c0e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 12:18:45 +0530 Subject: [PATCH 01/14] refactor: update navigation and chat initialization logic - Changed navigation from researcher to new-chat in EditorPage. - Updated NewChatPage to support lazy thread creation on first message. - Adjusted breadcrumb to not display thread ID for new-chat sections. - Improved user experience by modifying error messages and loading states. - Enhanced styling in model-selector component for better visibility. --- .../editor/[documentId]/page.tsx | 8 +-- .../new-chat/[[...chat_id]]/page.tsx | 54 ++++++++++++------- .../components/assistant-ui/thread.tsx | 19 ++++--- .../components/dashboard-breadcrumb.tsx | 5 +- .../components/new-chat/model-selector.tsx | 2 +- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 8fc2fb825..235420fcb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -323,7 +323,7 @@ export default function EditorPage() { if (hasUnsavedChanges) { setShowUnsavedDialog(true); } else { - router.push(`/dashboard/${searchSpaceId}/researcher`); + router.push(`/dashboard/${searchSpaceId}/new-chat`); } }; @@ -333,12 +333,12 @@ export default function EditorPage() { setGlobalHasUnsavedChanges(false); setHasUnsavedChanges(false); - // If there's a pending navigation (from sidebar), use that; otherwise go back to researcher + // If there's a pending navigation (from sidebar), use that; otherwise go back to chat if (pendingNavigation) { router.push(pendingNavigation); setPendingNavigation(null); } else { - router.push(`/dashboard/${searchSpaceId}/researcher`); + router.push(`/dashboard/${searchSpaceId}/new-chat`); } }; @@ -379,7 +379,7 @@ export default function EditorPage() { - - ))} - {/* Text input */} - + 0 - ? "Ask about these documents..." - : "Ask SurfSense (type @ to mention docs)" - } - className="aui-composer-input flex-1 min-w-[120px] max-h-32 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0 py-1" - rows={1} - autoFocus - aria-label="Message input" + className="min-h-[24px]" /> @@ -605,11 +571,11 @@ const Composer: FC = () => { style={{ zIndex: 9999, backgroundColor: "#18181b", - bottom: inputRef.current - ? `${window.innerHeight - inputRef.current.getBoundingClientRect().top + 8}px` + bottom: editorContainerRef.current + ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` : "200px", - left: inputRef.current - ? `${inputRef.current.getBoundingClientRect().left}px` + left: editorContainerRef.current + ? `${editorContainerRef.current.getBoundingClientRect().left}px` : "50%", }} > From 7c8e269190940a1a058d81907e681820fd598873 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:19:22 +0530 Subject: [PATCH 03/14] refactor: enhance InlineMentionEditor and Composer auto-focus functionality - Refactored InlineMentionEditor to improve caret position handling and mention triggering logic. - Removed unnecessary caret position saving and restoring functions for cleaner code. - Updated mention handling to ensure proper closing of mention popover based on cursor position. - Added auto-focus feature in Composer for new chat threads, enhancing user experience. - Implemented checks to reset auto-focus when the thread is no longer empty. --- .../assistant-ui/inline-mention-editor.tsx | 95 ++++++++++--------- .../components/assistant-ui/thread.tsx | 23 +++++ 2 files changed, 74 insertions(+), 44 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 2e5d1938d..56926de5e 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -1,14 +1,16 @@ "use client"; -import { X } from "lucide-react"; import { forwardRef, useCallback, + createElement, useEffect, useImperativeHandle, useRef, useState, } from "react"; +import { X } from "lucide-react"; +import ReactDOMServer from "react-dom/server"; import type { Document } from "@/contracts/types/document.types"; import { cn } from "@/lib/utils"; @@ -65,7 +67,6 @@ export const InlineMentionEditor = forwardRef new Map(initialDocuments.map((d) => [d.id, d])) ); const isComposingRef = useRef(false); - const lastCaretPositionRef = useRef<{ node: Node; offset: number } | null>(null); // Sync initial documents useEffect(() => { @@ -74,36 +75,6 @@ export const InlineMentionEditor = forwardRef { - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - lastCaretPositionRef.current = { - node: range.startContainer, - offset: range.startOffset, - }; - } - }, []); - - // Restore caret position - const restoreCaretPosition = useCallback(() => { - if (lastCaretPositionRef.current && editorRef.current) { - const { node, offset } = lastCaretPositionRef.current; - try { - const selection = window.getSelection(); - const range = document.createRange(); - range.setStart(node, offset); - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - } catch { - // Node might not exist anymore, focus at end - focusAtEnd(); - } - } - }, []); - // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; @@ -139,11 +110,12 @@ export const InlineMentionEditor = forwardRef`; + removeBtn.innerHTML = ReactDOMServer.renderToString( + createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 }) + ); removeBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); @@ -248,8 +222,8 @@ export const InlineMentionEditor = forwardRef 0) { const range = selection.getRangeAt(0); const textNode = range.startContainer; @@ -350,16 +327,26 @@ export const InlineMentionEditor = forwardRef 0 && textContent[0] === "@") { + // Will delete @, close mention popover + setTimeout(() => { + onMentionClose?.(); + }, 0); + } + } else if (node.nodeType === Node.TEXT_NODE && offset > 0) { + // Check if we're about to delete @ + const textContent = node.textContent || ""; + if (textContent[offset - 1] === "@") { + // Will delete @, close mention popover + setTimeout(() => { + onMentionClose?.(); + }, 0); } } else if (node.nodeType === Node.ELEMENT_NODE && offset > 0) { // Check if previous child is a chip @@ -425,7 +430,7 @@ export const InlineMentionEditor = forwardRef + {/** biome-ignore lint/a11y/useSemanticElements: */}
{ const { search_space_id } = useParams(); const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const composerRuntime = useComposerRuntime(); + const hasAutoFocusedRef = useRef(false); + + // Check if thread is empty (new chat) + const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); + + // Auto-focus editor when on new chat page + useEffect(() => { + if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { + // Small delay to ensure the editor is fully mounted + const timeoutId = setTimeout(() => { + editorRef.current?.focus(); + hasAutoFocusedRef.current = true; + }, 100); + return () => clearTimeout(timeoutId); + } + }, [isThreadEmpty]); + + // Reset auto-focus flag when thread becomes non-empty (user sent a message) + useEffect(() => { + if (!isThreadEmpty) { + hasAutoFocusedRef.current = false; + } + }, [isThreadEmpty]); // Sync mentioned document IDs to atom for use in chat request useEffect(() => { From eb6192d4804daf535929a31eafa6cda6cb40dc41 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:29:44 +0530 Subject: [PATCH 04/14] chore: fix formatting --- .../assistant-ui/inline-mention-editor.tsx | 86 ++++++++++--------- .../components/assistant-ui/thread.tsx | 5 +- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 56926de5e..524d4470a 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -90,7 +90,7 @@ export const InlineMentionEditor = forwardRef { if (!editorRef.current) return ""; - + let text = ""; const walker = document.createTreeWalker( editorRef.current, @@ -127,47 +127,50 @@ export const InlineMentionEditor = forwardRef { - const chip = document.createElement("span"); - chip.setAttribute(CHIP_DATA_ATTR, "true"); - chip.setAttribute(CHIP_ID_ATTR, String(doc.id)); - chip.contentEditable = "false"; - chip.className = - "inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none"; - chip.style.userSelect = "none"; - chip.style.verticalAlign = "baseline"; + const createChipElement = useCallback( + (doc: MentionedDocument): HTMLSpanElement => { + const chip = document.createElement("span"); + chip.setAttribute(CHIP_DATA_ATTR, "true"); + chip.setAttribute(CHIP_ID_ATTR, String(doc.id)); + chip.contentEditable = "false"; + chip.className = + "inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none"; + chip.style.userSelect = "none"; + chip.style.verticalAlign = "baseline"; - const titleSpan = document.createElement("span"); - titleSpan.className = "max-w-[80px] truncate"; - titleSpan.textContent = doc.title; - titleSpan.title = doc.title; + const titleSpan = document.createElement("span"); + titleSpan.className = "max-w-[80px] truncate"; + titleSpan.textContent = doc.title; + titleSpan.title = doc.title; - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = - "size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5"; - removeBtn.innerHTML = ReactDOMServer.renderToString( - createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 }) - ); - removeBtn.onclick = (e) => { - e.preventDefault(); - e.stopPropagation(); - chip.remove(); - setMentionedDocs((prev) => { - const next = new Map(prev); - next.delete(doc.id); - return next; - }); - // Notify parent that a document was removed - onDocumentRemove?.(doc.id); - focusAtEnd(); - }; + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = + "size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5"; + removeBtn.innerHTML = ReactDOMServer.renderToString( + createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 }) + ); + removeBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + chip.remove(); + setMentionedDocs((prev) => { + const next = new Map(prev); + next.delete(doc.id); + return next; + }); + // Notify parent that a document was removed + onDocumentRemove?.(doc.id); + focusAtEnd(); + }; - chip.appendChild(titleSpan); - chip.appendChild(removeBtn); + chip.appendChild(titleSpan); + chip.appendChild(removeBtn); - return chip; - }, [focusAtEnd, onDocumentRemove]); + return chip; + }, + [focusAtEnd, onDocumentRemove] + ); // Insert a document chip at the current cursor position const insertDocumentChip = useCallback( @@ -222,8 +225,8 @@ export const InlineMentionEditor = forwardRef { {/* -------- Inline Mention Editor -------- */} -
+
Date: Thu, 25 Dec 2025 17:52:48 +0530 Subject: [PATCH 05/14] feat: enhance chat functionality with improved attachment handling and user experience - Updated system prompt to clarify usage of the display_image tool, emphasizing URL requirements and restrictions on user-uploaded images. - Enhanced the streaming chat process to provide more context about user attachments and documents during analysis. - Implemented state resets when switching between chats to prevent stale data and race conditions. - Added new components for displaying image previews and document attachments in the chat interface. - Improved attachment processing to support image data URLs for persistent display after uploads. --- .../app/agents/new_chat/system_prompt.py | 8 +- .../app/tasks/chat/stream_new_chat.py | 74 +++++-- .../new-chat/[[...chat_id]]/page.tsx | 19 +- .../components/assistant-ui/attachment.tsx | 92 ++++++++- .../components/assistant-ui/thread.tsx | 25 ++- .../prompt-kit/chain-of-thought.tsx | 195 ++++++++++++++++-- surfsense_web/lib/chat/attachment-adapter.ts | 29 +++ 7 files changed, 389 insertions(+), 53 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 91b4eee08..61a8fbdd6 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -64,14 +64,18 @@ You have access to the following tools: - The preview card will automatically be displayed in the chat. 4. display_image: Display an image in the chat with metadata. - - Use this tool when you want to show an image to the user. + - Use this tool when you want to show an image from a URL to the user. - This displays the image with an optional title, description, and source attribution. - Common use cases: * Showing an image from a URL mentioned in the conversation * Displaying a diagram, chart, or illustration you're referencing * Showing visual examples when explaining concepts + - IMPORTANT: Do NOT use this tool for user-uploaded image attachments! + * User attachments are already visible in the chat UI - the user can see them + * This tool requires a valid HTTP/HTTPS URL, not a local file path + * When a user uploads an image, just analyze it and respond - don't try to display it again - Args: - - src: The URL of the image to display (must be a valid HTTP/HTTPS image URL) + - src: The URL of the image to display (must be a valid HTTP/HTTPS image URL, not a local path) - alt: Alternative text describing the image (for accessibility) - title: Optional title to display below the image - description: Optional description providing context about the image diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 2038e85dc..aff6fa32b 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -255,13 +255,59 @@ async def stream_new_chat( # Initial thinking step - analyzing the request analyze_step_id = next_thinking_step_id() last_active_step_id = analyze_step_id - last_active_step_title = "Understanding your request" - last_active_step_items = [ - f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}" - ] + + # Determine step title and action verb based on context + if attachments and mentioned_documents: + last_active_step_title = "Analyzing your content" + action_verb = "Reading" + elif attachments: + last_active_step_title = "Reading your content" + action_verb = "Reading" + elif mentioned_documents: + last_active_step_title = "Analyzing referenced content" + action_verb = "Analyzing" + else: + last_active_step_title = "Understanding your request" + action_verb = "Processing" + + # Build the message with inline context about attachments/documents + processing_parts = [] + + # Add the user query + query_text = user_query[:80] + ("..." if len(user_query) > 80 else "") + processing_parts.append(query_text) + + # Add file attachment names inline + if attachments: + attachment_names = [] + for attachment in attachments: + name = attachment.name + if len(name) > 30: + name = name[:27] + "..." + attachment_names.append(name) + if len(attachment_names) == 1: + processing_parts.append(f"[{attachment_names[0]}]") + else: + processing_parts.append(f"[{len(attachment_names)} files]") + + # Add mentioned document names inline + if mentioned_documents: + doc_names = [] + for doc in mentioned_documents: + title = doc.title + if len(title) > 30: + title = title[:27] + "..." + doc_names.append(title) + if len(doc_names) == 1: + processing_parts.append(f"[{doc_names[0]}]") + else: + processing_parts.append(f"[{len(doc_names)} documents]") + + last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] + yield streaming_service.format_thinking_step( step_id=analyze_step_id, - title="Understanding your request", + title=last_active_step_title, status="in_progress", items=last_active_step_items, ) @@ -369,13 +415,13 @@ async def stream_new_chat( if isinstance(tool_input, dict) else "" ) - last_active_step_title = "Displaying image" + last_active_step_title = "Analyzing the image" last_active_step_items = [ - f"Image: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}" + f"Analyzing: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}" ] yield streaming_service.format_thinking_step( step_id=tool_step_id, - title="Displaying image", + title="Analyzing the image", status="in_progress", items=last_active_step_items, ) @@ -471,7 +517,7 @@ async def stream_new_chat( else str(tool_input) ) yield streaming_service.format_terminal_info( - f"Displaying image: {src[:60]}{'...' if len(src) > 60 else ''}", + f"Analyzing image: {src[:60]}{'...' if len(src) > 60 else ''}", "info", ) elif tool_name == "scrape_webpage": @@ -575,20 +621,20 @@ async def stream_new_chat( items=completed_items, ) elif tool_name == "display_image": - # Build completion items for image display + # Build completion items for image analysis if isinstance(tool_output, dict): title = tool_output.get("title", "") alt = tool_output.get("alt", "Image") display_name = title or alt completed_items = [ *last_active_step_items, - f"Showing: {display_name[:50]}{'...' if len(display_name) > 50 else ''}", + f"Analyzed: {display_name[:50]}{'...' if len(display_name) > 50 else ''}", ] else: - completed_items = [*last_active_step_items, "Image displayed"] + completed_items = [*last_active_step_items, "Image analyzed"] yield streaming_service.format_thinking_step( step_id=original_step_id, - title="Displaying image", + title="Analyzing the image", status="completed", items=completed_items, ) @@ -744,7 +790,7 @@ async def stream_new_chat( "alt", "Image" ) yield streaming_service.format_terminal_info( - f"Image displayed: {title[:40]}{'...' if len(title) > 40 else ''}", + f"Image analyzed: {title[:40]}{'...' if len(title) > 40 else ''}", "success", ) elif tool_name == "scrape_webpage": diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 4a344ec36..9687d2b4d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -171,6 +171,14 @@ export default function NewChatPage() { const initializeThread = useCallback(async () => { setIsInitializing(true); + // Reset all state when switching between chats to prevent stale data + setMessages([]); + setThreadId(null); + setMessageThinkingSteps(new Map()); + setMentionedDocumentIds([]); + setMentionedDocuments([]); + setMessageDocumentsMap({}); + try { if (urlChatId > 0) { // Thread exists - load messages @@ -219,7 +227,7 @@ export default function NewChatPage() { } finally { setIsInitializing(false); } - }, [urlChatId, setMessageDocumentsMap]); + }, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments]); // Initialize on mount useEffect(() => { @@ -238,6 +246,13 @@ export default function NewChatPage() { // Handle new message from user const onNew = useCallback( async (message: AppendMessage) => { + // Abort any previous streaming request to prevent race conditions + // when user sends a second query while the first is still streaming + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + // Extract user query text from content parts let userQuery = ""; for (const part of message.content) { @@ -297,6 +312,8 @@ export default function NewChatPage() { role: "user", content: message.content, createdAt: new Date(), + // Include attachments so they can be displayed + attachments: message.attachments || [], }; setMessages((prev) => [...prev, userMessage]); diff --git a/surfsense_web/components/assistant-ui/attachment.tsx b/surfsense_web/components/assistant-ui/attachment.tsx index dfb63cbf3..152f54127 100644 --- a/surfsense_web/components/assistant-ui/attachment.tsx +++ b/surfsense_web/components/assistant-ui/attachment.tsx @@ -41,13 +41,23 @@ const useAttachmentSrc = () => { const { file, src } = useAssistantState( useShallow(({ attachment }): { file?: File; src?: string } => { if (!attachment || attachment.type !== "image") return {}; + + // First priority: use File object if available (for new uploads) if (attachment.file) return { file: attachment.file }; - // Only try to filter if content is an array (standard assistant-ui format) - // Our custom ChatAttachment has content as a string, so skip this - if (Array.isArray(attachment.content)) { - const src = attachment.content.filter((c) => c.type === "image")[0]?.image; - if (src) return { src }; + + // Second priority: use stored imageDataUrl (for persisted messages) + // This is stored in our custom ChatAttachment interface + const customAttachment = attachment as { imageDataUrl?: string }; + if (customAttachment.imageDataUrl) { + return { src: customAttachment.imageDataUrl }; } + + // Third priority: try to extract from content array (standard assistant-ui format) + if (Array.isArray(attachment.content)) { + const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image; + if (contentSrc) return { src: contentSrc }; + } + return {}; }) ); @@ -218,11 +228,77 @@ const AttachmentRemove: FC = () => { ); }; +/** + * Image attachment with preview thumbnail (click to expand) + */ +const MessageImageAttachment: FC = () => { + const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Image"); + const src = useAttachmentSrc(); + + if (!src) return null; + + return ( + +
+ {attachmentName} + {/* Hover overlay with filename */} +
+
+ + {attachmentName} + +
+
+
+
+ ); +}; + +/** + * Document/file attachment as chip (similar to mentioned documents) + */ +const MessageDocumentAttachment: FC = () => { + const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Attachment"); + + return ( + + + + {attachmentName} + + + ); +}; + +/** + * Attachment component for user messages + * Shows image preview for images, chip for documents + */ +const MessageAttachmentChip: FC = () => { + const isImage = useAssistantState(({ attachment }) => attachment?.type === "image"); + + if (isImage) { + return ; + } + + return ; +}; + export const UserMessageAttachments: FC = () => { return ( -
- -
+ ); }; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index a2b1e0425..1d4ebed6d 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -426,6 +426,9 @@ const Composer: FC = () => { // Check if thread is empty (new chat) const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); + // Check if thread is currently running (streaming response) + const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + // Auto-focus editor when on new chat page useEffect(() => { if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { @@ -504,6 +507,10 @@ const Composer: FC = () => { // Handle submit from inline editor (Enter key) const handleSubmit = useCallback(() => { + // Prevent sending while a response is still streaming + if (isThreadRunning) { + return; + } if (!showDocumentPopover) { composerRuntime.send(); // Clear the editor after sending @@ -511,7 +518,7 @@ const Composer: FC = () => { setMentionedDocuments([]); setMentionedDocumentIds([]); } - }, [showDocumentPopover, composerRuntime, setMentionedDocuments, setMentionedDocumentIds]); + }, [showDocumentPopover, isThreadRunning, composerRuntime, setMentionedDocuments, setMentionedDocumentIds]); // Handle document removal from inline editor const handleDocumentRemove = useCallback( @@ -973,19 +980,23 @@ const UserMessage: FC = () => { const messageId = useAssistantState(({ message }) => message?.id); const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom); const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; + const hasAttachments = useAssistantState( + ({ message }) => message?.attachments && message.attachments.length > 0 + ); return ( - -
- {/* Display mentioned documents as chips */} - {mentionedDocs && mentionedDocs.length > 0 && ( -
- {mentionedDocs.map((doc) => ( + {/* Display attachments and mentioned documents */} + {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && ( +
+ {/* Attachments (images show as thumbnails, documents as chips) */} + + {/* Mentioned documents as chips */} + {mentionedDocs?.map((doc) => ( { + const timer = setTimeout(() => setIsVisible(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + return isVisible; +} + +/** + * Get file icon based on file extension (all icons are muted/gray) + */ +function getFileIcon(name: string): React.ReactNode { + const ext = name.split(".").pop()?.toLowerCase() || ""; + + // PDF / Word documents + if (ext === "pdf" || ["doc", "docx"].includes(ext)) { + return ; + } + // Spreadsheets + if (["xls", "xlsx", "csv"].includes(ext)) { + return ; + } + // Images + if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) { + return ; + } + // Audio + if (["mp3", "wav", "m4a", "ogg", "webm"].includes(ext)) { + return ; + } + // Video + if (["mp4", "mov", "avi", "mkv"].includes(ext)) { + return ; + } + // Code files + if (["js", "ts", "tsx", "jsx", "py", "html", "css", "json", "md"].includes(ext)) { + return ; + } + // Default + return ; +} + +/** + * Compact attachment tile component - matches the chat UI style + */ +const AttachmentTile: React.FC<{ name: string }> = ({ name }) => { + const icon = getFileIcon(name); + + return ( + + {icon} + {name} + + ); +}; + +/** + * Parse text and render bracketed items (like [filename.pdf]) as styled tiles + */ +function parseAndRenderWithBadges(text: string): React.ReactNode { + // Match patterns like [filename.ext] or [N files] or [N documents] + const regex = /\[([^\]]+)\]/g; + const matches = Array.from(text.matchAll(regex)); + + if (matches.length === 0) { + return text; + } + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + for (const match of matches) { + const matchIndex = match.index ?? 0; + + // Add text before the match + if (matchIndex > lastIndex) { + parts.push(text.slice(lastIndex, matchIndex)); + } + + const content = match[1]; + + // Render as a compact tile matching chat UI style with file-type colors + parts.push(); + + lastIndex = matchIndex + match[0].length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; +} + export type ChainOfThoughtItemProps = React.ComponentProps<"div">; export const ChainOfThoughtItem = ({ children, className, ...props }: ChainOfThoughtItemProps) => ( -
- {children} +
+ {typeof children === "string" ? parseAndRenderWithBadges(children) : children}
); @@ -80,9 +186,28 @@ export const ChainOfThoughtContent = ({ {...props} >
-
+ {/* Animated vertical connection line */} +
-
{children}
+
+ {React.Children.map(children, (child, index) => { + const key = React.isValidElement(child) ? child.key : `cot-item-${index}`; + return ( +
+ {child} +
+ ); + })} +
); @@ -98,14 +223,19 @@ export function ChainOfThought({ children, className }: ChainOfThoughtProps) { return (
- {childrenArray.map((child, index) => ( - - {React.isValidElement(child) && - React.cloneElement(child as React.ReactElement, { - isLast: index === childrenArray.length - 1, - })} - - ))} + {childrenArray.map((child, index) => { + // React.Children.toArray assigns stable keys to each child + const key = React.isValidElement(child) ? child.key : `cot-step-${index}`; + return ( + + {React.isValidElement(child) && + React.cloneElement(child as React.ReactElement, { + isLast: index === childrenArray.length - 1, + stepIndex: index, + })} + + ); + })}
); } @@ -114,19 +244,42 @@ export type ChainOfThoughtStepProps = { children: React.ReactNode; className?: string; isLast?: boolean; + /** Index of the step for staggered animation */ + stepIndex?: number; }; export const ChainOfThoughtStep = ({ children, className, isLast = false, + stepIndex = 0, ...props }: ChainOfThoughtStepProps & React.ComponentProps) => { + // Staggered entrance animation based on step index + const isVisible = useEntranceAnimation(stepIndex * 50); + return ( - + {children} + {/* Animated connection line to next step */}
-
+
); diff --git a/surfsense_web/lib/chat/attachment-adapter.ts b/surfsense_web/lib/chat/attachment-adapter.ts index b31af9116..f084af411 100644 --- a/surfsense_web/lib/chat/attachment-adapter.ts +++ b/surfsense_web/lib/chat/attachment-adapter.ts @@ -60,9 +60,11 @@ interface ProcessAttachmentResponse { /** * Extended CompleteAttachment with our custom extractedContent field * We store the extracted text in a custom field so we can access it in onNew + * For images, we also store the data URL so it can be displayed after persistence */ export interface ChatAttachment extends CompleteAttachment { extractedContent: string; + imageDataUrl?: string; // Base64 data URL for images (persists across page reloads) } /** @@ -118,6 +120,21 @@ async function processAttachment(file: File): Promise // Store processed results for the send() method const processedAttachments = new Map(); +// Store image data URLs for attachments (so they persist after File objects are lost) +const imageDataUrls = new Map(); + +/** + * Convert a File to a data URL (base64) for images + */ +async function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + /** * Create the attachment adapter for assistant-ui * @@ -170,6 +187,12 @@ export function createAttachmentAdapter(): AttachmentAdapter { } as PendingAttachment; try { + // For images, convert to data URL so we can display them after persistence + if (attachmentType === "image") { + const dataUrl = await fileToDataUrl(file); + imageDataUrls.set(id, dataUrl); + } + // Process the file through the backend ETL service const result = await processAttachment(file); @@ -204,10 +227,14 @@ export function createAttachmentAdapter(): AttachmentAdapter { */ async send(pendingAttachment: PendingAttachment): Promise { const result = processedAttachments.get(pendingAttachment.id); + const imageDataUrl = imageDataUrls.get(pendingAttachment.id); if (result) { // Clean up stored result processedAttachments.delete(pendingAttachment.id); + if (imageDataUrl) { + imageDataUrls.delete(pendingAttachment.id); + } return { id: result.id, @@ -222,6 +249,7 @@ export function createAttachmentAdapter(): AttachmentAdapter { }, ], extractedContent: result.content, + imageDataUrl, // Store data URL for images so they can be displayed after persistence }; } @@ -238,6 +266,7 @@ export function createAttachmentAdapter(): AttachmentAdapter { status: { type: "complete" }, content: [], extractedContent: "", + imageDataUrl, // Still include data URL if available }; }, From 5c4a445456f0b852d62891a5a1aeeb4b606965f6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:26:36 +0530 Subject: [PATCH 06/14] refactor: streamline ThinkingStepsDisplay and ChainOfThought components - Removed unnecessary calculations for in-progress and last completed step states in ThinkingStepsDisplay. - Simplified the getStepOpenState function to focus on current step status. - Introduced constants for animation timing and file extension categories in ChainOfThought for better maintainability. - Enhanced icon retrieval logic based on file types and step statuses, improving clarity and performance. - Updated component props to use TypeScript interfaces for better type safety and readability. --- .../components/assistant-ui/thread.tsx | 21 +- .../prompt-kit/chain-of-thought.tsx | 179 ++++++++--- .../components/tool-ui/deepagent-thinking.tsx | 284 ++++++++++++------ 3 files changed, 327 insertions(+), 157 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 1d4ebed6d..d2b82000f 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -147,15 +147,6 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea return step.status; }; - // Check if any step is effectively in progress - const hasInProgressStep = steps.some((step) => getEffectiveStatus(step) === "in_progress"); - - // Find the last completed step index (using effective status) - const lastCompletedIndex = steps - .map((s, i) => (getEffectiveStatus(s) === "completed" ? i : -1)) - .filter((i) => i !== -1) - .pop(); - // Clear manual overrides when a step's status changes useEffect(() => { const currentStatuses: Record = {}; @@ -175,7 +166,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea if (steps.length === 0) return null; - const getStepOpenState = (step: ThinkingStep, index: number): boolean => { + const getStepOpenState = (step: ThinkingStep): boolean => { const effectiveStatus = getEffectiveStatus(step); // If user has manually toggled, respect that if (manualOverrides[step.id] !== undefined) { @@ -185,11 +176,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea if (effectiveStatus === "in_progress") { return true; } - // Auto behavior: keep last completed step open if no in-progress step - if (!hasInProgressStep && index === lastCompletedIndex) { - return true; - } - // Default: collapsed + // Default: collapsed (all steps collapse when processing is done) return false; }; @@ -203,10 +190,10 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea return (
- {steps.map((step, index) => { + {steps.map((step) => { const effectiveStatus = getEffectiveStatus(step); const icon = getStepIcon(effectiveStatus, step.title); - const isOpen = getStepOpenState(step, index); + const isOpen = getStepOpenState(step); return ( { @@ -30,44 +62,60 @@ function useEntranceAnimation(delay = 0) { return isVisible; } +// ============================================================================ +// File Icon Utilities +// ============================================================================ + +/** + * Check if an extension belongs to a specific category + */ +function isExtensionInCategory( + ext: string, + category: FileExtensionCategory +): boolean { + return (FILE_EXTENSIONS[category] as readonly string[]).includes(ext); +} + /** * Get file icon based on file extension (all icons are muted/gray) */ function getFileIcon(name: string): React.ReactNode { - const ext = name.split(".").pop()?.toLowerCase() || ""; + const ext = name.split(".").pop()?.toLowerCase() ?? ""; - // PDF / Word documents - if (ext === "pdf" || ["doc", "docx"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "DOCUMENT")) { + return ; } - // Spreadsheets - if (["xls", "xlsx", "csv"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "SPREADSHEET")) { + return ; } - // Images - if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "IMAGE")) { + return ; } - // Audio - if (["mp3", "wav", "m4a", "ogg", "webm"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "AUDIO")) { + return ; } - // Video - if (["mp4", "mov", "avi", "mkv"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "VIDEO")) { + return ; } - // Code files - if (["js", "ts", "tsx", "jsx", "py", "html", "css", "json", "md"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "CODE")) { + return ; } - // Default - return ; + return ; +} + +// ============================================================================ +// Attachment Components +// ============================================================================ + +interface AttachmentTileProps { + /** File name to display */ + name: string; } /** * Compact attachment tile component - matches the chat UI style */ -const AttachmentTile: React.FC<{ name: string }> = ({ name }) => { +const AttachmentTile: React.FC = ({ name }) => { const icon = getFileIcon(name); return ( @@ -120,26 +168,46 @@ function parseAndRenderWithBadges(text: string): React.ReactNode { return parts; } -export type ChainOfThoughtItemProps = React.ComponentProps<"div">; +// ============================================================================ +// Chain of Thought Components +// ============================================================================ -export const ChainOfThoughtItem = ({ children, className, ...props }: ChainOfThoughtItemProps) => ( -
+export interface ChainOfThoughtItemProps + extends React.HTMLAttributes { + children: React.ReactNode; +} + +export const ChainOfThoughtItem: React.FC = ({ + children, + className, + ...props +}) => ( +
{typeof children === "string" ? parseAndRenderWithBadges(children) : children}
); -export type ChainOfThoughtTriggerProps = React.ComponentProps & { +export interface ChainOfThoughtTriggerProps + extends React.ComponentProps { + /** Optional icon to display on the left side */ leftIcon?: React.ReactNode; + /** Whether to swap the icon with chevron on hover */ swapIconOnHover?: boolean; -}; +} -export const ChainOfThoughtTrigger = ({ +export const ChainOfThoughtTrigger: React.FC = ({ children, className, leftIcon, swapIconOnHover = true, ...props -}: ChainOfThoughtTriggerProps) => ( +}) => ( ); -export type ChainOfThoughtContentProps = React.ComponentProps; +export interface ChainOfThoughtContentProps + extends React.ComponentProps {} -export const ChainOfThoughtContent = ({ +export const ChainOfThoughtContent: React.FC = ({ children, className, ...props -}: ChainOfThoughtContentProps) => { +}) => { return ( {child}
@@ -213,12 +285,15 @@ export const ChainOfThoughtContent = ({ ); }; -export type ChainOfThoughtProps = { +export interface ChainOfThoughtProps { children: React.ReactNode; className?: string; -}; +} -export function ChainOfThought({ children, className }: ChainOfThoughtProps) { +export const ChainOfThought: React.FC = ({ + children, + className, +}) => { const childrenArray = React.Children.toArray(children); return ( @@ -238,25 +313,31 @@ export function ChainOfThought({ children, className }: ChainOfThoughtProps) { })}
); -} - -export type ChainOfThoughtStepProps = { - children: React.ReactNode; - className?: string; - isLast?: boolean; - /** Index of the step for staggered animation */ - stepIndex?: number; }; -export const ChainOfThoughtStep = ({ +export interface ChainOfThoughtStepProps + extends Omit, "children"> { + children: React.ReactNode; + className?: string; + /** Whether this is the last step (hides connection line) */ + isLast?: boolean; + /** Index of the step for staggered animation timing */ + stepIndex?: number; +} + +export const ChainOfThoughtStep: React.FC = ({ children, className, isLast = false, stepIndex = 0, ...props -}: ChainOfThoughtStepProps & React.ComponentProps) => { +}) => { // Staggered entrance animation based on step index - const isVisible = useEntranceAnimation(stepIndex * 50); + const isVisible = useEntranceAnimation(stepIndex * ANIMATION.STAGGER_DELAY_MS); + + // Calculate connection line delay: step delay + additional offset + const connectionLineDelay = + stepIndex * ANIMATION.STAGGER_DELAY_MS + ANIMATION.CONNECTION_LINE_DELAY_MS; return (
diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 573837bce..22c4777f7 100644 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx @@ -2,7 +2,8 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import type { FC, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { ChainOfThought, @@ -13,14 +14,59 @@ import { } from "@/components/prompt-kit/chain-of-thought"; import { cn } from "@/lib/utils"; -/** - * Zod schemas for runtime validation - */ +// ============================================================================ +// Constants +// ============================================================================ + +/** Step status values */ +const STEP_STATUS = { + PENDING: "pending", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", +} as const; + +/** Agent thinking status values */ +const THINKING_STATUS = { + THINKING: "thinking", + SEARCHING: "searching", + SYNTHESIZING: "synthesizing", + COMPLETED: "completed", +} as const; + +/** Keywords for icon detection */ +const STEP_KEYWORDS = { + SEARCH: ["search", "knowledge"] as const, + ANALYSIS: ["analy", "understand"] as const, +} as const; + +/** Icon size class */ +const ICON_SIZE_CLASS = "size-4" as const; + +/** Status text mapping */ +const STATUS_TEXT_MAP: Record = { + [THINKING_STATUS.SEARCHING]: "Searching knowledge base...", + [THINKING_STATUS.SYNTHESIZING]: "Synthesizing response...", + [THINKING_STATUS.THINKING]: "Thinking...", +} as const; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +type StepStatus = (typeof STEP_STATUS)[keyof typeof STEP_STATUS]; +type ThinkingStatus = (typeof THINKING_STATUS)[keyof typeof THINKING_STATUS]; + +// ============================================================================ +// Zod Schemas +// ============================================================================ + const ThinkingStepSchema = z.object({ id: z.string(), title: z.string(), items: z.array(z.string()).default([]), - status: z.enum(["pending", "in_progress", "completed"]).default("pending"), + status: z + .enum([STEP_STATUS.PENDING, STEP_STATUS.IN_PROGRESS, STEP_STATUS.COMPLETED]) + .default(STEP_STATUS.PENDING), }); const DeepAgentThinkingArgsSchema = z.object({ @@ -30,17 +76,34 @@ const DeepAgentThinkingArgsSchema = z.object({ const DeepAgentThinkingResultSchema = z.object({ steps: z.array(ThinkingStepSchema).optional(), - status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(), + status: z + .enum([ + THINKING_STATUS.THINKING, + THINKING_STATUS.SEARCHING, + THINKING_STATUS.SYNTHESIZING, + THINKING_STATUS.COMPLETED, + ]) + .optional(), summary: z.string().optional(), }); -/** - * Types derived from Zod schemas - */ +/** Types derived from Zod schemas */ type ThinkingStep = z.infer; type DeepAgentThinkingArgs = z.infer; type DeepAgentThinkingResult = z.infer; +// ============================================================================ +// Parser Functions +// ============================================================================ + +/** Default fallback step when parsing fails */ +const DEFAULT_FALLBACK_STEP: ThinkingStep = { + id: "unknown", + title: "Processing...", + items: [], + status: STEP_STATUS.PENDING, +} as const; + /** * Parse and validate a single thinking step */ @@ -48,13 +111,7 @@ export function parseThinkingStep(data: unknown): ThinkingStep { const result = ThinkingStepSchema.safeParse(data); if (!result.success) { console.warn("Invalid thinking step data:", result.error.issues); - // Return a fallback step - return { - id: "unknown", - title: "Processing...", - items: [], - status: "pending", - }; + return DEFAULT_FALLBACK_STEP; } return result.data; } @@ -71,55 +128,79 @@ export function parseThinkingResult(data: unknown): DeepAgentThinkingResult { return result.data; } +// ============================================================================ +// Icon Utilities +// ============================================================================ + /** - * Get icon based on step status and type + * Check if title contains any of the keywords */ -function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) { - // Check for specific step types based on title keywords +function titleContainsKeywords( + title: string, + keywords: readonly string[] +): boolean { const titleLower = title.toLowerCase(); + return keywords.some((keyword) => titleLower.includes(keyword)); +} - if (status === "in_progress") { - return ; +/** + * Get icon based on step status and title + */ +function getStepIcon(status: StepStatus, title: string): ReactNode { + if (status === STEP_STATUS.IN_PROGRESS) { + return ; } - if (status === "completed") { - return ; + if (status === STEP_STATUS.COMPLETED) { + return ; } - // Default icons based on step type - if (titleLower.includes("search") || titleLower.includes("knowledge")) { - return ; + // Default icons based on step type keywords + if (titleContainsKeywords(title, STEP_KEYWORDS.SEARCH)) { + return ; } - if (titleLower.includes("analy") || titleLower.includes("understand")) { - return ; + if (titleContainsKeywords(title, STEP_KEYWORDS.ANALYSIS)) { + return ; } - return ; + return ; +} + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface ThinkingStepDisplayProps { + step: ThinkingStep; + isOpen: boolean; + onToggle: () => void; } /** * Component to display a single thinking step with controlled open state */ -function ThinkingStepDisplay({ +const ThinkingStepDisplay: FC = ({ step, isOpen, onToggle, -}: { - step: ThinkingStep; - isOpen: boolean; - onToggle: () => void; -}) { - const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]); +}) => { + const icon = useMemo( + () => getStepIcon(step.status, step.title), + [step.status, step.title] + ); + + const isInProgress = step.status === STEP_STATUS.IN_PROGRESS; + const isCompleted = step.status === STEP_STATUS.COMPLETED; return ( {step.title} @@ -131,22 +212,21 @@ function ThinkingStepDisplay({ ); +}; + +interface ThinkingLoadingStateProps { + status?: ThinkingStatus | string; } /** * Loading state with animated thinking indicator */ -function ThinkingLoadingState({ status }: { status?: string }) { +const ThinkingLoadingState: FC = ({ status }) => { const statusText = useMemo(() => { - switch (status) { - case "searching": - return "Searching knowledge base..."; - case "synthesizing": - return "Synthesizing response..."; - case "thinking": - default: - return "Thinking..."; + if (status && status in STATUS_TEXT_MAP) { + return STATUS_TEXT_MAP[status]; } + return STATUS_TEXT_MAP[THINKING_STATUS.THINKING]; }, [status]); return ( @@ -161,33 +241,35 @@ function ThinkingLoadingState({ status }: { status?: string }) { {statusText}
); +}; + +interface SmartChainOfThoughtProps { + steps: ThinkingStep[]; } +/** Type for tracking step override states */ +type StepOverrides = Record; + +/** Type for tracking step status history */ +type StepStatusHistory = Record; + /** * Smart chain of thought renderer with state management */ -function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) { +const SmartChainOfThought: FC = ({ steps }) => { // Track which steps the user has manually toggled - const [manualOverrides, setManualOverrides] = useState>({}); + const [manualOverrides, setManualOverrides] = useState({}); // Track previous step statuses to detect changes - const prevStatusesRef = useRef>({}); - - // Check if any step is currently in progress - const hasInProgressStep = steps.some((step) => step.status === "in_progress"); - - // Find the last completed step index - const lastCompletedIndex = steps - .map((s, i) => (s.status === "completed" ? i : -1)) - .filter((i) => i !== -1) - .pop(); + const prevStatusesRef = useRef({}); // Clear manual overrides when a step's status changes useEffect(() => { - const currentStatuses: Record = {}; + const currentStatuses: StepStatusHistory = {}; steps.forEach((step) => { currentStatuses[step.id] = step.status; // If status changed, clear any manual override for this step - if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) { + const prevStatus = prevStatusesRef.current[step.id]; + if (prevStatus && prevStatus !== step.status) { setManualOverrides((prev) => { const next = { ...prev }; delete next[step.id]; @@ -198,34 +280,33 @@ function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) { prevStatusesRef.current = currentStatuses; }, [steps]); - const getStepOpenState = (step: ThinkingStep, index: number): boolean => { - // If user has manually toggled, respect that - if (manualOverrides[step.id] !== undefined) { - return manualOverrides[step.id]; - } - // Auto behavior: open if in progress - if (step.status === "in_progress") { - return true; - } - // Auto behavior: keep last completed step open if no in-progress step - if (!hasInProgressStep && index === lastCompletedIndex) { - return true; - } - // Default: collapsed - return false; - }; + const getStepOpenState = useCallback( + (step: ThinkingStep): boolean => { + // If user has manually toggled, respect that + if (manualOverrides[step.id] !== undefined) { + return manualOverrides[step.id]; + } + // Auto behavior: open if in progress + if (step.status === STEP_STATUS.IN_PROGRESS) { + return true; + } + // Default: collapsed (all steps collapse when processing is done) + return false; + }, + [manualOverrides] + ); - const handleToggle = (stepId: string, currentOpen: boolean) => { + const handleToggle = useCallback((stepId: string, currentOpen: boolean) => { setManualOverrides((prev) => ({ ...prev, [stepId]: !currentOpen, })); - }; + }, []); return ( - {steps.map((step, index) => { - const isOpen = getStepOpenState(step, index); + {steps.map((step) => { + const isOpen = getStepOpenState(step); return ( ); -} +}; /** * DeepAgent Thinking Tool UI Component @@ -281,21 +362,30 @@ export const DeepAgentThinkingToolUI = makeAssistantToolUI< }, }); +// ============================================================================ +// Public Components +// ============================================================================ + +export interface InlineThinkingDisplayProps { + /** The thinking steps to display */ + steps: ThinkingStep[]; + /** Whether content is currently streaming */ + isStreaming?: boolean; + /** Additional CSS class names */ + className?: string; +} + /** * Inline Thinking Display Component * * A simpler version that can be used inline with the message content * for displaying reasoning without the full tool UI infrastructure. */ -export function InlineThinkingDisplay({ +export const InlineThinkingDisplay: FC = ({ steps, isStreaming = false, className, -}: { - steps: ThinkingStep[]; - isStreaming?: boolean; - className?: string; -}) { +}) => { if (steps.length === 0 && !isStreaming) { return null; } @@ -309,6 +399,18 @@ export function InlineThinkingDisplay({ )}
); -} +}; -export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult }; +// ============================================================================ +// Exports +// ============================================================================ + +export type { + ThinkingStep, + DeepAgentThinkingArgs, + DeepAgentThinkingResult, + StepStatus, + ThinkingStatus, +}; + +export { STEP_STATUS, THINKING_STATUS }; From 8fae4b1742f330789007a15ff13af7e1d5c3eadf Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:37:35 +0530 Subject: [PATCH 07/14] refactor: update audio component for improved volume control and UI - Removed the play overlay button from the artwork section for a cleaner design. - Replaced the existing volume slider with a custom volume bar for better visual distinction. - Enhanced the volume control layout for improved user experience and accessibility. --- surfsense_web/components/tool-ui/audio.tsx | 41 +++++++++++----------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/surfsense_web/components/tool-ui/audio.tsx b/surfsense_web/components/tool-ui/audio.tsx index 19098d5b8..4b7679cd6 100644 --- a/surfsense_web/components/tool-ui/audio.tsx +++ b/surfsense_web/components/tool-ui/audio.tsx @@ -188,19 +188,6 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
)} - {/* Play overlay on artwork */} -
@@ -254,17 +241,29 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN {/* Volume control */} -
+
- + {/* Custom volume bar - visually distinct from progress slider */} +
+
+
+
+ handleVolumeChange([Number.parseFloat(e.target.value)])} + className="absolute inset-0 h-full w-full cursor-pointer opacity-0" + aria-label="Volume" + /> +
From ee5d14c86c6e91ba14ee109cb75278e4fae9a950 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:28:46 +0530 Subject: [PATCH 08/14] refactor: improve type safety and structure in editor page - Updated BlockNote document handling by defining TypeScript types for better clarity and maintainability. - Refactored extractTitleFromBlockNote function to utilize the new types, enhancing type safety. - Changed state management for editorContent to use the defined BlockNoteDocument type. - Adjusted layout classes for improved responsiveness and visual consistency in the editor interface. --- .../editor/[documentId]/page.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 235420fcb..8ac20a606 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -25,17 +25,29 @@ import { Separator } from "@/components/ui/separator"; import { notesApiService } from "@/lib/apis/notes-api.service"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +// BlockNote types +type BlockNoteInlineContent = string | { text?: string; type?: string; styles?: Record }; + +interface BlockNoteBlock { + type: string; + content?: BlockNoteInlineContent[]; + children?: BlockNoteBlock[]; + props?: Record; +} + +type BlockNoteDocument = BlockNoteBlock[] | null | undefined; + interface EditorContent { document_id: number; title: string; document_type?: string; - blocknote_document: any; + blocknote_document: BlockNoteDocument; updated_at: string | null; } // Helper function to extract title from BlockNote document // Takes the text content from the first block (should be a heading for notes) -function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string { +function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string { if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) { return "Untitled"; } @@ -49,9 +61,9 @@ function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): // BlockNote blocks have a content array with inline content if (firstBlock.content && Array.isArray(firstBlock.content)) { const textContent = firstBlock.content - .map((item: any) => { + .map((item: BlockNoteInlineContent) => { if (typeof item === "string") return item; - if (item?.text) return item.text; + if (typeof item === "object" && item?.text) return item.text; return ""; }) .join("") @@ -73,7 +85,7 @@ export default function EditorPage() { const [document, setDocument] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [editorContent, setEditorContent] = useState(null); + const [editorContent, setEditorContent] = useState(null); const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); @@ -410,7 +422,7 @@ export default function EditorPage() { {/* Toolbar */}
@@ -444,7 +456,7 @@ export default function EditorPage() {
{/* Editor Container */} -
+
{error && ( Date: Thu, 25 Dec 2025 19:32:18 +0530 Subject: [PATCH 09/14] chore: run biome lints --- .../editor/[documentId]/page.tsx | 4 +++- .../components/assistant-ui/attachment.tsx | 12 +++++------- .../components/assistant-ui/thread.tsx | 8 +++++++- .../components/prompt-kit/chain-of-thought.tsx | 18 ++++-------------- .../components/tool-ui/deepagent-thinking.tsx | 16 +++------------- 5 files changed, 22 insertions(+), 36 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 8ac20a606..1f51d9975 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -26,7 +26,9 @@ import { notesApiService } from "@/lib/apis/notes-api.service"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; // BlockNote types -type BlockNoteInlineContent = string | { text?: string; type?: string; styles?: Record }; +type BlockNoteInlineContent = + | string + | { text?: string; type?: string; styles?: Record }; interface BlockNoteBlock { type: string; diff --git a/surfsense_web/components/assistant-ui/attachment.tsx b/surfsense_web/components/assistant-ui/attachment.tsx index 152f54127..9750b24d9 100644 --- a/surfsense_web/components/assistant-ui/attachment.tsx +++ b/surfsense_web/components/assistant-ui/attachment.tsx @@ -41,23 +41,23 @@ const useAttachmentSrc = () => { const { file, src } = useAssistantState( useShallow(({ attachment }): { file?: File; src?: string } => { if (!attachment || attachment.type !== "image") return {}; - + // First priority: use File object if available (for new uploads) if (attachment.file) return { file: attachment.file }; - + // Second priority: use stored imageDataUrl (for persisted messages) // This is stored in our custom ChatAttachment interface const customAttachment = attachment as { imageDataUrl?: string }; if (customAttachment.imageDataUrl) { return { src: customAttachment.imageDataUrl }; } - + // Third priority: try to extract from content array (standard assistant-ui format) if (Array.isArray(attachment.content)) { const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image; if (contentSrc) return { src: contentSrc }; } - + return {}; }) ); @@ -297,9 +297,7 @@ const MessageAttachmentChip: FC = () => { }; export const UserMessageAttachments: FC = () => { - return ( - - ); + return ; }; export const ComposerAttachments: FC = () => { diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d2b82000f..d59835649 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -505,7 +505,13 @@ const Composer: FC = () => { setMentionedDocuments([]); setMentionedDocumentIds([]); } - }, [showDocumentPopover, isThreadRunning, composerRuntime, setMentionedDocuments, setMentionedDocumentIds]); + }, [ + showDocumentPopover, + isThreadRunning, + composerRuntime, + setMentionedDocuments, + setMentionedDocumentIds, + ]); // Handle document removal from inline editor const handleDocumentRemove = useCallback( diff --git a/surfsense_web/components/prompt-kit/chain-of-thought.tsx b/surfsense_web/components/prompt-kit/chain-of-thought.tsx index d7b466a02..ca9698b31 100644 --- a/surfsense_web/components/prompt-kit/chain-of-thought.tsx +++ b/surfsense_web/components/prompt-kit/chain-of-thought.tsx @@ -69,10 +69,7 @@ function useEntranceAnimation(delay = 0): boolean { /** * Check if an extension belongs to a specific category */ -function isExtensionInCategory( - ext: string, - category: FileExtensionCategory -): boolean { +function isExtensionInCategory(ext: string, category: FileExtensionCategory): boolean { return (FILE_EXTENSIONS[category] as readonly string[]).includes(ext); } @@ -172,8 +169,7 @@ function parseAndRenderWithBadges(text: string): React.ReactNode { // Chain of Thought Components // ============================================================================ -export interface ChainOfThoughtItemProps - extends React.HTMLAttributes { +export interface ChainOfThoughtItemProps extends React.HTMLAttributes { children: React.ReactNode; } @@ -183,10 +179,7 @@ export const ChainOfThoughtItem: React.FC = ({ ...props }) => (
{typeof children === "string" ? parseAndRenderWithBadges(children) : children} @@ -290,10 +283,7 @@ export interface ChainOfThoughtProps { className?: string; } -export const ChainOfThought: React.FC = ({ - children, - className, -}) => { +export const ChainOfThought: React.FC = ({ children, className }) => { const childrenArray = React.Children.toArray(children); return ( diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 22c4777f7..5694035bc 100644 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx @@ -135,10 +135,7 @@ export function parseThinkingResult(data: unknown): DeepAgentThinkingResult { /** * Check if title contains any of the keywords */ -function titleContainsKeywords( - title: string, - keywords: readonly string[] -): boolean { +function titleContainsKeywords(title: string, keywords: readonly string[]): boolean { const titleLower = title.toLowerCase(); return keywords.some((keyword) => titleLower.includes(keyword)); } @@ -180,15 +177,8 @@ interface ThinkingStepDisplayProps { /** * Component to display a single thinking step with controlled open state */ -const ThinkingStepDisplay: FC = ({ - step, - isOpen, - onToggle, -}) => { - const icon = useMemo( - () => getStepIcon(step.status, step.title), - [step.status, step.title] - ); +const ThinkingStepDisplay: FC = ({ step, isOpen, onToggle }) => { + const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]); const isInProgress = step.status === STEP_STATUS.IN_PROGRESS; const isCompleted = step.status === STEP_STATUS.COMPLETED; From 4c2de73694a8dd35c140eea34474363b801fe3b6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:40:43 +0530 Subject: [PATCH 10/14] refactor: update UI components for consistent styling --- surfsense_web/components/assistant-ui/thread.tsx | 3 +-- surfsense_web/components/new-chat/DocumentsDataTable.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d59835649..e6e9be45d 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -587,10 +587,9 @@ const Composer: FC = () => { /> {/* Popover positioned above input */}
Date: Fri, 26 Dec 2025 00:07:45 +0530 Subject: [PATCH 11/14] refactor: enhance link preview functionality with Chromium fallback - Added a fallback mechanism using headless Chromium to fetch page content when standard HTTP requests fail. - Introduced utility functions for unescaping HTML entities and converting relative URLs to absolute. - Updated HTTP request headers to mimic a browser for better compatibility with web servers. - Improved error handling and logging for better debugging and user feedback. - Made various properties in Zod schemas nullable for better type safety and flexibility in handling optional data. --- .../app/agents/new_chat/tools/link_preview.py | 206 +++++++++++++++--- .../components/tool-ui/article/index.tsx | 26 +-- .../components/tool-ui/deepagent-thinking.tsx | 12 +- .../components/tool-ui/display-image.tsx | 2 +- .../components/tool-ui/generate-podcast.tsx | 32 +-- .../components/tool-ui/image/index.tsx | 59 +++-- .../components/tool-ui/media-card/index.tsx | 20 +- 7 files changed, 271 insertions(+), 86 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py index 188863015..90b5da1d7 100644 --- a/surfsense_backend/app/agents/new_chat/tools/link_preview.py +++ b/surfsense_backend/app/agents/new_chat/tools/link_preview.py @@ -6,13 +6,19 @@ Open Graph image, etc.) to display rich link previews in the chat UI. """ import hashlib +import logging import re from typing import Any from urllib.parse import urlparse import httpx +import trafilatura +from fake_useragent import UserAgent +from langchain_community.document_loaders import AsyncChromiumLoader from langchain_core.tools import tool +logger = logging.getLogger(__name__) + def extract_domain(url: str) -> str: """Extract the domain from a URL.""" @@ -138,6 +144,96 @@ def generate_preview_id(url: str) -> str: return f"link-preview-{hash_val}" +def _unescape_html(text: str) -> str: + """Unescape common HTML entities.""" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'") + .replace("'", "'") + ) + + +def _make_absolute_url(image_url: str, base_url: str) -> str: + """Convert a relative image URL to an absolute URL.""" + if image_url.startswith(("http://", "https://")): + return image_url + if image_url.startswith("//"): + return f"https:{image_url}" + if image_url.startswith("/"): + parsed = urlparse(base_url) + return f"{parsed.scheme}://{parsed.netloc}{image_url}" + return image_url + + +async def fetch_with_chromium(url: str) -> dict[str, Any] | None: + """ + Fetch page content using headless Chromium browser. + Used as a fallback when simple HTTP requests are blocked (403, etc.). + + Args: + url: URL to fetch + + Returns: + Dict with title, description, image, and raw_html, or None if failed + """ + try: + logger.info(f"[link_preview] Falling back to Chromium for {url}") + + # Generate a realistic User-Agent to avoid bot detection + ua = UserAgent() + user_agent = ua.random + + # Use AsyncChromiumLoader to fetch the page + crawl_loader = AsyncChromiumLoader( + urls=[url], headless=True, user_agent=user_agent + ) + documents = await crawl_loader.aload() + + if not documents: + logger.warning(f"[link_preview] Chromium returned no documents for {url}") + return None + + doc = documents[0] + raw_html = doc.page_content + + if not raw_html or len(raw_html.strip()) == 0: + logger.warning(f"[link_preview] Chromium returned empty content for {url}") + return None + + # Extract metadata using Trafilatura + trafilatura_metadata = trafilatura.extract_metadata(raw_html) + + # Extract OG image from raw HTML (trafilatura doesn't extract this) + image = extract_image(raw_html) + + result = { + "title": None, + "description": None, + "image": image, + "raw_html": raw_html, + } + + if trafilatura_metadata: + result["title"] = trafilatura_metadata.title + result["description"] = trafilatura_metadata.description + + # If trafilatura didn't get the title/description, try OG tags + if not result["title"]: + result["title"] = extract_title(raw_html) + if not result["description"]: + result["description"] = extract_description(raw_html) + + logger.info(f"[link_preview] Successfully fetched {url} via Chromium") + return result + + except Exception as e: + logger.error(f"[link_preview] Chromium fallback failed for {url}: {e}") + return None + + def create_link_preview_tool(): """ Factory function to create the link_preview tool. @@ -184,13 +280,20 @@ def create_link_preview_tool(): url = f"https://{url}" try: + # Use a browser-like User-Agent to fetch Open Graph metadata. + # This is the same approach used by Slack, Discord, Twitter, etc. for link previews. + # We're only fetching publicly available metadata (title, description, thumbnail) + # that websites intentionally expose via OG tags for link preview purposes. async with httpx.AsyncClient( timeout=10.0, follow_redirects=True, headers={ - "User-Agent": "Mozilla/5.0 (compatible; SurfSenseBot/1.0; +https://surfsense.net)", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.5", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Pragma": "no-cache", }, ) as client: response = await client.get(url) @@ -218,32 +321,14 @@ def create_link_preview_tool(): image = extract_image(html) # Make sure image URL is absolute - if image and not image.startswith(("http://", "https://")): - if image.startswith("//"): - image = f"https:{image}" - elif image.startswith("/"): - parsed = urlparse(url) - image = f"{parsed.scheme}://{parsed.netloc}{image}" + if image: + image = _make_absolute_url(image, url) # Clean up title and description (unescape HTML entities) if title: - title = ( - title.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", '"') - .replace("'", "'") - .replace("'", "'") - ) + title = _unescape_html(title) if description: - description = ( - description.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", '"') - .replace("'", "'") - .replace("'", "'") - ) + description = _unescape_html(description) # Truncate long descriptions if len(description) > 200: description = description[:197] + "..." @@ -260,6 +345,37 @@ def create_link_preview_tool(): } except httpx.TimeoutException: + # Timeout - try Chromium fallback + logger.warning(f"[link_preview] Timeout for {url}, trying Chromium fallback") + chromium_result = await fetch_with_chromium(url) + if chromium_result: + title = chromium_result.get("title") or domain + description = chromium_result.get("description") + image = chromium_result.get("image") + + # Clean up and truncate + if title: + title = _unescape_html(title) + if description: + description = _unescape_html(description) + if len(description) > 200: + description = description[:197] + "..." + + # Make sure image URL is absolute + if image: + image = _make_absolute_url(image, url) + + return { + "id": preview_id, + "assetId": url, + "kind": "link", + "href": url, + "title": title, + "description": description, + "thumb": image, + "domain": domain, + } + return { "id": preview_id, "assetId": url, @@ -270,6 +386,42 @@ def create_link_preview_tool(): "error": "Request timed out", } except httpx.HTTPStatusError as e: + status_code = e.response.status_code + + # For 403 (Forbidden) and similar bot-detection errors, try Chromium fallback + if status_code in (403, 401, 406, 429): + logger.warning( + f"[link_preview] HTTP {status_code} for {url}, trying Chromium fallback" + ) + chromium_result = await fetch_with_chromium(url) + if chromium_result: + title = chromium_result.get("title") or domain + description = chromium_result.get("description") + image = chromium_result.get("image") + + # Clean up and truncate + if title: + title = _unescape_html(title) + if description: + description = _unescape_html(description) + if len(description) > 200: + description = description[:197] + "..." + + # Make sure image URL is absolute + if image: + image = _make_absolute_url(image, url) + + return { + "id": preview_id, + "assetId": url, + "kind": "link", + "href": url, + "title": title, + "description": description, + "thumb": image, + "domain": domain, + } + return { "id": preview_id, "assetId": url, @@ -277,11 +429,11 @@ def create_link_preview_tool(): "href": url, "title": domain or "Link", "domain": domain, - "error": f"HTTP {e.response.status_code}", + "error": f"HTTP {status_code}", } except Exception as e: error_message = str(e) - print(f"[link_preview] Error fetching {url}: {error_message}") + logger.error(f"[link_preview] Error fetching {url}: {error_message}") return { "id": preview_id, "assetId": url, diff --git a/surfsense_web/components/tool-ui/article/index.tsx b/surfsense_web/components/tool-ui/article/index.tsx index fd73d993d..5669ea832 100644 --- a/surfsense_web/components/tool-ui/article/index.tsx +++ b/surfsense_web/components/tool-ui/article/index.tsx @@ -19,20 +19,20 @@ import { cn } from "@/lib/utils"; */ const SerializableArticleSchema = z.object({ id: z.string().default("article-unknown"), - assetId: z.string().optional(), - kind: z.literal("article").optional(), + assetId: z.string().nullish(), + kind: z.literal("article").nullish(), title: z.string().default("Untitled Article"), - description: z.string().optional(), - content: z.string().optional(), - href: z.string().url().optional(), - domain: z.string().optional(), - author: z.string().optional(), - date: z.string().optional(), - word_count: z.number().optional(), - wordCount: z.number().optional(), - was_truncated: z.boolean().optional(), - wasTruncated: z.boolean().optional(), - error: z.string().optional(), + description: z.string().nullish(), + content: z.string().nullish(), + href: z.string().url().nullish(), + domain: z.string().nullish(), + author: z.string().nullish(), + date: z.string().nullish(), + word_count: z.number().nullish(), + wordCount: z.number().nullish(), + was_truncated: z.boolean().nullish(), + wasTruncated: z.boolean().nullish(), + error: z.string().nullish(), }); /** diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 5694035bc..3e6f668a8 100644 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx @@ -70,12 +70,12 @@ const ThinkingStepSchema = z.object({ }); const DeepAgentThinkingArgsSchema = z.object({ - query: z.string().optional(), - context: z.string().optional(), + query: z.string().nullish(), + context: z.string().nullish(), }); const DeepAgentThinkingResultSchema = z.object({ - steps: z.array(ThinkingStepSchema).optional(), + steps: z.array(ThinkingStepSchema).nullish(), status: z .enum([ THINKING_STATUS.THINKING, @@ -83,8 +83,8 @@ const DeepAgentThinkingResultSchema = z.object({ THINKING_STATUS.SYNTHESIZING, THINKING_STATUS.COMPLETED, ]) - .optional(), - summary: z.string().optional(), + .nullish(), + summary: z.string().nullish(), }); /** Types derived from Zod schemas */ @@ -325,7 +325,7 @@ export const DeepAgentThinkingToolUI = makeAssistantToolUI< render: function DeepAgentThinkingUI({ result, status }) { // Loading state - tool is still running if (status.type === "running" || status.type === "requires-action") { - return ; + return ; } // Incomplete/cancelled state diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx index 28900840e..cd1c14241 100644 --- a/surfsense_web/components/tool-ui/display-image.tsx +++ b/surfsense_web/components/tool-ui/display-image.tsx @@ -23,7 +23,7 @@ interface DisplayImageResult { id: string; assetId: string; src: string; - alt: string; + alt?: string; // Made optional - parseSerializableImage provides fallback title?: string; description?: string; domain?: string; diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 6ab598bf1..166d95e47 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -14,27 +14,27 @@ import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/pod */ const GeneratePodcastArgsSchema = z.object({ source_content: z.string(), - podcast_title: z.string().optional(), - user_prompt: z.string().optional(), + podcast_title: z.string().nullish(), + user_prompt: z.string().nullish(), }); const GeneratePodcastResultSchema = z.object({ status: z.enum(["processing", "already_generating", "success", "error"]), - task_id: z.string().optional(), - podcast_id: z.number().optional(), - title: z.string().optional(), - transcript_entries: z.number().optional(), - message: z.string().optional(), - error: z.string().optional(), + task_id: z.string().nullish(), + podcast_id: z.number().nullish(), + title: z.string().nullish(), + transcript_entries: z.number().nullish(), + message: z.string().nullish(), + error: z.string().nullish(), }); const TaskStatusResponseSchema = z.object({ status: z.enum(["processing", "success", "error"]), - podcast_id: z.number().optional(), - title: z.string().optional(), - transcript_entries: z.number().optional(), - state: z.string().optional(), - error: z.string().optional(), + podcast_id: z.number().nullish(), + title: z.string().nullish(), + transcript_entries: z.number().nullish(), + state: z.string().nullish(), + error: z.string().nullish(), }); const PodcastTranscriptEntrySchema = z.object({ @@ -43,7 +43,7 @@ const PodcastTranscriptEntrySchema = z.object({ }); const PodcastDetailsSchema = z.object({ - podcast_transcript: z.array(PodcastTranscriptEntrySchema).optional(), + podcast_transcript: z.array(PodcastTranscriptEntrySchema).nullish(), }); /** @@ -75,7 +75,9 @@ function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTrans console.warn("Invalid podcast details:", result.error.issues); return {}; } - return result.data; + return { + podcast_transcript: result.data.podcast_transcript ?? undefined, + }; } /** diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx index 79f1c5a10..1d28490a3 100644 --- a/surfsense_web/components/tool-ui/image/index.tsx +++ b/surfsense_web/components/tool-ui/image/index.tsx @@ -11,26 +11,26 @@ import { cn } from "@/lib/utils"; /** * Zod schemas for runtime validation */ -const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "auto"]); +const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "21:9", "auto"]); const ImageFitSchema = z.enum(["cover", "contain"]); const ImageSourceSchema = z.object({ label: z.string(), - iconUrl: z.string().optional(), - url: z.string().optional(), + iconUrl: z.string().nullish(), + url: z.string().nullish(), }); const SerializableImageSchema = z.object({ id: z.string(), assetId: z.string(), src: z.string(), - alt: z.string(), - title: z.string().optional(), - description: z.string().optional(), - href: z.string().optional(), - domain: z.string().optional(), - ratio: AspectRatioSchema.optional(), - source: ImageSourceSchema.optional(), + alt: z.string().nullish(), // Made optional - will use fallback if missing + title: z.string().nullish(), + description: z.string().nullish(), + href: z.string().nullish(), + domain: z.string().nullish(), + ratio: AspectRatioSchema.nullish(), + source: ImageSourceSchema.nullish(), }); /** @@ -48,7 +48,7 @@ export interface ImageProps { id: string; assetId: string; src: string; - alt: string; + alt?: string; // Optional with default fallback title?: string; description?: string; href?: string; @@ -62,18 +62,45 @@ export interface ImageProps { /** * Parse and validate serializable image from tool result + * Returns a valid SerializableImage with fallback values for missing optional fields */ -export function parseSerializableImage(result: unknown): SerializableImage { +export function parseSerializableImage(result: unknown): SerializableImage & { alt: string } { const parsed = SerializableImageSchema.safeParse(result); if (!parsed.success) { console.warn("Invalid image data:", parsed.error.issues); - // Try to extract basic info for error display + + // Try to extract basic info and return a fallback object const obj = (result && typeof result === "object" ? result : {}) as Record; + + // If we have at least id, assetId, and src, we can still render the image + if ( + typeof obj.id === "string" && + typeof obj.assetId === "string" && + typeof obj.src === "string" + ) { + return { + id: obj.id, + assetId: obj.assetId, + src: obj.src, + alt: typeof obj.alt === "string" ? obj.alt : "Image", + title: typeof obj.title === "string" ? obj.title : undefined, + description: typeof obj.description === "string" ? obj.description : undefined, + href: typeof obj.href === "string" ? obj.href : undefined, + domain: typeof obj.domain === "string" ? obj.domain : undefined, + ratio: undefined, // Use default ratio + source: undefined, + }; + } + throw new Error(`Invalid image: ${parsed.error.issues.map((i) => i.message).join(", ")}`); } - return parsed.data; + // Provide fallback for alt if it's null/undefined + return { + ...parsed.data, + alt: parsed.data.alt ?? "Image", + }; } /** @@ -89,6 +116,8 @@ function getAspectRatioClass(ratio?: AspectRatio): string { return "aspect-video"; case "9:16": return "aspect-[9/16]"; + case "21:9": + return "aspect-[21/9]"; case "auto": default: return "aspect-[4/3]"; @@ -172,7 +201,7 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string }) export function Image({ id, src, - alt, + alt = "Image", title, description, href, diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx index b773ef4a3..d4fe0c7c0 100644 --- a/surfsense_web/components/tool-ui/media-card/index.tsx +++ b/surfsense_web/components/tool-ui/media-card/index.tsx @@ -13,27 +13,27 @@ import { cn } from "@/lib/utils"; /** * Zod schemas for runtime validation */ -const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "21:9", "auto"]); +const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "21:9", "auto"]); const MediaCardKindSchema = z.enum(["link", "image", "video", "audio"]); const ResponseActionSchema = z.object({ id: z.string(), label: z.string(), - variant: z.enum(["default", "secondary", "outline", "destructive", "ghost"]).optional(), - confirmLabel: z.string().optional(), + variant: z.enum(["default", "secondary", "outline", "destructive", "ghost"]).nullish(), + confirmLabel: z.string().nullish(), }); const SerializableMediaCardSchema = z.object({ id: z.string(), assetId: z.string(), kind: MediaCardKindSchema, - href: z.string().optional(), - src: z.string().optional(), + href: z.string().nullish(), + src: z.string().nullish(), title: z.string(), - description: z.string().optional(), - thumb: z.string().optional(), - ratio: AspectRatioSchema.optional(), - domain: z.string().optional(), + description: z.string().nullish(), + thumb: z.string().nullish(), + ratio: AspectRatioSchema.nullish(), + domain: z.string().nullish(), }); /** @@ -90,6 +90,8 @@ function getAspectRatioClass(ratio?: AspectRatio): string { return "aspect-[4/3]"; case "16:9": return "aspect-video"; + case "9:16": + return "aspect-[9/16]"; case "21:9": return "aspect-[21/9]"; case "auto": From 9bc3f193c33f1e49c51a7d55a64dc21a946beeed Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:18:47 +0530 Subject: [PATCH 12/14] refactor: improve type safety in document extraction and UI components - Introduced Zod schemas for type-safe parsing of mentioned document information in chat messages. - Updated the extractMentionedDocuments function to utilize the new schemas for better validation. - Made the 'alt' property optional in image-related components for improved flexibility and fallback handling. --- .../new-chat/[[...chat_id]]/page.tsx | 32 +++++++++++++------ .../components/tool-ui/display-image.tsx | 2 +- .../components/tool-ui/image/index.tsx | 10 +++--- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9687d2b4d..f288bfa59 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -10,6 +10,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { z } from "zod"; import { type MentionedDocumentInfo, mentionedDocumentIdsAtom, @@ -55,20 +56,33 @@ function extractThinkingSteps(content: unknown): ThinkingStep[] { } /** - * Extract mentioned documents from message content + * Zod schema for mentioned document info (for type-safe parsing) + */ +const MentionedDocumentInfoSchema = z.object({ + id: z.number(), + title: z.string(), + document_type: z.string(), +}); + +const MentionedDocumentsPartSchema = z.object({ + type: z.literal("mentioned-documents"), + documents: z.array(MentionedDocumentInfoSchema), +}); + +/** + * Extract mentioned documents from message content (type-safe with Zod) */ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { if (!Array.isArray(content)) return []; - const docsPart = content.find( - (part: unknown) => - typeof part === "object" && - part !== null && - "type" in part && - (part as { type: string }).type === "mentioned-documents" - ) as { type: "mentioned-documents"; documents: MentionedDocumentInfo[] } | undefined; + for (const part of content) { + const result = MentionedDocumentsPartSchema.safeParse(part); + if (result.success) { + return result.data.documents; + } + } - return docsPart?.documents || []; + return []; } /** diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx index cd1c14241..333cd496c 100644 --- a/surfsense_web/components/tool-ui/display-image.tsx +++ b/surfsense_web/components/tool-ui/display-image.tsx @@ -23,7 +23,7 @@ interface DisplayImageResult { id: string; assetId: string; src: string; - alt?: string; // Made optional - parseSerializableImage provides fallback + alt?: string; // Made optional - parseSerializableImage provides fallback title?: string; description?: string; domain?: string; diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx index 1d28490a3..f872e293f 100644 --- a/surfsense_web/components/tool-ui/image/index.tsx +++ b/surfsense_web/components/tool-ui/image/index.tsx @@ -24,7 +24,7 @@ const SerializableImageSchema = z.object({ id: z.string(), assetId: z.string(), src: z.string(), - alt: z.string().nullish(), // Made optional - will use fallback if missing + alt: z.string().nullish(), // Made optional - will use fallback if missing title: z.string().nullish(), description: z.string().nullish(), href: z.string().nullish(), @@ -48,7 +48,7 @@ export interface ImageProps { id: string; assetId: string; src: string; - alt?: string; // Optional with default fallback + alt?: string; // Optional with default fallback title?: string; description?: string; href?: string; @@ -69,10 +69,10 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a if (!parsed.success) { console.warn("Invalid image data:", parsed.error.issues); - + // Try to extract basic info and return a fallback object const obj = (result && typeof result === "object" ? result : {}) as Record; - + // If we have at least id, assetId, and src, we can still render the image if ( typeof obj.id === "string" && @@ -92,7 +92,7 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a source: undefined, }; } - + throw new Error(`Invalid image: ${parsed.error.issues.map((i) => i.message).join(", ")}`); } From 2fdf567b714b72caa8e36cea9d41845d33976575 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:41:14 +0530 Subject: [PATCH 13/14] refactor: replace DocumentsDataTable with DocumentMentionPicker for improved document selection - Introduced DocumentMentionPicker component to enhance document selection experience in the chat interface. - Updated InlineMentionEditor and Composer components to utilize the new DocumentMentionPicker. - Removed the deprecated DocumentsDataTable component to streamline the codebase and improve maintainability. - Enhanced type safety and validation in document handling logic. --- .../app/agents/new_chat/tools/link_preview.py | 50 ++-- .../assistant-ui/inline-mention-editor.tsx | 75 ++++-- .../components/assistant-ui/thread.tsx | 10 +- .../new-chat/DocumentMentionPicker.tsx | 243 +++++++++++++++++ .../new-chat/DocumentsDataTable.tsx | 248 ------------------ 5 files changed, 327 insertions(+), 299 deletions(-) create mode 100644 surfsense_web/components/new-chat/DocumentMentionPicker.tsx delete mode 100644 surfsense_web/components/new-chat/DocumentsDataTable.tsx diff --git a/surfsense_backend/app/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py index 90b5da1d7..17e89345e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/link_preview.py +++ b/surfsense_backend/app/agents/new_chat/tools/link_preview.py @@ -172,63 +172,63 @@ async def fetch_with_chromium(url: str) -> dict[str, Any] | None: """ Fetch page content using headless Chromium browser. Used as a fallback when simple HTTP requests are blocked (403, etc.). - + Args: url: URL to fetch - + Returns: Dict with title, description, image, and raw_html, or None if failed """ try: logger.info(f"[link_preview] Falling back to Chromium for {url}") - + # Generate a realistic User-Agent to avoid bot detection ua = UserAgent() user_agent = ua.random - + # Use AsyncChromiumLoader to fetch the page crawl_loader = AsyncChromiumLoader( urls=[url], headless=True, user_agent=user_agent ) documents = await crawl_loader.aload() - + if not documents: logger.warning(f"[link_preview] Chromium returned no documents for {url}") return None - + doc = documents[0] raw_html = doc.page_content - + if not raw_html or len(raw_html.strip()) == 0: logger.warning(f"[link_preview] Chromium returned empty content for {url}") return None - + # Extract metadata using Trafilatura trafilatura_metadata = trafilatura.extract_metadata(raw_html) - + # Extract OG image from raw HTML (trafilatura doesn't extract this) image = extract_image(raw_html) - + result = { "title": None, "description": None, "image": image, "raw_html": raw_html, } - + if trafilatura_metadata: result["title"] = trafilatura_metadata.title result["description"] = trafilatura_metadata.description - + # If trafilatura didn't get the title/description, try OG tags if not result["title"]: result["title"] = extract_title(raw_html) if not result["description"]: result["description"] = extract_description(raw_html) - + logger.info(f"[link_preview] Successfully fetched {url} via Chromium") return result - + except Exception as e: logger.error(f"[link_preview] Chromium fallback failed for {url}: {e}") return None @@ -346,13 +346,15 @@ def create_link_preview_tool(): except httpx.TimeoutException: # Timeout - try Chromium fallback - logger.warning(f"[link_preview] Timeout for {url}, trying Chromium fallback") + logger.warning( + f"[link_preview] Timeout for {url}, trying Chromium fallback" + ) chromium_result = await fetch_with_chromium(url) if chromium_result: title = chromium_result.get("title") or domain description = chromium_result.get("description") image = chromium_result.get("image") - + # Clean up and truncate if title: title = _unescape_html(title) @@ -360,11 +362,11 @@ def create_link_preview_tool(): description = _unescape_html(description) if len(description) > 200: description = description[:197] + "..." - + # Make sure image URL is absolute if image: image = _make_absolute_url(image, url) - + return { "id": preview_id, "assetId": url, @@ -375,7 +377,7 @@ def create_link_preview_tool(): "thumb": image, "domain": domain, } - + return { "id": preview_id, "assetId": url, @@ -387,7 +389,7 @@ def create_link_preview_tool(): } except httpx.HTTPStatusError as e: status_code = e.response.status_code - + # For 403 (Forbidden) and similar bot-detection errors, try Chromium fallback if status_code in (403, 401, 406, 429): logger.warning( @@ -398,7 +400,7 @@ def create_link_preview_tool(): title = chromium_result.get("title") or domain description = chromium_result.get("description") image = chromium_result.get("image") - + # Clean up and truncate if title: title = _unescape_html(title) @@ -406,11 +408,11 @@ def create_link_preview_tool(): description = _unescape_html(description) if len(description) > 200: description = description[:197] + "..." - + # Make sure image URL is absolute if image: image = _make_absolute_url(image, url) - + return { "id": preview_id, "assetId": url, @@ -421,7 +423,7 @@ def create_link_preview_tool(): "thumb": image, "domain": domain, } - + return { "id": preview_id, "assetId": url, diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 524d4470a..97e4b4373 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -45,6 +45,27 @@ interface InlineMentionEditorProps { const CHIP_DATA_ATTR = "data-mention-chip"; const CHIP_ID_ATTR = "data-mention-id"; +/** + * Type guard to check if a node is a chip element + */ +function isChipElement(node: Node | null): node is HTMLSpanElement { + return ( + node !== null && + node.nodeType === Node.ELEMENT_NODE && + (node as Element).hasAttribute(CHIP_DATA_ATTR) + ); +} + +/** + * Safely parse chip ID from element attribute + */ +function getChipId(element: Element): number | null { + const idStr = element.getAttribute(CHIP_ID_ATTR); + if (!idStr) return null; + const id = parseInt(idStr, 10); + return Number.isNaN(id) ? null : id; +} + export const InlineMentionEditor = forwardRef( ( { @@ -177,6 +198,12 @@ export const InlineMentionEditor = forwardRef { if (!editorRef.current) return; + // Validate required fields for type safety + if (typeof doc.id !== "number" || typeof doc.title !== "string") { + console.warn("[InlineMentionEditor] Invalid document passed to insertDocumentChip:", doc); + return; + } + const mentionDoc: MentionedDocument = { id: doc.id, title: doc.title, @@ -381,19 +408,21 @@ export const InlineMentionEditor = forwardRef { - const next = new Map(prev); - next.delete(chipId); - return next; - }); - // Notify parent that a document was removed - onDocumentRemove?.(chipId); + const chipId = getChipId(prevSibling); + if (chipId !== null) { + prevSibling.remove(); + setMentionedDocs((prev) => { + const next = new Map(prev); + next.delete(chipId); + return next; + }); + // Notify parent that a document was removed + onDocumentRemove?.(chipId); + } return; } // Check if we're about to delete @ at the start @@ -414,19 +443,21 @@ export const InlineMentionEditor = forwardRef 0) { - // Check if previous child is a chip + // Check if previous child is a chip using type guard const prevChild = (node as Element).childNodes[offset - 1]; - if (prevChild && (prevChild as Element).hasAttribute?.(CHIP_DATA_ATTR)) { + if (isChipElement(prevChild)) { e.preventDefault(); - const chipId = Number((prevChild as Element).getAttribute(CHIP_ID_ATTR)); - prevChild.parentNode?.removeChild(prevChild); - setMentionedDocs((prev) => { - const next = new Map(prev); - next.delete(chipId); - return next; - }); - // Notify parent that a document was removed - onDocumentRemove?.(chipId); + const chipId = getChipId(prevChild); + if (chipId !== null) { + prevChild.remove(); + setMentionedDocs((prev) => { + const next = new Map(prev); + next.delete(chipId); + return next; + }); + // Notify parent that a document was removed + onDocumentRemove?.(chipId); + } } } } diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index e6e9be45d..4f8c3d2ee 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -72,9 +72,9 @@ import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { - DocumentsDataTable, - type DocumentsDataTableRef, -} from "@/components/new-chat/DocumentsDataTable"; + DocumentMentionPicker, + type DocumentMentionPickerRef, +} from "@/components/new-chat/DocumentMentionPicker"; import { ChainOfThought, ChainOfThoughtContent, @@ -404,7 +404,7 @@ const Composer: FC = () => { const [mentionQuery, setMentionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); - const documentPickerRef = useRef(null); + const documentPickerRef = useRef(null); const { search_space_id } = useParams(); const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const composerRuntime = useComposerRuntime(); @@ -598,7 +598,7 @@ const Composer: FC = () => { : "50%", }} > - void; + moveUp: () => void; + moveDown: () => void; +} + +interface DocumentMentionPickerProps { + searchSpaceId: number; + onSelectionChange: (documents: Document[]) => void; + onDone: () => void; + initialSelectedDocuments?: Document[]; + externalSearch?: string; +} + +function useDebounced(value: T, delay = 300) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return debounced; +} + +export const DocumentMentionPicker = forwardRef< + DocumentMentionPickerRef, + DocumentMentionPickerProps +>(function DocumentMentionPicker( + { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" }, + ref +) { + // Use external search + const search = externalSearch; + const debouncedSearch = useDebounced(search, 150); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const itemRefs = useRef>(new Map()); + + const fetchQueryParams = useMemo( + () => ({ + search_space_id: searchSpaceId, + page: 0, + page_size: 20, + }), + [searchSpaceId] + ); + + const searchQueryParams = useMemo(() => { + return { + search_space_id: searchSpaceId, + page: 0, + page_size: 20, + title: debouncedSearch, + }; + }, [debouncedSearch, searchSpaceId]); + + // Use query for fetching documents + const { data: documents, isLoading: isDocumentsLoading } = useQuery({ + queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), + queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }), + staleTime: 3 * 60 * 1000, + enabled: !!searchSpaceId && !debouncedSearch.trim(), + }); + + // Searching + const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({ + queryKey: cacheKeys.documents.withQueryParams(searchQueryParams), + queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), + staleTime: 3 * 60 * 1000, + enabled: !!searchSpaceId && !!debouncedSearch.trim(), + }); + + const actualDocuments = debouncedSearch.trim() + ? searchedDocuments?.items || [] + : documents?.items || []; + const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading; + + // Track already selected document IDs + const selectedIds = useMemo( + () => new Set(initialSelectedDocuments.map((d) => d.id)), + [initialSelectedDocuments] + ); + + // Filter out already selected documents for navigation + const selectableDocuments = useMemo( + () => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), + [actualDocuments, selectedIds] + ); + + const handleSelectDocument = useCallback( + (doc: Document) => { + onSelectionChange([...initialSelectedDocuments, doc]); + onDone(); + }, + [initialSelectedDocuments, onSelectionChange, onDone] + ); + + // Scroll highlighted item into view + useEffect(() => { + const item = itemRefs.current.get(highlightedIndex); + if (item) { + item.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [highlightedIndex]); + + // Reset highlighted index when external search changes + const prevSearchRef = useRef(search); + if (prevSearchRef.current !== search) { + prevSearchRef.current = search; + if (highlightedIndex !== 0) { + setHighlightedIndex(0); + } + } + + // Expose methods to parent via ref + useImperativeHandle( + ref, + () => ({ + selectHighlighted: () => { + if (selectableDocuments[highlightedIndex]) { + handleSelectDocument(selectableDocuments[highlightedIndex]); + } + }, + moveUp: () => { + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1)); + }, + moveDown: () => { + setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0)); + }, + }), + [selectableDocuments, highlightedIndex, handleSelectDocument] + ); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (selectableDocuments.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0)); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1)); + break; + case "Enter": + e.preventDefault(); + if (selectableDocuments[highlightedIndex]) { + handleSelectDocument(selectableDocuments[highlightedIndex]); + } + break; + case "Escape": + e.preventDefault(); + onDone(); + break; + } + }, + [selectableDocuments, highlightedIndex, handleSelectDocument, onDone] + ); + + return ( +
+ {/* Document List */} +
+ {actualLoading ? ( +
+
+
+ ) : actualDocuments.length === 0 ? ( +
+ +

No documents found

+
+ ) : ( +
+ {actualDocuments.map((doc) => { + const isAlreadySelected = selectedIds.has(doc.id); + const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id); + const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + + return ( + + ); + })} +
+ )} +
+
+ ); +}); diff --git a/surfsense_web/components/new-chat/DocumentsDataTable.tsx b/surfsense_web/components/new-chat/DocumentsDataTable.tsx deleted file mode 100644 index 50b026dfc..000000000 --- a/surfsense_web/components/new-chat/DocumentsDataTable.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { FileText } from "lucide-react"; -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import type { Document } 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"; - -export interface DocumentsDataTableRef { - selectHighlighted: () => void; - moveUp: () => void; - moveDown: () => void; -} - -interface DocumentsDataTableProps { - searchSpaceId: number; - onSelectionChange: (documents: Document[]) => void; - onDone: () => void; - initialSelectedDocuments?: Document[]; - externalSearch?: string; -} - -function useDebounced(value: T, delay = 300) { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const t = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(t); - }, [value, delay]); - return debounced; -} - -export const DocumentsDataTable = forwardRef( - function DocumentsDataTable( - { - searchSpaceId, - onSelectionChange, - onDone, - initialSelectedDocuments = [], - externalSearch = "", - }, - ref - ) { - // Use external search - const search = externalSearch; - const debouncedSearch = useDebounced(search, 150); - const [highlightedIndex, setHighlightedIndex] = useState(0); - const itemRefs = useRef>(new Map()); - - const fetchQueryParams = useMemo( - () => ({ - search_space_id: searchSpaceId, - page: 0, - page_size: 20, - }), - [searchSpaceId] - ); - - const searchQueryParams = useMemo(() => { - return { - search_space_id: searchSpaceId, - page: 0, - page_size: 20, - title: debouncedSearch, - }; - }, [debouncedSearch, searchSpaceId]); - - // Use query for fetching documents - const { data: documents, isLoading: isDocumentsLoading } = useQuery({ - queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), - queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }), - staleTime: 3 * 60 * 1000, - enabled: !!searchSpaceId && !debouncedSearch.trim(), - }); - - // Searching - const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({ - queryKey: cacheKeys.documents.withQueryParams(searchQueryParams), - queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), - staleTime: 3 * 60 * 1000, - enabled: !!searchSpaceId && !!debouncedSearch.trim(), - }); - - const actualDocuments = debouncedSearch.trim() - ? searchedDocuments?.items || [] - : documents?.items || []; - const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading; - - // Track already selected document IDs - const selectedIds = useMemo( - () => new Set(initialSelectedDocuments.map((d) => d.id)), - [initialSelectedDocuments] - ); - - // Filter out already selected documents for navigation - const selectableDocuments = useMemo( - () => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), - [actualDocuments, selectedIds] - ); - - const handleSelectDocument = useCallback( - (doc: Document) => { - onSelectionChange([...initialSelectedDocuments, doc]); - onDone(); - }, - [initialSelectedDocuments, onSelectionChange, onDone] - ); - - // Scroll highlighted item into view - useEffect(() => { - const item = itemRefs.current.get(highlightedIndex); - if (item) { - item.scrollIntoView({ block: "nearest", behavior: "smooth" }); - } - }, [highlightedIndex]); - - // Reset highlighted index when external search changes - const prevSearchRef = useRef(search); - if (prevSearchRef.current !== search) { - prevSearchRef.current = search; - if (highlightedIndex !== 0) { - setHighlightedIndex(0); - } - } - - // Expose methods to parent via ref - useImperativeHandle( - ref, - () => ({ - selectHighlighted: () => { - if (selectableDocuments[highlightedIndex]) { - handleSelectDocument(selectableDocuments[highlightedIndex]); - } - }, - moveUp: () => { - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1)); - }, - moveDown: () => { - setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0)); - }, - }), - [selectableDocuments, highlightedIndex, handleSelectDocument] - ); - - // Handle keyboard navigation - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (selectableDocuments.length === 0) return; - - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0)); - break; - case "ArrowUp": - e.preventDefault(); - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1)); - break; - case "Enter": - e.preventDefault(); - if (selectableDocuments[highlightedIndex]) { - handleSelectDocument(selectableDocuments[highlightedIndex]); - } - break; - case "Escape": - e.preventDefault(); - onDone(); - break; - } - }, - [selectableDocuments, highlightedIndex, handleSelectDocument, onDone] - ); - - return ( -
- {/* Document List */} -
- {actualLoading ? ( -
-
-
- ) : actualDocuments.length === 0 ? ( -
- -

No documents found

-
- ) : ( -
- {actualDocuments.map((doc) => { - const isAlreadySelected = selectedIds.has(doc.id); - const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; - - return ( - - ); - })} -
- )} -
-
- ); - } -); From 7f809ffa1b587e66f3f1edbdba6200e1dbfc9aab Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 11:42:12 -0800 Subject: [PATCH 14/14] chore: linting --- .../components/assistant-ui/inline-mention-editor.tsx | 4 ++-- surfsense_web/components/assistant-ui/thread.tsx | 2 +- ...{DocumentMentionPicker.tsx => document-mention-picker.tsx} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename surfsense_web/components/new-chat/{DocumentMentionPicker.tsx => document-mention-picker.tsx} (100%) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 97e4b4373..12a8f895f 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -1,15 +1,15 @@ "use client"; +import { X } from "lucide-react"; import { + createElement, forwardRef, useCallback, - createElement, useEffect, useImperativeHandle, useRef, useState, } from "react"; -import { X } from "lucide-react"; import ReactDOMServer from "react-dom/server"; import type { Document } from "@/contracts/types/document.types"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 4f8c3d2ee..90d4e62a3 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -74,7 +74,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { DocumentMentionPicker, type DocumentMentionPickerRef, -} from "@/components/new-chat/DocumentMentionPicker"; +} from "@/components/new-chat/document-mention-picker"; import { ChainOfThought, ChainOfThoughtContent, diff --git a/surfsense_web/components/new-chat/DocumentMentionPicker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx similarity index 100% rename from surfsense_web/components/new-chat/DocumentMentionPicker.tsx rename to surfsense_web/components/new-chat/document-mention-picker.tsx