diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 6026bd95e..476ff2935 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -705,6 +705,7 @@ async def handle_new_chat( session=session, llm_config_id=llm_config_id, attachments=request.attachments, + mentioned_document_ids=request.mentioned_document_ids, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index ffaf85554..78498cf04 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -160,3 +160,6 @@ class NewChatRequest(BaseModel): attachments: list[ChatAttachment] | None = ( None # Optional attachments with extracted content ) + mentioned_document_ids: list[int] | None = ( + None # Optional document IDs mentioned with @ in the chat + ) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 7a3d4b20d..2038e85dc 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -14,6 +14,7 @@ from collections.abc import AsyncGenerator from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer @@ -24,6 +25,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) +from app.db import Document from app.schemas.new_chat import ChatAttachment from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -46,6 +48,27 @@ def format_attachments_as_context(attachments: list[ChatAttachment]) -> str: return "\n".join(context_parts) +def format_mentioned_documents_as_context(documents: list[Document]) -> str: + """Format mentioned documents as context for the agent.""" + if not documents: + return "" + + context_parts = [""] + context_parts.append( + "The user has explicitly mentioned the following documents from their knowledge base. " + "These documents are directly relevant to the query and should be prioritized as primary sources." + ) + for i, doc in enumerate(documents, 1): + context_parts.append( + f"" + ) + context_parts.append(f"") + context_parts.append("") + context_parts.append("") + + return "\n".join(context_parts) + + async def stream_new_chat( user_query: str, search_space_id: int, @@ -53,6 +76,7 @@ async def stream_new_chat( session: AsyncSession, llm_config_id: int = -1, attachments: list[ChatAttachment] | None = None, + mentioned_document_ids: list[int] | None = None, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -68,6 +92,8 @@ async def stream_new_chat( session: The database session llm_config_id: The LLM configuration ID (default: -1 for first global config) messages: Optional chat history from frontend (list of ChatMessage) + attachments: Optional attachments with extracted content + mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat Yields: str: SSE formatted response strings @@ -136,13 +162,32 @@ async def stream_new_chat( # Build input with message history from frontend langchain_messages = [] - # Format the user query with attachment context if any - final_query = user_query - if attachments: - attachment_context = format_attachments_as_context(attachments) - final_query = ( - f"{attachment_context}\n\n{user_query}" + # Fetch mentioned documents if any + mentioned_documents: list[Document] = [] + if mentioned_document_ids: + result = await session.execute( + select(Document).filter( + Document.id.in_(mentioned_document_ids), + Document.search_space_id == search_space_id, + ) ) + mentioned_documents = list(result.scalars().all()) + + # Format the user query with context (attachments + mentioned documents) + final_query = user_query + context_parts = [] + + if attachments: + context_parts.append(format_attachments_as_context(attachments)) + + if mentioned_documents: + context_parts.append( + format_mentioned_documents_as_context(mentioned_documents) + ) + + if context_parts: + context = "\n\n".join(context_parts) + final_query = f"{context}\n\n{user_query}" # if messages: # # Convert frontend messages to LangChain format 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 e18629b92..a7fc23802 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 @@ -6,9 +6,16 @@ import { type ThreadMessageLike, useExternalStoreRuntime, } from "@assistant-ui/react"; +import { useAtomValue, useSetAtom } from "jotai"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { + type MentionedDocumentInfo, + mentionedDocumentIdsAtom, + mentionedDocumentsAtom, + messageDocumentsMapAtom, +} from "@/atoms/chat/mentioned-documents.atom"; import { Thread } from "@/components/assistant-ui/thread"; import { ChatHeader } from "@/components/new-chat/chat-header"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; @@ -47,6 +54,23 @@ function extractThinkingSteps(content: unknown): ThinkingStep[] { return thinkingPart?.steps || []; } +/** + * Extract mentioned documents from message content + */ +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; + + return docsPart?.documents || []; +} + /** * Convert backend message to assistant-ui ThreadMessageLike format * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps @@ -57,16 +81,13 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { if (typeof msg.content === "string") { content = [{ type: "text", text: msg.content }]; } else if (Array.isArray(msg.content)) { - // Filter out thinking-steps part - it's handled separately via messageThinkingSteps - const filteredContent = msg.content.filter( - (part: unknown) => - !( - typeof part === "object" && - part !== null && - "type" in part && - (part as { type: string }).type === "thinking-steps" - ) - ); + // Filter out custom metadata parts - they're handled separately + const filteredContent = msg.content.filter((part: unknown) => { + if (typeof part !== "object" || part === null || !("type" in part)) return true; + const partType = (part as { type: string }).type; + // Filter out thinking-steps and mentioned-documents + return partType !== "thinking-steps" && partType !== "mentioned-documents"; + }); content = filteredContent.length > 0 ? (filteredContent as ThreadMessageLike["content"]) @@ -117,6 +138,13 @@ export default function NewChatPage() { ); const abortControllerRef = useRef(null); + // Get mentioned document IDs from the composer + const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom); + const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); + const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); + const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom); + const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); + // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); @@ -154,6 +182,9 @@ export default function NewChatPage() { // Extract and restore thinking steps from persisted messages const restoredThinkingSteps = new Map(); + // Extract and restore mentioned documents from persisted messages + const restoredDocsMap: Record = {}; + for (const msg of response.messages) { if (msg.role === "assistant") { const steps = extractThinkingSteps(msg.content); @@ -161,10 +192,19 @@ export default function NewChatPage() { restoredThinkingSteps.set(`msg-${msg.id}`, steps); } } + if (msg.role === "user") { + const docs = extractMentionedDocuments(msg.content); + if (docs.length > 0) { + restoredDocsMap[`msg-${msg.id}`] = docs; + } + } } if (restoredThinkingSteps.size > 0) { setMessageThinkingSteps(restoredThinkingSteps); } + if (Object.keys(restoredDocsMap).length > 0) { + setMessageDocumentsMap(restoredDocsMap); + } } } else { // Create new thread @@ -181,7 +221,7 @@ export default function NewChatPage() { } finally { setIsInitializing(false); } - }, [urlChatId, searchSpaceId, router]); + }, [urlChatId, searchSpaceId, router, setMessageDocumentsMap]); // Initialize on mount useEffect(() => { @@ -243,10 +283,37 @@ export default function NewChatPage() { }; setMessages((prev) => [...prev, userMessage]); - // Persist user message (don't await, fire and forget) + // Store mentioned documents with this message for display + if (mentionedDocuments.length > 0) { + const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({ + id: doc.id, + title: doc.title, + document_type: doc.document_type, + })); + setMessageDocumentsMap((prev) => ({ + ...prev, + [userMsgId]: docsInfo, + })); + } + + // Persist user message with mentioned documents (don't await, fire and forget) + const persistContent = + mentionedDocuments.length > 0 + ? [ + ...message.content, + { + type: "mentioned-documents", + documents: mentionedDocuments.map((doc) => ({ + id: doc.id, + title: doc.title, + document_type: doc.document_type, + })), + }, + ] + : message.content; appendMessage(threadId, { role: "user", - content: message.content, + content: persistContent, }).catch((err) => console.error("Failed to persist user message:", err)); // Start streaming response @@ -385,6 +452,15 @@ export default function NewChatPage() { // Extract attachment content to send with the request const attachments = extractAttachmentContent(messageAttachments); + // Get mentioned document IDs for context + const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined; + + // Clear mentioned documents after capturing them + if (mentionedDocumentIds.length > 0) { + setMentionedDocumentIds([]); + setMentionedDocuments([]); + } + const response = await fetch(`${backendUrl}/api/v1/new_chat`, { method: "POST", headers: { @@ -397,6 +473,7 @@ export default function NewChatPage() { search_space_id: searchSpaceId, messages: messageHistory, attachments: attachments.length > 0 ? attachments : undefined, + mentioned_document_ids: documentIds, }), signal: controller.signal, }); @@ -558,7 +635,16 @@ export default function NewChatPage() { // Note: We no longer clear thinking steps - they persist with the message } }, - [threadId, searchSpaceId, messages] + [ + threadId, + searchSpaceId, + messages, + mentionedDocumentIds, + mentionedDocuments, + setMentionedDocumentIds, + setMentionedDocuments, + setMessageDocumentsMap, + ] ); // Convert message (pass through since already in correct format) diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts new file mode 100644 index 000000000..79ea27d12 --- /dev/null +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -0,0 +1,31 @@ +"use client"; + +import { atom } from "jotai"; +import type { Document } from "@/contracts/types/document.types"; + +/** + * Atom to store the IDs of documents mentioned in the current chat composer. + * This is used to pass document context to the backend when sending a message. + */ +export const mentionedDocumentIdsAtom = atom([]); + +/** + * Atom to store the full document objects mentioned in the current chat composer. + * This persists across component remounts. + */ +export const mentionedDocumentsAtom = atom([]); + +/** + * Simplified document info for display purposes + */ +export interface MentionedDocumentInfo { + id: number; + title: string; + document_type: string; +} + +/** + * Atom to store mentioned documents per message ID. + * This allows displaying which documents were mentioned with each user message. + */ +export const messageDocumentsMapAtom = atom>({}); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 27c08d144..191d60338 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -8,8 +8,9 @@ import { ThreadPrimitive, useAssistantState, useMessage, + useThreadViewport, } from "@assistant-ui/react"; -import { useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { AlertCircle, ArrowDownIcon, @@ -21,6 +22,7 @@ import { ChevronRightIcon, CopyIcon, DownloadIcon, + FileText, Loader2, PencilIcon, Plug2, @@ -29,10 +31,27 @@ import { Search, Sparkles, SquareIcon, + X, } from "lucide-react"; import Link from "next/link"; -import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "next/navigation"; +import { + createContext, + type FC, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { + mentionedDocumentIdsAtom, + mentionedDocumentsAtom, + messageDocumentsMapAtom, +} from "@/atoms/chat/mentioned-documents.atom"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { globalNewLLMConfigsAtom, @@ -49,6 +68,10 @@ import { 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"; import { ChainOfThought, ChainOfThoughtContent, @@ -60,6 +83,7 @@ import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { Document } from "@/contracts/types/document.types"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { cn } from "@/lib/utils"; @@ -73,8 +97,6 @@ interface ThreadProps { } // Context to pass thinking steps to AssistantMessage -import { createContext, useContext } from "react"; - const ThinkingStepsContext = createContext>(new Map()); /** @@ -213,6 +235,56 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea ); }; +/** + * Component that handles auto-scroll when thinking steps update. + * Uses useThreadViewport to scroll to bottom when thinking steps change, + * ensuring the user always sees the latest content during streaming. + */ +const ThinkingStepsScrollHandler: FC = () => { + const thinkingStepsMap = useContext(ThinkingStepsContext); + const viewport = useThreadViewport(); + const isRunning = useAssistantState(({ thread }) => thread.isRunning); + // Track the serialized state to detect any changes + const prevStateRef = useRef(""); + + useEffect(() => { + // Only act during streaming + if (!isRunning) { + prevStateRef.current = ""; + return; + } + + // Serialize the thinking steps state to detect any changes + // This catches new steps, status changes, and item additions + let stateString = ""; + thinkingStepsMap.forEach((steps, msgId) => { + steps.forEach((step) => { + stateString += `${msgId}:${step.id}:${step.status}:${step.items?.length || 0};`; + }); + }); + + // If state changed at all during streaming, scroll + if (stateString !== prevStateRef.current && stateString !== "") { + prevStateRef.current = stateString; + + // Multiple attempts to ensure scroll happens after DOM updates + const scrollAttempt = () => { + try { + viewport.scrollToBottom(); + } catch { + // Ignore errors - viewport might not be ready + } + }; + + // Delayed attempts to handle async DOM updates + requestAnimationFrame(scrollAttempt); + setTimeout(scrollAttempt, 100); + } + }, [thinkingStepsMap, viewport, isRunning]); + + return null; // This component doesn't render anything +}; + export const Thread: FC = ({ messageThinkingSteps = new Map(), header }) => { return ( @@ -316,12 +388,15 @@ const getTimeBasedGreeting = (userEmail?: string): string => { const ThreadWelcome: FC = () => { const { data: user } = useAtomValue(currentUserAtom); + // Memoize greeting so it doesn't change on re-renders (only on user change) + const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]); + return (
{/* Greeting positioned above the composer - fixed position */}

- {getTimeBasedGreeting(user?.email)} + {greeting}

{/* Composer - top edge fixed, expands downward only */} @@ -333,42 +408,226 @@ const ThreadWelcome: FC = () => { }; const Composer: FC = () => { - // Check if a model is configured - needed to disable input - const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); - const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); - const { data: preferences } = useAtomValue(llmPreferencesAtom); + // ---- State for document mentions (using atoms to persist across remounts) ---- + const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); + const [showDocumentPopover, setShowDocumentPopover] = useState(false); + const [mentionQuery, setMentionQuery] = useState(""); + const inputRef = useRef(null); + const documentPickerRef = useRef(null); + const { search_space_id } = useParams(); + const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); - const hasModelConfigured = useMemo(() => { - if (!preferences) return false; - const agentLlmId = preferences.agent_llm_id; - if (agentLlmId === null || agentLlmId === undefined) return false; + // Sync mentioned document IDs to atom for use in chat request + useEffect(() => { + setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + }, [mentionedDocuments, setMentionedDocumentIds]); - // Check if the configured model actually exists - if (agentLlmId < 0) { - return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; + // Extract mention query (text after @) + const extractMentionQuery = useCallback((value: string): string => { + const atIndex = value.lastIndexOf("@"); + if (atIndex === -1) return ""; + return value.slice(atIndex + 1); + }, []); + + const handleKeyUp = (e: React.KeyboardEvent) => { + const textarea = e.currentTarget; + const value = textarea.value; + + // Open document picker when user types '@' + if (e.key === "@" || (e.key === "2" && e.shiftKey)) { + setShowDocumentPopover(true); + setMentionQuery(""); + return; } - return userConfigs?.some((c) => c.id === agentLlmId) ?? false; - }, [preferences, globalConfigs, userConfigs]); + + // Check if value contains @ and extract query + if (value.includes("@")) { + const query = extractMentionQuery(value); + + // Close popup if query starts with space (user typed "@ ") + if (query.startsWith(" ")) { + setShowDocumentPopover(false); + setMentionQuery(""); + return; + } + + // Reopen popup if @ is present and query doesn't start with space + // (handles case where user deleted the space after @) + if (!showDocumentPopover) { + setShowDocumentPopover(true); + } + setMentionQuery(query); + } else { + // Close popover if '@' is no longer in the input (user deleted it) + if (showDocumentPopover) { + setShowDocumentPopover(false); + setMentionQuery(""); + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // When popup is open, handle navigation keys + if (showDocumentPopover) { + if (e.key === "ArrowDown") { + e.preventDefault(); + documentPickerRef.current?.moveDown(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + documentPickerRef.current?.moveUp(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + documentPickerRef.current?.selectHighlighted(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setShowDocumentPopover(false); + setMentionQuery(""); + return; + } + } + + // Remove last document chip when pressing backspace at the beginning of input + if (e.key === "Backspace" && mentionedDocuments.length > 0) { + const textarea = e.currentTarget; + const selectionStart = textarea.selectionStart; + const selectionEnd = textarea.selectionEnd; + + // Only remove chip if cursor is at position 0 and nothing is selected + if (selectionStart === 0 && selectionEnd === 0) { + e.preventDefault(); + // Remove the last document chip + setMentionedDocuments((prev) => prev.slice(0, -1)); + } + } + }; + + const handleDocumentsMention = (documents: Document[]) => { + // Update mentioned documents (merge with existing, avoid duplicates) + setMentionedDocuments((prev) => { + const existingIds = new Set(prev.map((d) => d.id)); + const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + return [...prev, ...newDocs]; + }); + + // Clean up the '@...' mention text from input + if (inputRef.current) { + const input = inputRef.current; + const currentValue = input.value; + const atIndex = currentValue.lastIndexOf("@"); + + if (atIndex !== -1) { + // Remove @ and everything after it + const newValue = currentValue.slice(0, atIndex); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value" + )?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(input, newValue); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + } + // Focus the input so user can continue typing + input.focus(); + } + + // Reset mention query + setMentionQuery(""); + }; + + const handleRemoveDocument = (docId: number) => { + setMentionedDocuments((prev) => prev.filter((doc) => doc.id !== docId)); + }; return ( - + {/* Inline document chips */} + {mentionedDocuments.map((doc) => ( + + {doc.title} + + + ))} + {/* 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" + /> +
+ + {/* -------- Document mention popover (rendered via portal) -------- */} + {showDocumentPopover && + typeof document !== "undefined" && + createPortal( + <> + {/* Backdrop */} + + ); + })} + + )} + + + ); + } +);