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/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py index 188863015..17e89345e 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,39 @@ 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 +388,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 +431,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_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]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 8fc2fb825..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 @@ -25,17 +25,31 @@ 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 +63,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 +87,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); @@ -323,7 +337,7 @@ export default function EditorPage() { if (hasUnsavedChanges) { setShowUnsavedDialog(true); } else { - router.push(`/dashboard/${searchSpaceId}/researcher`); + router.push(`/dashboard/${searchSpaceId}/new-chat`); } }; @@ -333,12 +347,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 +393,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]" /> @@ -601,19 +587,18 @@ const Composer: FC = () => { /> {/* Popover positioned above input */}
- { 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) => ( { ))}
)} -
- -
-
- + {/* Message bubble with action bar positioned relative to it */} +
+
+ +
+
+ +
diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index df1021762..619e13fc5 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -165,14 +165,11 @@ export function DashboardBreadcrumb() { } // Handle new-chat sub-sections (thread IDs) + // Don't show thread ID in breadcrumb - users identify chats by content, not by ID if (section === "new-chat") { breadcrumbs.push({ label: t("chat") || "Chat", - href: `/dashboard/${segments[1]}/new-chat`, }); - if (subSection) { - breadcrumbs.push({ label: `Thread ${subSection}` }); - } return breadcrumbs; } diff --git a/surfsense_web/components/new-chat/DocumentsDataTable.tsx b/surfsense_web/components/new-chat/DocumentsDataTable.tsx deleted file mode 100644 index 49aeff75c..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 ( - - ); - })} -
- )} -
-
- ); - } -); diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx new file mode 100644 index 000000000..d46955324 --- /dev/null +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -0,0 +1,243 @@ +"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 DocumentMentionPickerRef { + selectHighlighted: () => 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/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 5dcf3bafa..4268d998c 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -175,7 +175,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp role="combobox" aria-expanded={open} className={cn( - "h-9 gap-2 px-3 rounded-xl border border-border/30 bg-background/50 backdrop-blur-sm", + "h-9 gap-2 px-3 rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm", "hover:bg-muted/80 hover:border-border/30 transition-all duration-200", "text-sm font-medium text-foreground", "focus-visible:ring-0 focus-visible:ring-offset-0", diff --git a/surfsense_web/components/prompt-kit/chain-of-thought.tsx b/surfsense_web/components/prompt-kit/chain-of-thought.tsx index 89a83b22b..ca9698b31 100644 --- a/surfsense_web/components/prompt-kit/chain-of-thought.tsx +++ b/surfsense_web/components/prompt-kit/chain-of-thought.tsx @@ -1,39 +1,206 @@ "use client"; import { - Brain, - CheckCircle2, ChevronDown, Circle, - Lightbulb, - Loader2, - Search, - Sparkles, + File, + FileAudio, + FileCode, + FileImage, + FileSpreadsheet, + FileText, + FileVideo, } from "lucide-react"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -export type ChainOfThoughtItemProps = React.ComponentProps<"div">; +// ============================================================================ +// Constants +// ============================================================================ -export const ChainOfThoughtItem = ({ children, className, ...props }: ChainOfThoughtItemProps) => ( -
- {children} +/** Animation timing constants (in milliseconds) */ +const ANIMATION = { + /** Delay between each step appearing */ + STAGGER_DELAY_MS: 50, + /** Additional delay for connection line animation */ + CONNECTION_LINE_DELAY_MS: 150, +} as const; + +/** File extension categories for icon mapping */ +const FILE_EXTENSIONS = { + DOCUMENT: ["pdf", "doc", "docx"] as const, + SPREADSHEET: ["xls", "xlsx", "csv"] as const, + IMAGE: ["png", "jpg", "jpeg", "gif", "webp", "svg"] as const, + AUDIO: ["mp3", "wav", "m4a", "ogg", "webm"] as const, + VIDEO: ["mp4", "mov", "avi", "mkv"] as const, + CODE: ["js", "ts", "tsx", "jsx", "py", "html", "css", "json", "md"] as const, +} as const; + +/** Type for file extension categories */ +type FileExtensionCategory = keyof typeof FILE_EXTENSIONS; + +/** Icon size class for file icons */ +const FILE_ICON_SIZE_CLASS = "size-3.5" as const; + +// ============================================================================ +// Hooks +// ============================================================================ + +/** + * Custom hook for entrance animation + * Returns true after mount to trigger CSS transitions + */ +function useEntranceAnimation(delay = 0): boolean { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + 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() ?? ""; + + if (isExtensionInCategory(ext, "DOCUMENT")) { + return ; + } + if (isExtensionInCategory(ext, "SPREADSHEET")) { + return ; + } + if (isExtensionInCategory(ext, "IMAGE")) { + return ; + } + if (isExtensionInCategory(ext, "AUDIO")) { + return ; + } + if (isExtensionInCategory(ext, "VIDEO")) { + return ; + } + if (isExtensionInCategory(ext, "CODE")) { + 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 }) => { + 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; +} + +// ============================================================================ +// Chain of Thought Components +// ============================================================================ + +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 (
-
+ {/* Animated vertical connection line */} +
-
{children}
+
+ {React.Children.map(children, (child, index) => { + const key = React.isValidElement(child) ? child.key : `cot-item-${index}`; + return ( +
+ {child} +
+ ); + })} +
); }; -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 (
- {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, + })} + + ); + })}
); -} - -export type ChainOfThoughtStepProps = { - children: React.ReactNode; - className?: string; - isLast?: boolean; }; -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 * 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 ( - + {children} + {/* Animated connection line to next step */}
-
+
); 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/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" + /> +
diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 573837bce..3e6f668a8 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,34 +14,96 @@ 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({ - 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(), - status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(), - summary: z.string().optional(), + steps: z.array(ThinkingStepSchema).nullish(), + status: z + .enum([ + THINKING_STATUS.THINKING, + THINKING_STATUS.SEARCHING, + THINKING_STATUS.SYNTHESIZING, + THINKING_STATUS.COMPLETED, + ]) + .nullish(), + summary: z.string().nullish(), }); -/** - * 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,69 @@ 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({ - step, - isOpen, - onToggle, -}: { - step: ThinkingStep; - isOpen: boolean; - onToggle: () => void; -}) { +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; + return ( {step.title} @@ -131,22 +202,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 +231,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 +270,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 @@ -254,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 @@ -281,21 +352,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 +389,18 @@ export function InlineThinkingDisplay({ )}
); -} +}; -export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult }; +// ============================================================================ +// Exports +// ============================================================================ + +export type { + ThinkingStep, + DeepAgentThinkingArgs, + DeepAgentThinkingResult, + StepStatus, + ThinkingStatus, +}; + +export { STEP_STATUS, THINKING_STATUS }; diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx index 28900840e..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; + 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..f872e293f 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": 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 }; },