From b8f3f413263369f64c677ea6720b2e573c2a0964 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:22:51 +0530 Subject: [PATCH 01/18] refactor: update dependencies and streamline assistant-ui package usages --- .../new-chat/[[...chat_id]]/page.tsx | 75 ------ .../assistant-ui/assistant-message.tsx | 95 ++++++-- .../assistant-ui/thinking-steps.tsx | 52 +---- .../components/assistant-ui/thread.tsx | 88 ++++--- .../components/assistant-ui/user-message.tsx | 12 +- .../public-chat/public-chat-view.tsx | 14 -- .../components/public-chat/public-thread.tsx | 40 +++- .../confluence/create-confluence-page.tsx | 11 +- .../confluence/delete-confluence-page.tsx | 11 +- .../confluence/update-confluence-page.tsx | 11 +- .../components/tool-ui/deepagent-thinking.tsx | 12 +- .../components/tool-ui/display-image.tsx | 9 +- .../components/tool-ui/generate-podcast.tsx | 12 +- .../components/tool-ui/generate-report.tsx | 9 +- .../components/tool-ui/gmail/create-draft.tsx | 68 +++--- .../components/tool-ui/gmail/send-email.tsx | 68 +++--- .../components/tool-ui/gmail/trash-email.tsx | 69 +++--- .../components/tool-ui/gmail/update-draft.tsx | 74 +++--- .../tool-ui/google-calendar/create-event.tsx | 68 +++--- .../tool-ui/google-calendar/delete-event.tsx | 71 +++--- .../tool-ui/google-calendar/update-event.tsx | 70 +++--- .../tool-ui/google-drive/create-file.tsx | 66 +++--- .../tool-ui/google-drive/trash-file.tsx | 70 +++--- .../tool-ui/jira/create-jira-issue.tsx | 11 +- .../tool-ui/jira/delete-jira-issue.tsx | 11 +- .../tool-ui/jira/update-jira-issue.tsx | 11 +- .../tool-ui/linear/create-linear-issue.tsx | 62 +++-- .../tool-ui/linear/delete-linear-issue.tsx | 66 +++--- .../tool-ui/linear/update-linear-issue.tsx | 80 +++---- .../components/tool-ui/link-preview.tsx | 19 +- .../tool-ui/notion/create-notion-page.tsx | 72 +++--- .../tool-ui/notion/delete-notion-page.tsx | 82 +++---- .../tool-ui/notion/update-notion-page.tsx | 78 +++---- .../components/tool-ui/sandbox-execute.tsx | 9 +- .../components/tool-ui/scrape-webpage.tsx | 9 +- .../components/tool-ui/user-memory.tsx | 16 +- .../generate-video-presentation.tsx | 11 +- .../components/tool-ui/write-todos.tsx | 160 ++++++------- surfsense_web/package.json | 5 +- surfsense_web/pnpm-lock.yaml | 219 +++++++----------- 40 files changed, 886 insertions(+), 1110 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 23c640ceb..f569940d7 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 @@ -37,55 +37,11 @@ import { Thread } from "@/components/assistant-ui/thread"; import { MobileEditorPanel } from "@/components/editor-panel/editor-panel"; import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; -import { - CreateConfluencePageToolUI, - DeleteConfluencePageToolUI, - UpdateConfluencePageToolUI, -} from "@/components/tool-ui/confluence"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; -import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; -import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; -import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; -import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; -import { - CreateGmailDraftToolUI, - SendGmailEmailToolUI, - TrashGmailEmailToolUI, - UpdateGmailDraftToolUI, -} from "@/components/tool-ui/gmail"; -import { - CreateCalendarEventToolUI, - DeleteCalendarEventToolUI, - UpdateCalendarEventToolUI, -} from "@/components/tool-ui/google-calendar"; -import { - CreateGoogleDriveFileToolUI, - DeleteGoogleDriveFileToolUI, -} from "@/components/tool-ui/google-drive"; -import { - CreateJiraIssueToolUI, - DeleteJiraIssueToolUI, - UpdateJiraIssueToolUI, -} from "@/components/tool-ui/jira"; -import { - CreateLinearIssueToolUI, - DeleteLinearIssueToolUI, - UpdateLinearIssueToolUI, -} from "@/components/tool-ui/linear"; -import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; -import { - CreateNotionPageToolUI, - DeleteNotionPageToolUI, - UpdateNotionPageToolUI, -} from "@/components/tool-ui/notion"; -import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; -import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; -import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { Skeleton } from "@/components/ui/skeleton"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; import { getBearerToken } from "@/lib/auth-utils"; import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { @@ -1719,37 +1675,6 @@ export default function NewChatPage() { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Disabled for now */}
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 4af5b07ee..72fed42ae 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -1,10 +1,9 @@ import { ActionBarPrimitive, - AssistantIf, + AuiIf, ErrorPrimitive, MessagePrimitive, - useAssistantState, - useMessage, + useAuiState, } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; @@ -21,6 +20,22 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; +import { CreateConfluencePageToolUI, DeleteConfluencePageToolUI, UpdateConfluencePageToolUI } from "@/components/tool-ui/confluence"; +import { DeepAgentThinkingToolUI } from "@/components/tool-ui/deepagent-thinking"; +import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; +import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; +import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; +import { CreateGmailDraftToolUI, SendGmailEmailToolUI, TrashGmailEmailToolUI, UpdateGmailDraftToolUI } from "@/components/tool-ui/gmail"; +import { CreateCalendarEventToolUI, DeleteCalendarEventToolUI, UpdateCalendarEventToolUI } from "@/components/tool-ui/google-calendar"; +import { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "@/components/tool-ui/google-drive"; +import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, UpdateJiraIssueToolUI } from "@/components/tool-ui/jira"; +import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, UpdateLinearIssueToolUI } from "@/components/tool-ui/linear"; +import { LinkPreviewToolUI, MultiLinkPreviewToolUI } from "@/components/tool-ui/link-preview"; +import { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "@/components/tool-ui/notion"; +import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; +import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; +import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; @@ -42,13 +57,13 @@ const ThinkingStepsPart: FC = () => { const thinkingStepsMap = useContext(ThinkingStepsContext); // Get the current message ID to look up thinking steps - const messageId = useAssistantState(({ message }) => message?.id); + const messageId = useAuiState(({ message }) => message?.id); const thinkingSteps = thinkingStepsMap.get(messageId) || []; // Check if this specific message is currently streaming // A message is streaming if: thread is running AND this is the last assistant message - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); + const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; if (thinkingSteps.length === 0) return null; @@ -70,7 +85,43 @@ const AssistantMessageInner: FC = () => { @@ -95,7 +146,7 @@ export const AssistantMessage: FC = () => { const messageRef = useRef(null); const commentPanelRef = useRef(null); const commentTriggerRef = useRef(null); - const messageId = useAssistantState(({ message }) => message?.id); + const messageId = useAuiState(({ message }) => message?.id); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const dbMessageId = parseMessageId(messageId); const commentsEnabled = useAtomValue(commentsEnabledAtom); @@ -104,8 +155,8 @@ export const AssistantMessage: FC = () => { const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)"); const isDesktop = useMediaQuery("(min-width: 1024px)"); - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); + const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; const { data: commentsData, isSuccess: commentsLoaded } = useComments({ @@ -227,38 +278,38 @@ export const AssistantMessage: FC = () => { }; const AssistantActionBar: FC = () => { - const { isLast } = useMessage(); + const isLast = useAuiState((s) => s.message.isLast); return ( - - + - message.isCopied}> + message.isCopied}> - - !message.isCopied}> + + !message.isCopied}> - + - + - {/* Only allow regenerating the last assistant message */} - {isLast && ( + {/* Only allow regenerating the last assistant message */} + {isLast && ( )} - - ); + + ); }; diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx index c773824f8..af87b7c59 100644 --- a/surfsense_web/components/assistant-ui/thinking-steps.tsx +++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx @@ -1,7 +1,6 @@ -import { useAssistantState, useThreadViewport } from "@assistant-ui/react"; import { ChevronRightIcon } from "lucide-react"; import type { FC } from "react"; -import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { createContext, useCallback, useEffect, useState } from "react"; import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; @@ -154,52 +153,3 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: ); }; -/** - * 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. - */ -export 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 -}; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index fa5595de4..e8c765d8c 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1,13 +1,13 @@ import { ActionBarPrimitive, - AssistantIf, + AuiIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, ThreadPrimitive, - useAssistantState, - useComposerRuntime, + useAui, + useAuiState, } from "@assistant-ui/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { @@ -125,19 +125,19 @@ export const Thread: FC = ({ messageThinkingSteps = new Map() }) => const ThreadContent: FC = () => { return ( - - - thread.isEmpty}> + thread.isEmpty}> - + { style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }} > - !thread.isEmpty}> + !thread.isEmpty}>
-
+
-
- ); + + ); }; const ThreadScrollToBottom: FC = () => { @@ -327,11 +327,11 @@ const Composer: FC = () => { const editorContainerRef = useRef(null); const documentPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); - const composerRuntime = useComposerRuntime(); + const aui = useAui(); const hasAutoFocusedRef = useRef(false); - const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); // Cycling placeholder state - only cycles in new chats const [placeholderIndex, setPlaceholderIndex] = useState(0); @@ -378,7 +378,7 @@ const Composer: FC = () => { // hooks never fire their own network requests (eliminates N+1 API calls). // Return a primitive string from the selector so useSyncExternalStore can // compare snapshots by value and avoid infinite re-render loops. - const assistantIdsKey = useAssistantState(({ thread }) => + const assistantIdsKey = useAuiState(({ thread }) => thread.messages .filter((m) => m.role === "assistant" && m.id?.startsWith("msg-")) .map((m) => m.id?.replace("msg-", "")) @@ -414,9 +414,9 @@ const Composer: FC = () => { // Sync editor text with assistant-ui composer runtime const handleEditorChange = useCallback( (text: string) => { - composerRuntime.setText(text); + aui.composer().setText(text); }, - [composerRuntime] + [aui] ); // Open document picker when @ mention is triggered @@ -469,7 +469,7 @@ const Composer: FC = () => { return; } if (!showDocumentPopover) { - composerRuntime.send(); + aui.composer().send(); editorRef.current?.clear(); setMentionedDocuments([]); setSidebarDocs([]); @@ -478,7 +478,7 @@ const Composer: FC = () => { showDocumentPopover, isThreadRunning, isBlockedByOtherUser, - composerRuntime, + aui, setMentionedDocuments, setSidebarDocs, ]); @@ -591,7 +591,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); - const isComposerTextEmpty = useAssistantState(({ composer }) => { + const isComposerTextEmpty = useAuiState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; }); @@ -702,8 +702,8 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; return ( -
-
+
+
{!isDesktop ? ( <> @@ -1007,16 +1007,14 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false )}
- - {!hasModelConfigured && ( + {!hasModelConfigured && (
Select a model
)} - -
- !thread.isRunning}> +
+ !thread.isRunning}> = ({ isBlockedByOtherUser = false - + - thread.isRunning}> + thread.isRunning}> - +
-
- ); +
+ ); }; /** Convert snake_case tool names to human-readable labels */ @@ -1151,13 +1149,13 @@ const ThinkingStepsPart: FC = () => { const thinkingStepsMap = useContext(ThinkingStepsContext); // Get the current message ID to look up thinking steps - const messageId = useAssistantState(({ message }) => message?.id); + const messageId = useAuiState(({ message }) => message?.id); const thinkingSteps = thinkingStepsMap.get(messageId) || []; // Check if this specific message is currently streaming // A message is streaming if: thread is running AND this is the last assistant message - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); + const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; if (thinkingSteps.length === 0) return null; @@ -1195,34 +1193,34 @@ const AssistantMessageInner: FC = () => { const AssistantActionBar: FC = () => { return ( - - + - message.isCopied}> + message.isCopied}> - - !message.isCopied}> + + !message.isCopied}> - + - + - + - - ); + + ); }; const EditComposer: FC = () => { diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 1c0525277..9613b9964 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,4 +1,4 @@ -import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; +import { ActionBarPrimitive, MessagePrimitive, useAuiState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { FileText, Pen } from "lucide-react"; import { type FC, useState } from "react"; @@ -42,10 +42,10 @@ const UserAvatar: FC = ({ displayName, avatarUrl }) => { }; export const UserMessage: FC = () => { - const messageId = useAssistantState(({ message }) => message?.id); + const messageId = useAuiState(({ message }) => message?.id); const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom); const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; - const metadata = useAssistantState(({ message }) => message?.metadata); + const metadata = useAuiState(({ message }) => message?.metadata); const author = metadata?.custom?.author as AuthorMetadata | undefined; return ( @@ -93,13 +93,13 @@ export const UserMessage: FC = () => { }; const UserActionBar: FC = () => { - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); // Get current message ID - const currentMessageId = useAssistantState(({ message }) => message?.id); + const currentMessageId = useAuiState(({ message }) => message?.id); // Find the last user message ID in the thread (computed once, memoized by selector) - const lastUserMessageId = useAssistantState(({ thread }) => { + const lastUserMessageId = useAuiState(({ thread }) => { const messages = thread.messages; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "user") { diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx index cafc0d1a3..52830f601 100644 --- a/surfsense_web/components/public-chat/public-chat-view.tsx +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -3,12 +3,6 @@ import { AssistantRuntimeProvider } from "@assistant-ui/react"; import { Navbar } from "@/components/homepage/navbar"; import { ReportPanel } from "@/components/report-panel/report-panel"; -import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; -import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; -import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; -import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; -import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; -import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { Spinner } from "@/components/ui/spinner"; import { usePublicChat } from "@/hooks/use-public-chat"; import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime"; @@ -45,14 +39,6 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
- {/* Tool UIs for rendering tool results */} - - - - - - -
} /> diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 9b31a1a02..3e6cf663c 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -2,16 +2,22 @@ import { ActionBarPrimitive, - AssistantIf, + AuiIf, MessagePrimitive, ThreadPrimitive, - useAssistantState, + useAuiState, } from "@assistant-ui/react"; import { CheckIcon, CopyIcon } from "lucide-react"; import { type FC, type ReactNode, useState } from "react"; 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 { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; +import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; +import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; +import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; +import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; interface PublicThreadProps { footer?: ReactNode; @@ -93,7 +99,7 @@ const UserAvatar: FC void } }; const PublicUserMessage: FC = () => { - const metadata = useAssistantState(({ message }) => message?.metadata); + const metadata = useAuiState(({ message }) => message?.metadata); const author = metadata?.custom?.author as AuthorMetadata | undefined; return ( @@ -139,7 +145,17 @@ const PublicAssistantMessage: FC = () => {
@@ -153,21 +169,21 @@ const PublicAssistantMessage: FC = () => { const PublicAssistantActionBar: FC = () => { return ( - - + - message.isCopied}> + message.isCopied}> - - !message.isCopied}> + + !message.isCopied}> - + - - ); + + ); }; diff --git a/surfsense_web/components/tool-ui/confluence/create-confluence-page.tsx b/surfsense_web/components/tool-ui/confluence/create-confluence-page.tsx index ea4434852..f0f44e0c7 100644 --- a/surfsense_web/components/tool-ui/confluence/create-confluence-page.tsx +++ b/surfsense_web/components/tool-ui/confluence/create-confluence-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -457,12 +457,10 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateConfluencePageToolUI = makeAssistantToolUI< +export const CreateConfluencePageToolUI = ({ args, result }: ToolCallMessagePartProps< { title: string; content?: string; space_id?: string }, CreateConfluencePageResult ->({ - toolName: "create_confluence_page", - render: function CreateConfluencePageUI({ args, result }) { +>) => { if (!result) return null; if (isInterruptResult(result)) { @@ -494,5 +492,4 @@ export const CreateConfluencePageToolUI = makeAssistantToolUI< if (isErrorResult(result)) return ; return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/confluence/delete-confluence-page.tsx b/surfsense_web/components/tool-ui/confluence/delete-confluence-page.tsx index 211ee3388..a4aa7409e 100644 --- a/surfsense_web/components/tool-ui/confluence/delete-confluence-page.tsx +++ b/surfsense_web/components/tool-ui/confluence/delete-confluence-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CornerDownLeftIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -396,12 +396,10 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const DeleteConfluencePageToolUI = makeAssistantToolUI< +export const DeleteConfluencePageToolUI = ({ result }: ToolCallMessagePartProps< { page_title_or_id: string; delete_from_kb?: boolean }, DeleteConfluencePageResult ->({ - toolName: "delete_confluence_page", - render: function DeleteConfluencePageUI({ result }) { +>) => { if (!result) return null; if (isInterruptResult(result)) { @@ -435,5 +433,4 @@ export const DeleteConfluencePageToolUI = makeAssistantToolUI< if (isErrorResult(result)) return ; return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/confluence/update-confluence-page.tsx b/surfsense_web/components/tool-ui/confluence/update-confluence-page.tsx index 286981d51..afd652900 100644 --- a/surfsense_web/components/tool-ui/confluence/update-confluence-page.tsx +++ b/surfsense_web/components/tool-ui/confluence/update-confluence-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -493,16 +493,14 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const UpdateConfluencePageToolUI = makeAssistantToolUI< +export const UpdateConfluencePageToolUI = ({ args, result }: ToolCallMessagePartProps< { page_title_or_id: string; new_title?: string; new_content?: string; }, UpdateConfluencePageResult ->({ - toolName: "update_confluence_page", - render: function UpdateConfluencePageUI({ args, result }) { +>) => { if (!result) return null; if (isInterruptResult(result)) { @@ -535,5 +533,4 @@ export const UpdateConfluencePageToolUI = makeAssistantToolUI< if (isErrorResult(result)) return ; return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 3e6f668a8..c495c1723 100644 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react"; import type { FC, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -317,12 +317,7 @@ const SmartChainOfThought: FC = ({ steps }) => { * when the deepagent is processing a query. It shows thinking steps * in a collapsible, hierarchical format. */ -export const DeepAgentThinkingToolUI = makeAssistantToolUI< - DeepAgentThinkingArgs, - DeepAgentThinkingResult ->({ - toolName: "deepagent_thinking", - render: function DeepAgentThinkingUI({ result, status }) { +export const DeepAgentThinkingToolUI = ({ result, status }: ToolCallMessagePartProps) => { // Loading state - tool is still running if (status.type === "running" || status.type === "requires-action") { return ; @@ -349,8 +344,7 @@ export const DeepAgentThinkingToolUI = makeAssistantToolUI<
); - }, -}); +}; // ============================================================================ // Public Components diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx index b5fccbc78..824ce0628 100644 --- a/surfsense_web/components/tool-ui/display-image.tsx +++ b/surfsense_web/components/tool-ui/display-image.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { AlertCircleIcon, ImageIcon } from "lucide-react"; import { z } from "zod"; import { @@ -103,9 +103,7 @@ function ParsedImage({ result }: { result: unknown }) { * - Hover overlay effects * - Click to open full size */ -export const DisplayImageToolUI = makeAssistantToolUI({ - toolName: "display_image", - render: function DisplayImageUI({ args, result, status }) { +export const DisplayImageToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const src = args.src || "Unknown"; // Loading state - tool is still running @@ -154,8 +152,7 @@ export const DisplayImageToolUI = makeAssistantToolUI
); - }, -}); +}; export { DisplayImageArgsSchema, diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index bac5b3d5c..b4a4fe5e8 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useParams, usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; @@ -372,12 +372,7 @@ function PodcastStatusPoller({ podcastId, title }: { podcastId: number; title: s * * It polls for task completion and auto-updates when the podcast is ready. */ -export const GeneratePodcastToolUI = makeAssistantToolUI< - GeneratePodcastArgs, - GeneratePodcastResult ->({ - toolName: "generate_podcast", - render: function GeneratePodcastUI({ args, result, status }) { +export const GeneratePodcastToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const title = args.podcast_title || "SurfSense Podcast"; // Loading state - tool is still running (agent processing) @@ -462,5 +457,4 @@ export const GeneratePodcastToolUI = makeAssistantToolUI< // Fallback - missing required data return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx index dd81d3403..33f283bbc 100644 --- a/surfsense_web/components/tool-ui/generate-report.tsx +++ b/surfsense_web/components/tool-ui/generate-report.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { Dot } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; @@ -273,9 +273,7 @@ function ReportCard({ * Generate Report Tool UI — renders custom UI inline in chat * when the generate_report tool is called by the agent. */ -export const GenerateReportToolUI = makeAssistantToolUI({ - toolName: "generate_report", - render: function GenerateReportUI({ args, result, status }) { +export const GenerateReportToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const params = useParams(); const pathname = usePathname(); const isPublicRoute = pathname?.startsWith("/public/"); @@ -332,5 +330,4 @@ export const GenerateReportToolUI = makeAssistantToolUI; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/gmail/create-draft.tsx b/surfsense_web/components/tool-ui/gmail/create-draft.tsx index bca1bba80..aa8f58c72 100644 --- a/surfsense_web/components/tool-ui/gmail/create-draft.tsx +++ b/surfsense_web/components/tool-ui/gmail/create-draft.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen, UserIcon, UsersIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -466,42 +466,42 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateGmailDraftToolUI = makeAssistantToolUI< +export const CreateGmailDraftToolUI = ({ + args, + result, +}: ToolCallMessagePartProps< { to: string; subject: string; body: string; cc?: string; bcc?: string }, CreateGmailDraftResult ->({ - toolName: "create_gmail_draft", - render: function CreateGmailDraftUI({ args, result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/gmail/send-email.tsx b/surfsense_web/components/tool-ui/gmail/send-email.tsx index d3cf9d639..fda375e51 100644 --- a/surfsense_web/components/tool-ui/gmail/send-email.tsx +++ b/surfsense_web/components/tool-ui/gmail/send-email.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -464,42 +464,42 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const SendGmailEmailToolUI = makeAssistantToolUI< +export const SendGmailEmailToolUI = ({ + args, + result, +}: ToolCallMessagePartProps< { to: string; subject: string; body: string; cc?: string; bcc?: string }, SendGmailEmailResult ->({ - toolName: "send_gmail_email", - render: function SendGmailEmailUI({ args, result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/gmail/trash-email.tsx b/surfsense_web/components/tool-ui/gmail/trash-email.tsx index d68c4b03f..f79d093f0 100644 --- a/surfsense_web/components/tool-ui/gmail/trash-email.tsx +++ b/surfsense_web/components/tool-ui/gmail/trash-email.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CalendarIcon, CornerDownLeftIcon, MailIcon, UserIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -379,43 +379,42 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const TrashGmailEmailToolUI = makeAssistantToolUI< +export const TrashGmailEmailToolUI = ({ + result, +}: ToolCallMessagePartProps< { email_subject_or_id: string; delete_from_kb?: boolean }, TrashGmailEmailResult ->({ - toolName: "trash_gmail_email", - render: function TrashGmailEmailUI({ result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isNotFoundResult(result)) return ; - if (isErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isNotFoundResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/gmail/update-draft.tsx b/surfsense_web/components/tool-ui/gmail/update-draft.tsx index 8c9fac109..7789368b2 100644 --- a/surfsense_web/components/tool-ui/gmail/update-draft.tsx +++ b/surfsense_web/components/tool-ui/gmail/update-draft.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -508,7 +508,10 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const UpdateGmailDraftToolUI = makeAssistantToolUI< +export const UpdateGmailDraftToolUI = ({ + args, + result, +}: ToolCallMessagePartProps< { draft_subject_or_id: string; body: string; @@ -518,42 +521,39 @@ export const UpdateGmailDraftToolUI = makeAssistantToolUI< bcc?: string; }, UpdateGmailDraftResult ->({ - toolName: "update_gmail_draft", - render: function UpdateGmailDraftUI({ args, result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isNotFoundResult(result)) return ; - if (isErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isNotFoundResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/google-calendar/create-event.tsx b/surfsense_web/components/tool-ui/google-calendar/create-event.tsx index 8d337c8f7..a2e23dd36 100644 --- a/surfsense_web/components/tool-ui/google-calendar/create-event.tsx +++ b/surfsense_web/components/tool-ui/google-calendar/create-event.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pen, UsersIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -606,7 +606,10 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateCalendarEventToolUI = makeAssistantToolUI< +export const CreateCalendarEventToolUI = ({ + args, + result, +}: ToolCallMessagePartProps< { summary: string; start_datetime: string; @@ -616,39 +619,36 @@ export const CreateCalendarEventToolUI = makeAssistantToolUI< attendees?: string[]; }, CreateCalendarEventResult ->({ - toolName: "create_calendar_event", - render: function CreateCalendarEventUI({ args, result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/google-calendar/delete-event.tsx b/surfsense_web/components/tool-ui/google-calendar/delete-event.tsx index 8df47c3d8..404a6ced2 100644 --- a/surfsense_web/components/tool-ui/google-calendar/delete-event.tsx +++ b/surfsense_web/components/tool-ui/google-calendar/delete-event.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CalendarIcon, ClockIcon, CornerDownLeftIcon, MapPinIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -431,44 +431,43 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const DeleteCalendarEventToolUI = makeAssistantToolUI< +export const DeleteCalendarEventToolUI = ({ + result, +}: ToolCallMessagePartProps< { event_title_or_id: string; delete_from_kb?: boolean }, DeleteCalendarEventResult ->({ - toolName: "delete_calendar_event", - render: function DeleteCalendarEventUI({ result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isNotFoundResult(result)) return ; - if (isWarningResult(result)) return ; - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isErrorResult(result)) return ; + if (isNotFoundResult(result)) return ; + if (isWarningResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/google-calendar/update-event.tsx b/surfsense_web/components/tool-ui/google-calendar/update-event.tsx index c5e261c11..cc941bab8 100644 --- a/surfsense_web/components/tool-ui/google-calendar/update-event.tsx +++ b/surfsense_web/components/tool-ui/google-calendar/update-event.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { ArrowRightIcon, @@ -653,7 +653,10 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const UpdateCalendarEventToolUI = makeAssistantToolUI< +export const UpdateCalendarEventToolUI = ({ + args, + result, +}: ToolCallMessagePartProps< { event_ref: string; new_summary?: string; @@ -664,40 +667,37 @@ export const UpdateCalendarEventToolUI = makeAssistantToolUI< new_attendees?: string[]; }, UpdateCalendarEventResult ->({ - toolName: "update_calendar_event", - render: function UpdateCalendarEventUI({ args, result }) { - if (!result) return null; +>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isNotFoundResult(result)) return ; - if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; - if (isErrorResult(result)) return ; + if (isNotFoundResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) + return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/google-drive/create-file.tsx b/surfsense_web/components/tool-ui/google-drive/create-file.tsx index c0b38db8e..3b0da4a4d 100644 --- a/surfsense_web/components/tool-ui/google-drive/create-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/create-file.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -492,44 +492,38 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateGoogleDriveFileToolUI = makeAssistantToolUI< - { name: string; file_type: string; content?: string }, - CreateGoogleDriveFileResult ->({ - toolName: "create_google_drive_file", - render: function CreateGoogleDriveFileUI({ args, result }) { - if (!result) return null; +export const CreateGoogleDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; file_type: string; content?: string }, CreateGoogleDriveFileResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; + if (isInsufficientPermissionsResult(result)) + return ; - if (isErrorResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/google-drive/trash-file.tsx b/surfsense_web/components/tool-ui/google-drive/trash-file.tsx index 35559bb30..71d20e736 100644 --- a/surfsense_web/components/tool-ui/google-drive/trash-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/trash-file.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CornerDownLeftIcon, InfoIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -410,46 +410,40 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const DeleteGoogleDriveFileToolUI = makeAssistantToolUI< - { file_name: string; delete_from_kb?: boolean }, - DeleteGoogleDriveFileResult ->({ - toolName: "delete_google_drive_file", - render: function DeleteGoogleDriveFileUI({ result }) { - if (!result) return null; +export const DeleteGoogleDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteGoogleDriveFileResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; - if (isInsufficientPermissionsResult(result)) - return ; + if (isInsufficientPermissionsResult(result)) + return ; - if (isNotFoundResult(result)) return ; - if (isWarningResult(result)) return ; - if (isErrorResult(result)) return ; + if (isNotFoundResult(result)) return ; + if (isWarningResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/jira/create-jira-issue.tsx b/surfsense_web/components/tool-ui/jira/create-jira-issue.tsx index 67dac58b1..92a9c521c 100644 --- a/surfsense_web/components/tool-ui/jira/create-jira-issue.tsx +++ b/surfsense_web/components/tool-ui/jira/create-jira-issue.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -536,7 +536,7 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateJiraIssueToolUI = makeAssistantToolUI< +export const CreateJiraIssueToolUI = ({ args, result }: ToolCallMessagePartProps< { project_key: string; summary: string; @@ -545,9 +545,7 @@ export const CreateJiraIssueToolUI = makeAssistantToolUI< priority?: string; }, CreateJiraIssueResult ->({ - toolName: "create_jira_issue", - render: function CreateJiraIssueUI({ args, result }) { +>) => { if (!result) return null; if (isInterruptResult(result)) { @@ -579,5 +577,4 @@ export const CreateJiraIssueToolUI = makeAssistantToolUI< if (isErrorResult(result)) return ; return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/jira/delete-jira-issue.tsx b/surfsense_web/components/tool-ui/jira/delete-jira-issue.tsx index 0ad5be5bd..26a7ec20b 100644 --- a/surfsense_web/components/tool-ui/jira/delete-jira-issue.tsx +++ b/surfsense_web/components/tool-ui/jira/delete-jira-issue.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CornerDownLeftIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -393,12 +393,10 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const DeleteJiraIssueToolUI = makeAssistantToolUI< +export const DeleteJiraIssueToolUI = ({ result }: ToolCallMessagePartProps< { issue_title_or_key: string; delete_from_kb?: boolean }, DeleteJiraIssueResult ->({ - toolName: "delete_jira_issue", - render: function DeleteJiraIssueUI({ result }) { +>) => { if (!result) return null; if (isInterruptResult(result)) { @@ -432,5 +430,4 @@ export const DeleteJiraIssueToolUI = makeAssistantToolUI< if (isErrorResult(result)) return ; return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/jira/update-jira-issue.tsx b/surfsense_web/components/tool-ui/jira/update-jira-issue.tsx index f085a04d6..7d1547b48 100644 --- a/surfsense_web/components/tool-ui/jira/update-jira-issue.tsx +++ b/surfsense_web/components/tool-ui/jira/update-jira-issue.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -553,7 +553,7 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const UpdateJiraIssueToolUI = makeAssistantToolUI< +export const UpdateJiraIssueToolUI = ({ args, result }: ToolCallMessagePartProps< { issue_title_or_key: string; new_summary?: string; @@ -561,9 +561,7 @@ export const UpdateJiraIssueToolUI = makeAssistantToolUI< new_priority?: string; }, UpdateJiraIssueResult ->({ - toolName: "update_jira_issue", - render: function UpdateJiraIssueUI({ args, result }) { +>) => { if (!result) return null; if (isInterruptResult(result)) { @@ -596,5 +594,4 @@ export const UpdateJiraIssueToolUI = makeAssistantToolUI< if (isErrorResult(result)) return ; return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx index 39e689a46..394af8306 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -605,40 +605,34 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateLinearIssueToolUI = makeAssistantToolUI< - { title: string; description?: string }, - CreateLinearIssueResult ->({ - toolName: "create_linear_issue", - render: function CreateLinearIssueUI({ args, result }) { - if (!result) return null; +export const CreateLinearIssueToolUI = ({ args, result }: ToolCallMessagePartProps<{ title: string; description?: string }, CreateLinearIssueResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isAuthErrorResult(result)) return ; - if (isErrorResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/linear/delete-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/delete-linear-issue.tsx index 592f01555..5302c7646 100644 --- a/surfsense_web/components/tool-ui/linear/delete-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/delete-linear-issue.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CornerDownLeftIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -360,42 +360,36 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const DeleteLinearIssueToolUI = makeAssistantToolUI< - { issue_ref: string; delete_from_kb?: boolean }, - DeleteLinearIssueResult ->({ - toolName: "delete_linear_issue", - render: function DeleteLinearIssueUI({ result }) { - if (!result) return null; +export const DeleteLinearIssueToolUI = ({ result }: ToolCallMessagePartProps<{ issue_ref: string; delete_from_kb?: boolean }, DeleteLinearIssueResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isNotFoundResult(result)) return ; - if (isAuthErrorResult(result)) return ; - if (isWarningResult(result)) return ; - if (isErrorResult(result)) return ; + if (isNotFoundResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isWarningResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index 0b0aa4623..d43e89b7f 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -739,49 +739,43 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const UpdateLinearIssueToolUI = makeAssistantToolUI< - { - issue_ref: string; - new_title?: string; - new_description?: string; - new_state_name?: string; - new_assignee_email?: string; - new_priority?: number; - new_label_names?: string[]; - }, - UpdateLinearIssueResult ->({ - toolName: "update_linear_issue", - render: function UpdateLinearIssueUI({ args, result }) { - if (!result) return null; +export const UpdateLinearIssueToolUI = ({ args, result }: ToolCallMessagePartProps<{ + issue_ref: string; + new_title?: string; + new_description?: string; + new_state_name?: string; + new_assignee_email?: string; + new_priority?: number; + new_label_names?: string[]; +}, UpdateLinearIssueResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - window.dispatchEvent( - new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) - ); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isNotFoundResult(result)) return ; - if (isAuthErrorResult(result)) return ; - if (isErrorResult(result)) return ; + if (isNotFoundResult(result)) return ; + if (isAuthErrorResult(result)) return ; + if (isErrorResult(result)) return ; - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/link-preview.tsx b/surfsense_web/components/tool-ui/link-preview.tsx index 5c1a952b2..7af00c5ba 100644 --- a/surfsense_web/components/tool-ui/link-preview.tsx +++ b/surfsense_web/components/tool-ui/link-preview.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react"; import { z } from "zod"; import { @@ -111,9 +111,7 @@ function ParsedMediaCard({ result }: { result: unknown }) { * - Domain name * - Clickable link to open in new tab */ -export const LinkPreviewToolUI = makeAssistantToolUI({ - toolName: "link_preview", - render: function LinkPreviewUI({ args, result, status }) { +export const LinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const url = args.url || "Unknown URL"; // Loading state - tool is still running @@ -162,8 +160,7 @@ export const LinkPreviewToolUI = makeAssistantToolUI
); - }, -}); +}; // ============================================================================ // Multi Link Preview Schemas @@ -195,12 +192,7 @@ const MultiLinkPreviewResultSchema = z.object({ type MultiLinkPreviewArgs = z.infer; type MultiLinkPreviewResult = z.infer; -export const MultiLinkPreviewToolUI = makeAssistantToolUI< - MultiLinkPreviewArgs, - MultiLinkPreviewResult ->({ - toolName: "multi_link_preview", - render: function MultiLinkPreviewUI({ args, result, status }) { +export const MultiLinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const urls = args.urls || []; // Loading state @@ -244,8 +236,7 @@ export const MultiLinkPreviewToolUI = makeAssistantToolUI< ))}
); - }, -}); +}; export { LinkPreviewArgsSchema, diff --git a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx index 3b89b3c4a..c3beb77f9 100644 --- a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -445,46 +445,40 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const CreateNotionPageToolUI = makeAssistantToolUI< - { title: string; content: string }, - CreateNotionPageResult ->({ - toolName: "create_notion_page", - render: function CreateNotionPageUI({ args, result }) { - if (!result) return null; +export const CreateNotionPageToolUI = ({ args, result }: ToolCallMessagePartProps<{ title: string; content: string }, CreateNotionPageResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if (isAuthErrorResult(result)) { - return ; - } + if (isAuthErrorResult(result)) { + return ; + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isErrorResult(result)) { - return ; - } + if (isErrorResult(result)) { + return ; + } - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx b/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx index c3f78209d..4709bde01 100644 --- a/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { CornerDownLeftIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -372,53 +372,47 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const DeleteNotionPageToolUI = makeAssistantToolUI< - { page_title: string; delete_from_kb?: boolean }, - DeleteNotionPageResult ->({ - toolName: "delete_notion_page", - render: function DeleteNotionPageUI({ result }) { - if (!result) return null; +export const DeleteNotionPageToolUI = ({ result }: ToolCallMessagePartProps<{ page_title: string; delete_from_kb?: boolean }, DeleteNotionPageResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isInfoResult(result)) { - return ; - } + if (isInfoResult(result)) { + return ; + } - if (isWarningResult(result)) { - return ; - } + if (isWarningResult(result)) { + return ; + } - if (isAuthErrorResult(result)) { - return ; - } + if (isAuthErrorResult(result)) { + return ; + } - if (isErrorResult(result)) { - return ; - } + if (isErrorResult(result)) { + return ; + } - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx index b3bb05117..244f57211 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -395,50 +395,44 @@ function SuccessCard({ result }: { result: SuccessResult }) { ); } -export const UpdateNotionPageToolUI = makeAssistantToolUI< - { page_title: string; content: string }, - UpdateNotionPageResult ->({ - toolName: "update_notion_page", - render: function UpdateNotionPageUI({ args, result }) { - if (!result) return null; +export const UpdateNotionPageToolUI = ({ args, result }: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => { + if (!result) return null; - if (isInterruptResult(result)) { - return ( - { - const event = new CustomEvent("hitl-decision", { - detail: { decisions: [decision] }, - }); - window.dispatchEvent(event); - }} - /> - ); - } + if (isInterruptResult(result)) { + return ( + { + const event = new CustomEvent("hitl-decision", { + detail: { decisions: [decision] }, + }); + window.dispatchEvent(event); + }} + /> + ); + } - if ( - typeof result === "object" && - result !== null && - "status" in result && - (result as { status: string }).status === "rejected" - ) { - return null; - } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) { + return null; + } - if (isInfoResult(result)) { - return ; - } + if (isInfoResult(result)) { + return ; + } - if (isAuthErrorResult(result)) { - return ; - } + if (isAuthErrorResult(result)) { + return ; + } - if (isErrorResult(result)) { - return ; - } + if (isErrorResult(result)) { + return ; + } - return ; - }, -}); + return ; +}; diff --git a/surfsense_web/components/tool-ui/sandbox-execute.tsx b/surfsense_web/components/tool-ui/sandbox-execute.tsx index d4e60de9e..2de1b7f43 100644 --- a/surfsense_web/components/tool-ui/sandbox-execute.tsx +++ b/surfsense_web/components/tool-ui/sandbox-execute.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { AlertCircleIcon, CheckCircle2Icon, @@ -380,9 +380,7 @@ function ExecuteCompleted({ // Tool UI // ============================================================================ -export const SandboxExecuteToolUI = makeAssistantToolUI({ - toolName: "execute", - render: function SandboxExecuteUI({ args, result, status }) { +export const SandboxExecuteToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const command = args.command || "…"; if (status.type === "running" || status.type === "requires-action") { @@ -414,7 +412,6 @@ export const SandboxExecuteToolUI = makeAssistantToolUI; - }, -}); +}; export { ExecuteArgsSchema, ExecuteResultSchema, type ExecuteArgs, type ExecuteResult }; diff --git a/surfsense_web/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx index 87fae8868..a17c56734 100644 --- a/surfsense_web/components/tool-ui/scrape-webpage.tsx +++ b/surfsense_web/components/tool-ui/scrape-webpage.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { AlertCircleIcon, FileTextIcon } from "lucide-react"; import { z } from "zod"; import { @@ -104,9 +104,7 @@ function ParsedArticle({ result }: { result: unknown }) { * - Word count * - Link to original source */ -export const ScrapeWebpageToolUI = makeAssistantToolUI({ - toolName: "scrape_webpage", - render: function ScrapeWebpageUI({ args, result, status }) { +export const ScrapeWebpageToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const url = args.url || "Unknown URL"; // Loading state - tool is still running @@ -155,8 +153,7 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI
); - }, -}); +}; export { ScrapeWebpageArgsSchema, diff --git a/surfsense_web/components/tool-ui/user-memory.tsx b/surfsense_web/components/tool-ui/user-memory.tsx index f7c002887..9fa4d2bc6 100644 --- a/surfsense_web/components/tool-ui/user-memory.tsx +++ b/surfsense_web/components/tool-ui/user-memory.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { BrainIcon, CheckIcon, Loader2Icon, SearchIcon, XIcon } from "lucide-react"; import { z } from "zod"; @@ -80,9 +80,7 @@ function CategoryBadge({ category }: { category: string }) { // Save Memory Tool UI // ============================================================================ -export const SaveMemoryToolUI = makeAssistantToolUI({ - toolName: "save_memory", - render: function SaveMemoryUI({ args, result, status }) { +export const SaveMemoryToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const isRunning = status.type === "running" || status.type === "requires-action"; const isComplete = status.type === "complete"; const isError = result?.status === "error"; @@ -159,16 +157,13 @@ export const SaveMemoryToolUI = makeAssistantToolUI({ - toolName: "recall_memory", - render: function RecallMemoryUI({ args, result, status }) { +export const RecallMemoryToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { const isRunning = status.type === "running" || status.type === "requires-action"; const isComplete = status.type === "complete"; const isError = result?.status === "error"; @@ -263,8 +258,7 @@ export const RecallMemoryToolUI = makeAssistantToolUI; } -export const GenerateVideoPresentationToolUI = makeAssistantToolUI< +export const GenerateVideoPresentationToolUI = ({ args, result, status }: ToolCallMessagePartProps< GenerateVideoPresentationArgs, GenerateVideoPresentationResult ->({ - toolName: "generate_video_presentation", - render: function GenerateVideoPresentationUI({ args, result, status }) { +>) => { const params = useParams(); const pathname = usePathname(); const isPublicRoute = pathname?.startsWith("/public/"); @@ -705,5 +703,4 @@ export const GenerateVideoPresentationToolUI = makeAssistantToolUI< } return ; - }, -}); +}; diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx index 9b959bd33..104cbcf44 100644 --- a/surfsense_web/components/tool-ui/write-todos.tsx +++ b/surfsense_web/components/tool-ui/write-todos.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react"; +import { type ToolCallMessagePartProps, useAuiState } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { useEffect, useMemo } from "react"; import { z } from "zod"; @@ -63,96 +63,98 @@ function WriteTodosLoading() { * only the FIRST component renders. Subsequent updates just update the * shared state, and the first component reads from it. */ -export const WriteTodosToolUI = makeAssistantToolUI({ - toolName: "write_todos", - render: function WriteTodosUI({ args, result, status, toolCallId }) { - const updatePlanState = useSetAtom(updatePlanStateAtom); - const planStates = useAtomValue(planStatesAtom); +export const WriteTodosToolUI = ({ + args, + result, + status, + toolCallId, +}: ToolCallMessagePartProps) => { + const updatePlanState = useSetAtom(updatePlanStateAtom); + const planStates = useAtomValue(planStatesAtom); - // Check if the THREAD is running - const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + // Check if the THREAD is running + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); - // Use result if available, otherwise args (for streaming) - const data = result || args; - const hasTodos = data?.todos && data.todos.length > 0; + // Use result if available, otherwise args (for streaming) + const data = result || args; + const hasTodos = data?.todos && data.todos.length > 0; - // Fixed title for all plans in conversation - const planTitle = "Plan"; + // Fixed title for all plans in conversation + const planTitle = "Plan"; - // SYNCHRONOUS ownership check - const isOwner = useMemo(() => { - return registerPlanOwner(planTitle, toolCallId); - }, [planTitle, toolCallId]); + // SYNCHRONOUS ownership check + const isOwner = useMemo(() => { + return registerPlanOwner(planTitle, toolCallId); + }, [planTitle, toolCallId]); - // Get canonical title - const canonicalTitle = useMemo(() => getCanonicalPlanTitle(planTitle), [planTitle]); + // Get canonical title + const canonicalTitle = useMemo(() => getCanonicalPlanTitle(planTitle), [planTitle]); - // Register/update the plan state - useEffect(() => { - if (hasTodos) { - const normalizedPlan = parseSerializablePlan({ todos: data.todos }); - updatePlanState({ - id: normalizedPlan.id, - title: canonicalTitle, - todos: normalizedPlan.todos, - toolCallId, - }); - } - }, [data, hasTodos, canonicalTitle, updatePlanState, toolCallId]); - - // Get the current plan state - const currentPlanState = planStates.get(canonicalTitle); - - // If we're NOT the owner, render nothing - if (!isOwner) { - return null; + // Register/update the plan state + useEffect(() => { + if (hasTodos) { + const normalizedPlan = parseSerializablePlan({ todos: data.todos }); + updatePlanState({ + id: normalizedPlan.id, + title: canonicalTitle, + todos: normalizedPlan.todos, + toolCallId, + }); } + }, [data, hasTodos, canonicalTitle, updatePlanState, toolCallId]); - // Loading state - if (status.type === "running" || status.type === "requires-action") { - if (hasTodos) { - const plan = parseSerializablePlan({ todos: data.todos }); - return ( -
- - - -
- ); - } - return ; + // Get the current plan state + const currentPlanState = planStates.get(canonicalTitle); + + // If we're NOT the owner, render nothing + if (!isOwner) { + return null; + } + + // Loading state + if (status.type === "running" || status.type === "requires-action") { + if (hasTodos) { + const plan = parseSerializablePlan({ todos: data.todos }); + return ( +
+ + + +
+ ); } + return ; + } - // Incomplete/cancelled state - if (status.type === "incomplete") { - if (currentPlanState || hasTodos) { - const plan = currentPlanState || parseSerializablePlan({ todos: data?.todos || [] }); - return ( -
- - - -
- ); - } - return null; + // Incomplete/cancelled state + if (status.type === "incomplete") { + if (currentPlanState || hasTodos) { + const plan = currentPlanState || parseSerializablePlan({ todos: data?.todos || [] }); + return ( +
+ + + +
+ ); } + return null; + } - // Success - render the plan - const planToRender = - currentPlanState || (hasTodos ? parseSerializablePlan({ todos: data.todos }) : null); - if (!planToRender) { - return ; - } + // Success - render the plan + const planToRender = + currentPlanState || (hasTodos ? parseSerializablePlan({ todos: data.todos }) : null); + if (!planToRender) { + return ; + } - return ( -
- - - -
- ); - }, -}); + return ( +
+ + + +
+ ); +}; export { WriteTodosSchema, type WriteTodosData }; diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 2a33c9ab2..67fe6260a 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -23,9 +23,8 @@ "dependencies": { "@ai-sdk/react": "^1.2.12", "@ariakit/react": "^0.4.21", - "@assistant-ui/react": "^0.11.53", - "@assistant-ui/react-ai-sdk": "^1.1.20", - "@assistant-ui/react-markdown": "^0.11.9", + "@assistant-ui/react": "^0.12.19", + "@assistant-ui/react-markdown": "^0.12.6", "@babel/standalone": "^7.29.2", "@electric-sql/client": "^1.4.0", "@electric-sql/pglite": "^0.3.14", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 51c3f3f1e..ee6da25a4 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -15,14 +15,11 @@ importers: specifier: ^0.4.21 version: 0.4.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@assistant-ui/react': - specifier: ^0.11.53 - version: 0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@assistant-ui/react-ai-sdk': - specifier: ^1.1.20 - version: 1.3.8(@assistant-ui/react@0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(assistant-cloud@0.1.18)(react@19.2.4) + specifier: ^0.12.19 + version: 0.12.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) '@assistant-ui/react-markdown': - specifier: ^0.11.9 - version: 0.11.10(@assistant-ui/react@0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^0.12.6 + version: 0.12.6(@assistant-ui/react@0.12.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@babel/standalone': specifier: ^7.29.2 version: 7.29.2 @@ -441,32 +438,16 @@ importers: packages: - '@ai-sdk/gateway@3.0.53': - resolution: {integrity: sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@2.2.8': resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} engines: {node: '>=18'} peerDependencies: zod: ^3.23.8 - '@ai-sdk/provider-utils@4.0.15': - resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@1.1.3': resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} - '@ai-sdk/provider@3.0.8': - resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} - engines: {node: '>=18'} - '@ai-sdk/react@1.2.12': resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} engines: {node: '>=18'} @@ -477,12 +458,6 @@ packages: zod: optional: true - '@ai-sdk/react@3.0.99': - resolution: {integrity: sha512-xMsp5br4Dpr/3BYq/jrE8q4YLgViU1KHVq8VB0+dzdLJFU3jKA83uoxpbWqzV/edQOBPgGBSb2CgmV5v77rvzA==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 - '@ai-sdk/ui-utils@1.2.11': resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} engines: {node: '>=18'} @@ -508,31 +483,37 @@ packages: react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@assistant-ui/react-ai-sdk@1.3.8': - resolution: {integrity: sha512-LJ2k2r4SYDfH2gmd5xIsu7XBNGucN7ipLgzHmZ4nd8MX8/S/lBmfiNIUko7MPbwbauq6G4KPmRVsiJ5QrqIx6A==} + '@assistant-ui/core@0.1.7': + resolution: {integrity: sha512-219T42ihVOicbJXZLWgD2CW5Bylg9Nk7geC331X4RfJxTDYlm2zIjViGlGaqfj6URXBp6kMulO2BTUrHGmAvdw==} peerDependencies: - '@assistant-ui/react': ^0.12.11 + '@assistant-ui/store': ^0.2.3 + '@assistant-ui/tap': ^0.5.3 '@types/react': '*' - assistant-cloud: '*' + assistant-cloud: ^0.1.22 react: ^18 || ^19 + zustand: ^5.0.11 peerDependenciesMeta: '@types/react': optional: true assistant-cloud: optional: true + react: + optional: true + zustand: + optional: true - '@assistant-ui/react-markdown@0.11.10': - resolution: {integrity: sha512-7JFd9/s/ZzOtUAHfrxvij4Ti+4V42FVyjF9veWRUsGKKcw7bBZvBxyb2cBMr93sUf0R1eQHsIV39hZjil8J7lw==} + '@assistant-ui/react-markdown@0.12.6': + resolution: {integrity: sha512-utJqsdDXB3UVZfOa3ErLpaTHraeXkDshR0D34shWdTHrmLyx9e/HypTu4+BgiSsxS+ME6t9WO9M3VeGDprfUcQ==} peerDependencies: - '@assistant-ui/react': ^0.11.58 + '@assistant-ui/react': ^0.12.19 '@types/react': '*' react: ^18 || ^19 peerDependenciesMeta: '@types/react': optional: true - '@assistant-ui/react@0.11.58': - resolution: {integrity: sha512-5VbparS71X36Q7g+mHwXZvo4eaJohKkQzMP8jBZD9V/Bl26I8s/s3q9WjRqYWMRWaiyYaoEgnQhESM9yyBtW2g==} + '@assistant-ui/react@0.12.19': + resolution: {integrity: sha512-scAf0o8cwjuHT9Y44EFGXcE2y6BSmpeMvt0NxOn8+Y/HBlNttQMLNvrM0p2AjacXCUufagiafAnWybzBV3nKEQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -544,8 +525,18 @@ packages: '@types/react-dom': optional: true - '@assistant-ui/tap@0.3.6': - resolution: {integrity: sha512-4IAN32J9820qbwdc7DeR5HxJVTj+cRPVSMwa9Fv2oP2eMFPAV1eZ8+/co6mgtuM9jSc38vYtZntPsGSHwL7rTg==} + '@assistant-ui/store@0.2.3': + resolution: {integrity: sha512-daStbgSQiX7+csqK6Cvo7A8p8UZkTCSMxBHxbhJvwrlVbp7BRJWTxq3U3rpTkSGIar23SXIyVRRfXU8VW7pswA==} + peerDependencies: + '@assistant-ui/tap': ^0.5.3 + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/tap@0.5.3': + resolution: {integrity: sha512-wy06ksqF2LfFxe4JXy31Ns89N/be1Dy3c+mG363cFHFp3CbLkRu8CrCN2SQSgCkXt628E+D8QyzqdBcl9kD4NQ==} peerDependencies: '@types/react': '*' react: ^18 || ^19 @@ -1098,6 +1089,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/standalone@7.29.2': resolution: {integrity: sha512-VSuvywmVRS8efooKrvJzs6BlVSxRvAdLeGrAKUrWoBx1fFBSeE/oBpUZCQ5BcprLyXy04W8skzz7JT8GqlNRJg==} engines: {node: '>=6.9.0'} @@ -4143,10 +4138,6 @@ packages: cpu: [x64] os: [win32] - '@vercel/oidc@3.1.0': - resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} - engines: {node: '>= 20'} - '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -4171,12 +4162,6 @@ packages: react: optional: true - ai@6.0.97: - resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -4227,14 +4212,11 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - assistant-cloud@0.1.18: - resolution: {integrity: sha512-6tq2jPGIBjkjsLQ/Fd4r6PGj4hf05oM2jBl4hBs7YIkaJ3qBVUWiHary2+faNpsPOoY71brsVukl/qz5B1rQkA==} + assistant-cloud@0.1.22: + resolution: {integrity: sha512-AEE9shV+oFrGDv/MRTRERctNKpIYS0n34UpAQXXICiOkSWD6QZnS1ljLqruFko7fJoT5CIWq8dNeJWdzQLTBLg==} - assistant-stream@0.2.47: - resolution: {integrity: sha512-0f+yVwoh7GVwYqaWh6vT+P/zflvEyqysJJzGhjqOPxUYjbNOjcifBw+fVwQPtxysyzye2TZCQtmOWjP0ggvnqw==} - - assistant-stream@0.3.3: - resolution: {integrity: sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA==} + assistant-stream@0.3.6: + resolution: {integrity: sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw==} ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -4959,10 +4941,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -6057,6 +6035,11 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -7149,6 +7132,11 @@ packages: peerDependencies: react: '>=16.8.0' + use-effect-event@2.0.3: + resolution: {integrity: sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ==} + peerDependencies: + react: ^18.3 || ^19.0.0-0 + use-intl@4.8.3: resolution: {integrity: sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==} peerDependencies: @@ -7331,13 +7319,6 @@ packages: snapshots: - '@ai-sdk/gateway@3.0.53(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - '@vercel/oidc': 3.1.0 - zod: 4.3.6 - '@ai-sdk/provider-utils@2.2.8(zod@4.3.6)': dependencies: '@ai-sdk/provider': 1.1.3 @@ -7345,21 +7326,10 @@ snapshots: secure-json-parse: 2.7.0 zod: 4.3.6 - '@ai-sdk/provider-utils@4.0.15(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 4.3.6 - '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@3.0.8': - dependencies: - json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@19.2.4)(zod@4.3.6)': dependencies: '@ai-sdk/provider-utils': 2.2.8(zod@4.3.6) @@ -7370,16 +7340,6 @@ snapshots: optionalDependencies: zod: 4.3.6 - '@ai-sdk/react@3.0.99(react@19.2.4)(zod@4.3.6)': - dependencies: - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - ai: 6.0.97(zod@4.3.6) - react: 19.2.4 - swr: 2.4.0(react@19.2.4) - throttleit: 2.1.0 - transitivePeerDependencies: - - zod - '@ai-sdk/ui-utils@1.2.11(zod@4.3.6)': dependencies: '@ai-sdk/provider': 1.1.3 @@ -7405,20 +7365,21 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@assistant-ui/react-ai-sdk@1.3.8(@assistant-ui/react@0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(assistant-cloud@0.1.18)(react@19.2.4)': + '@assistant-ui/core@0.1.7(@assistant-ui/store@0.2.3(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(assistant-cloud@0.1.22)(react@19.2.4)(zustand@5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))': dependencies: - '@ai-sdk/react': 3.0.99(react@19.2.4)(zod@4.3.6) - '@assistant-ui/react': 0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - ai: 6.0.97(zod@4.3.6) - react: 19.2.4 - zod: 4.3.6 + '@assistant-ui/store': 0.2.3(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@assistant-ui/tap': 0.5.3(@types/react@19.2.14)(react@19.2.4) + assistant-stream: 0.3.6 + nanoid: 5.1.7 optionalDependencies: '@types/react': 19.2.14 - assistant-cloud: 0.1.18 + assistant-cloud: 0.1.22 + react: 19.2.4 + zustand: 5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@assistant-ui/react-markdown@0.11.10(@assistant-ui/react@0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@assistant-ui/react-markdown@0.12.6(@assistant-ui/react@0.12.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@assistant-ui/react': 0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@assistant-ui/react': 0.12.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) classnames: 2.5.1 @@ -7431,21 +7392,21 @@ snapshots: - react-dom - supports-color - '@assistant-ui/react@0.11.58(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@assistant-ui/react@0.12.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@assistant-ui/tap': 0.3.6(@types/react@19.2.14)(react@19.2.4) + '@assistant-ui/core': 0.1.7(@assistant-ui/store@0.2.3(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(assistant-cloud@0.1.22)(react@19.2.4)(zustand@5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))) + '@assistant-ui/store': 0.2.3(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@assistant-ui/tap': 0.5.3(@types/react@19.2.14)(react@19.2.4) '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - assistant-cloud: 0.1.18 - assistant-stream: 0.2.47 - nanoid: 5.1.6 + assistant-cloud: 0.1.22 + assistant-stream: 0.3.6 + nanoid: 5.1.7 + radix-ui: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4) @@ -7458,7 +7419,15 @@ snapshots: - immer - use-sync-external-store - '@assistant-ui/tap@0.3.6(@types/react@19.2.14)(react@19.2.4)': + '@assistant-ui/store@0.2.3(@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@assistant-ui/tap': 0.5.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + use-effect-event: 2.0.3(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@assistant-ui/tap@0.5.3(@types/react@19.2.14)(react@19.2.4)': optionalDependencies: '@types/react': 19.2.14 react: 19.2.4 @@ -8172,6 +8141,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} + '@babel/standalone@7.29.2': {} '@babel/template@7.28.6': @@ -11052,8 +11023,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/oidc@3.1.0': {} - '@xmldom/xmldom@0.8.11': {} acorn-jsx@5.3.2(acorn@8.16.0): @@ -11074,14 +11043,6 @@ snapshots: optionalDependencies: react: 19.2.4 - ai@6.0.97(zod@4.3.6): - dependencies: - '@ai-sdk/gateway': 3.0.53(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - '@opentelemetry/api': 1.9.0 - zod: 4.3.6 - ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -11168,20 +11129,14 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - assistant-cloud@0.1.18: + assistant-cloud@0.1.22: dependencies: - assistant-stream: 0.3.3 + assistant-stream: 0.3.6 - assistant-stream@0.2.47: + assistant-stream@0.3.6: dependencies: '@standard-schema/spec': 1.1.0 - nanoid: 5.1.6 - secure-json-parse: 4.1.0 - - assistant-stream@0.3.3: - dependencies: - '@standard-schema/spec': 1.1.0 - nanoid: 5.1.6 + nanoid: 5.1.7 secure-json-parse: 4.1.0 ast-types-flow@0.0.8: {} @@ -12054,8 +12009,6 @@ snapshots: esutils@2.0.3: {} - eventsource-parser@3.0.6: {} - extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -13437,6 +13390,8 @@ snapshots: nanoid@5.1.6: {} + nanoid@5.1.7: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -14027,7 +13982,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) @@ -14884,6 +14839,10 @@ snapshots: dequal: 2.0.3 react: 19.2.4 + use-effect-event@2.0.3(react@19.2.4): + dependencies: + react: 19.2.4 + use-intl@4.8.3(react@19.2.4): dependencies: '@formatjs/fast-memoize': 3.1.0 From e587b588c9d00487497f00eb1444e164da2cf56b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:23:05 +0530 Subject: [PATCH 02/18] refactor: migrate thinking steps handling to new data structure and streamline related components --- .../new-chat/[[...chat_id]]/page.tsx | 176 +++++------------- .../assistant-ui/assistant-message.tsx | 36 +--- .../assistant-ui/thinking-steps.tsx | 43 +++-- .../components/assistant-ui/thread.tsx | 148 +-------------- .../public-chat/public-chat-view.tsx | 2 + surfsense_web/lib/chat/message-utils.ts | 40 ++-- surfsense_web/lib/chat/streaming-state.ts | 43 ++++- 7 files changed, 135 insertions(+), 353 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 f569940d7..b303a45f5 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 @@ -34,10 +34,10 @@ import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; +import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { MobileEditorPanel } from "@/components/editor-panel/editor-panel"; import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; -import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Skeleton } from "@/components/ui/skeleton"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; @@ -57,6 +57,7 @@ import { type ContentPartsState, readSSEStream, type ThinkingStepData, + updateThinkingSteps, updateToolCall, } from "@/lib/chat/streaming-state"; import { @@ -93,23 +94,6 @@ function markInterruptsCompleted(contentParts: Array<{ type: string; result?: un } } -/** - * Extract thinking steps from message content - */ -function extractThinkingSteps(content: unknown): ThinkingStep[] { - if (!Array.isArray(content)) return []; - - const thinkingPart = content.find( - (part: unknown) => - typeof part === "object" && - part !== null && - "type" in part && - (part as { type: string }).type === "thinking-steps" - ) as { type: "thinking-steps"; steps: ThinkingStep[] } | undefined; - - return thinkingPart?.steps || []; -} - /** * Zod schema for mentioned document info (for type-safe parsing) */ @@ -183,11 +167,6 @@ export default function NewChatPage() { const [currentThread, setCurrentThread] = useState(null); const [messages, setMessages] = useState([]); const [isRunning, setIsRunning] = useState(false); - // Store thinking steps per message ID - kept separate from content to avoid - // "unsupported part type" errors from assistant-ui - const [messageThinkingSteps, setMessageThinkingSteps] = useState>( - new Map() - ); const abortControllerRef = useRef(null); const [pendingInterrupt, setPendingInterrupt] = useState<{ threadId: number; @@ -295,7 +274,6 @@ export default function NewChatPage() { setMessages([]); setThreadId(null); setCurrentThread(null); - setMessageThinkingSteps(new Map()); setMentionedDocuments([]); setSidebarDocuments([]); setMessageDocumentsMap({}); @@ -320,18 +298,8 @@ export default function NewChatPage() { const loadedMessages = messagesResponse.messages.map(convertToThreadMessage); setMessages(loadedMessages); - // 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 messagesResponse.messages) { - if (msg.role === "assistant") { - const steps = extractThinkingSteps(msg.content); - if (steps.length > 0) { - restoredThinkingSteps.set(`msg-${msg.id}`, steps); - } - } if (msg.role === "user") { const docs = extractMentionedDocuments(msg.content); if (docs.length > 0) { @@ -339,9 +307,6 @@ export default function NewChatPage() { } } } - if (restoredThinkingSteps.size > 0) { - setMessageThinkingSteps(restoredThinkingSteps); - } if (Object.keys(restoredDocsMap).length > 0) { setMessageDocumentsMap(restoredDocsMap); } @@ -745,18 +710,17 @@ export default function NewChatPage() { } case "data-thinking-step": { - // Handle thinking step events for chain-of-thought display const stepData = parsed.data as ThinkingStepData; if (stepData?.id) { currentThinkingSteps.set(stepData.id, stepData); - // Update thinking steps state for rendering - // The ThinkingStepsScrollHandler in Thread component - // will handle auto-scrolling when this state changes - setMessageThinkingSteps((prev) => { - const newMap = new Map(prev); - newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); - return newMap; - }); + updateThinkingSteps(contentPartsState, currentThinkingSteps); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); } break; } @@ -821,13 +785,8 @@ export default function NewChatPage() { } } - // Persist assistant message (with thinking steps for restoration on refresh) // Skip persistence for interrupted messages -- handleResume will persist the final version - const finalContent = buildContentForPersistence( - contentPartsState, - TOOLS_WITH_UI, - currentThinkingSteps - ); + const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); if (contentParts.length > 0 && !wasInterrupted) { try { const savedMessage = await appendMessage(currentThreadId, { @@ -847,18 +806,6 @@ export default function NewChatPage() { ? { ...prev, assistantMsgId: newMsgId } : prev ); - - // Also update thinking steps map with new ID - setMessageThinkingSteps((prev) => { - const steps = prev.get(assistantMsgId); - if (steps) { - const newMap = new Map(prev); - newMap.delete(assistantMsgId); - newMap.set(newMsgId, steps); - return newMap; - } - return prev; - }); } catch (err) { console.error("Failed to persist assistant message:", err); } @@ -875,11 +822,7 @@ export default function NewChatPage() { (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) ); if (hasContent && currentThreadId) { - const partialContent = buildContentForPersistence( - contentPartsState, - TOOLS_WITH_UI, - currentThinkingSteps - ); + const partialContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); try { const savedMessage = await appendMessage(currentThreadId, { role: "assistant", @@ -926,7 +869,6 @@ export default function NewChatPage() { } finally { setIsRunning(false); abortControllerRef.current = null; - // Note: We no longer clear thinking steps - they persist with the message } }, [ @@ -969,9 +911,7 @@ export default function NewChatPage() { const controller = new AbortController(); abortControllerRef.current = controller; - const currentThinkingSteps = new Map( - (messageThinkingSteps.get(assistantMsgId) ?? []).map((s) => [s.id, s]) - ); + const currentThinkingSteps = new Map(); const contentPartsState: ContentPartsState = { contentParts: [], @@ -998,6 +938,15 @@ export default function NewChatPage() { result: p.result as unknown, }); contentPartsState.currentTextPartIndex = -1; + } else if (p.type === "data-thinking-steps") { + const stepsData = p.data as { steps: ThinkingStepData[] } | undefined; + contentParts.push({ + type: "data-thinking-steps", + data: { steps: stepsData?.steps ?? [] }, + }); + for (const step of stepsData?.steps ?? []) { + currentThinkingSteps.set(step.id, step); + } } } } @@ -1115,11 +1064,14 @@ export default function NewChatPage() { const stepData = parsed.data as ThinkingStepData; if (stepData?.id) { currentThinkingSteps.set(stepData.id, stepData); - setMessageThinkingSteps((prev) => { - const newMap = new Map(prev); - newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); - return newMap; - }); + updateThinkingSteps(contentPartsState, currentThinkingSteps); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); } break; } @@ -1173,11 +1125,7 @@ export default function NewChatPage() { } } - const finalContent = buildContentForPersistence( - contentPartsState, - TOOLS_WITH_UI, - currentThinkingSteps - ); + const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); if (contentParts.length > 0) { try { const savedMessage = await appendMessage(resumeThreadId, { @@ -1188,16 +1136,6 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)) ); - setMessageThinkingSteps((prev) => { - const steps = prev.get(assistantMsgId); - if (steps) { - const newMap = new Map(prev); - newMap.delete(assistantMsgId); - newMap.set(newMsgId, steps); - return newMap; - } - return prev; - }); } catch (err) { console.error("Failed to persist resumed assistant message:", err); } @@ -1213,7 +1151,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } }, - [pendingInterrupt, messages, searchSpaceId, messageThinkingSteps] + [pendingInterrupt, messages, searchSpaceId] ); useEffect(() => { @@ -1332,20 +1270,6 @@ export default function NewChatPage() { return prev; }); - // Clear thinking steps for the removed messages - setMessageThinkingSteps((prev) => { - const newMap = new Map(prev); - // Remove thinking steps for the last two messages - const lastTwoIds = messages - .slice(-2) - .map((m) => m.id) - .filter((id): id is string => !!id); - for (const id of lastTwoIds) { - newMap.delete(id); - } - return newMap; - }); - // Start streaming setIsRunning(true); const controller = new AbortController(); @@ -1476,11 +1400,14 @@ export default function NewChatPage() { const stepData = parsed.data as ThinkingStepData; if (stepData?.id) { currentThinkingSteps.set(stepData.id, stepData); - setMessageThinkingSteps((prev) => { - const newMap = new Map(prev); - newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); - return newMap; - }); + updateThinkingSteps(contentPartsState, currentThinkingSteps); + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); } break; } @@ -1491,11 +1418,7 @@ export default function NewChatPage() { } // Persist messages after streaming completes - const finalContent = buildContentForPersistence( - contentPartsState, - TOOLS_WITH_UI, - currentThinkingSteps - ); + const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); if (contentParts.length > 0) { try { // Persist user message (for both edit and reload modes, since backend deleted it) @@ -1526,18 +1449,6 @@ export default function NewChatPage() { prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)) ); - setMessageThinkingSteps((prev) => { - const steps = prev.get(assistantMsgId); - if (steps) { - const newMap = new Map(prev); - newMap.delete(assistantMsgId); - newMap.set(newMsgId, steps); - return newMap; - } - return prev; - }); - - // Track successful response trackChatResponseReceived(searchSpaceId, threadId); } catch (err) { console.error("Failed to persist regenerated message:", err); @@ -1570,7 +1481,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } }, - [threadId, searchSpaceId, messages, setMessageThinkingSteps, disabledTools] + [threadId, searchSpaceId, messages, disabledTools] ); // Handle editing a message - truncates history and regenerates with new query @@ -1675,9 +1586,10 @@ export default function NewChatPage() { return ( +
- +
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 72fed42ae..223c4fc37 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -8,20 +8,15 @@ import { import { useAtomValue } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; -import { - ThinkingStepsContext, - ThinkingStepsDisplay, -} from "@/components/assistant-ui/thinking-steps"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CreateConfluencePageToolUI, DeleteConfluencePageToolUI, UpdateConfluencePageToolUI } from "@/components/tool-ui/confluence"; -import { DeepAgentThinkingToolUI } from "@/components/tool-ui/deepagent-thinking"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; @@ -50,44 +45,15 @@ export const MessageError: FC = () => { ); }; -/** - * Custom component to render thinking steps from Context - */ -const ThinkingStepsPart: FC = () => { - const thinkingStepsMap = useContext(ThinkingStepsContext); - - // Get the current message ID to look up thinking steps - const messageId = useAuiState(({ message }) => message?.id); - const thinkingSteps = thinkingStepsMap.get(messageId) || []; - - // Check if this specific message is currently streaming - // A message is streaming if: thread is running AND this is the last assistant message - const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); - const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); - const isMessageStreaming = isThreadRunning && isLastMessage; - - if (thinkingSteps.length === 0) return null; - - return ( -
- -
- ); -}; - const AssistantMessageInner: FC = () => { return ( <> - {/* Render thinking steps from message content - this ensures proper scroll tracking */} - -
>(new Map()); - /** * Chain of thought display component - single collapsible dropdown design */ @@ -18,7 +16,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: }) => { const [isOpen, setIsOpen] = useState(true); - // Derive effective status for each step const getEffectiveStatus = useCallback( (step: ThinkingStep): "pending" | "in_progress" | "completed" => { if (step.status === "in_progress" && !isThreadRunning) { @@ -36,7 +33,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: steps.every((s) => getEffectiveStatus(s) === "completed"); const isProcessing = isThreadRunning && !allCompleted; - // Auto-collapse when all tasks are completed useEffect(() => { if (allCompleted) { setIsOpen(false); @@ -61,7 +57,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: return (
- {/* Main collapsible header */} - {/* Collapsible content with CSS grid animation */}
- {/* Dot and line column */}
- {/* Vertical connection line - extends to next dot */} {!isLast && (
)} - {/* Step dot - on top of line */}
{effectiveStatus === "in_progress" ? ( @@ -117,9 +106,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
- {/* Step content */}
- {/* Step title */}
- {/* Step items (sub-content) */} {step.items && step.items.length > 0 && (
{step.items.map((item, idx) => ( @@ -153,3 +139,28 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: ); }; +/** + * assistant-ui data UI component that renders thinking steps from message content. + * Registered globally via makeAssistantDataUI — renders inside MessagePrimitive.Parts + * at the position of the data part in the content array. + */ +function ThinkingStepsDataRenderer({ data }: { name: string; data: unknown }) { + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); + const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); + const isMessageStreaming = isThreadRunning && isLastMessage; + + const steps = (data as { steps: ThinkingStep[] } | null)?.steps ?? []; + if (steps.length === 0) return null; + + return ( +
+ +
+ ); +} + +export const ThinkingStepsDataUI = makeAssistantDataUI({ + name: "thinking-steps", + render: ThinkingStepsDataRenderer, +}); + diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index e8c765d8c..02e57ba20 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1,9 +1,6 @@ import { - ActionBarPrimitive, AuiIf, - BranchPickerPrimitive, ComposerPrimitive, - ErrorPrimitive, MessagePrimitive, ThreadPrimitive, useAui, @@ -14,14 +11,8 @@ import { AlertCircle, ArrowDownIcon, ArrowUpIcon, - CheckIcon, - ChevronLeftIcon, - ChevronRightIcon, - CopyIcon, - DownloadIcon, Globe, Plus, - RefreshCwIcon, Settings2, SquareIcon, Unplug, @@ -32,7 +23,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; import { useParams } from "next/navigation"; -import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { agentToolsAtom, @@ -63,12 +54,6 @@ import { InlineMentionEditor, type InlineMentionEditorRef, } from "@/components/assistant-ui/inline-mention-editor"; -import { MarkdownText } from "@/components/assistant-ui/markdown-text"; -import { - ThinkingStepsContext, - ThinkingStepsDisplay, -} from "@/components/assistant-ui/thinking-steps"; -import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel"; @@ -76,7 +61,6 @@ import { DocumentMentionPicker, type DocumentMentionPickerRef, } from "@/components/new-chat/document-mention-picker"; -import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; @@ -111,16 +95,8 @@ const CYCLING_PLACEHOLDERS = [ "Check if this week's Slack messages reference any GitHub issues", ]; -interface ThreadProps { - messageThinkingSteps?: Map; -} - -export const Thread: FC = ({ messageThinkingSteps = new Map() }) => { - return ( - - - - ); +export const Thread: FC = () => { + return ; }; const ThreadContent: FC = () => { @@ -1132,97 +1108,6 @@ const TOOL_GROUPS: ToolGroup[] = [ }, ]; -const MessageError: FC = () => { - return ( - - - - - - ); -}; - -/** - * Custom component to render thinking steps from Context - */ -const ThinkingStepsPart: FC = () => { - const thinkingStepsMap = useContext(ThinkingStepsContext); - - // Get the current message ID to look up thinking steps - const messageId = useAuiState(({ message }) => message?.id); - const thinkingSteps = thinkingStepsMap.get(messageId) || []; - - // Check if this specific message is currently streaming - // A message is streaming if: thread is running AND this is the last assistant message - const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); - const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); - const isMessageStreaming = isThreadRunning && isLastMessage; - - if (thinkingSteps.length === 0) return null; - - return ( -
- -
- ); -}; - -const AssistantMessageInner: FC = () => { - return ( - <> - {/* Render thinking steps from message content - this ensures proper scroll tracking */} - - -
- - -
- -
- - -
- - ); -}; - -const AssistantActionBar: FC = () => { - return ( - - - - message.isCopied}> - - - !message.isCopied}> - - - - - - - - - - - - - - - - ); -}; - const EditComposer: FC = () => { return ( @@ -1245,30 +1130,3 @@ const EditComposer: FC = () => { ); }; - -const BranchPicker: FC = ({ className, ...rest }) => { - return ( - - - - - - - - / - - - - - - - - ); -}; diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx index 52830f601..f8dd6db5a 100644 --- a/surfsense_web/components/public-chat/public-chat-view.tsx +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -1,6 +1,7 @@ "use client"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { Navbar } from "@/components/homepage/navbar"; import { ReportPanel } from "@/components/report-panel/report-panel"; import { Spinner } from "@/components/ui/spinner"; @@ -39,6 +40,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
+
} /> diff --git a/surfsense_web/lib/chat/message-utils.ts b/surfsense_web/lib/chat/message-utils.ts index 81538731b..7c0da03c4 100644 --- a/surfsense_web/lib/chat/message-utils.ts +++ b/surfsense_web/lib/chat/message-utils.ts @@ -2,8 +2,8 @@ import type { ThreadMessageLike } from "@assistant-ui/react"; import type { MessageRecord } from "./thread-persistence"; /** - * Convert backend message to assistant-ui ThreadMessageLike format - * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps + * Convert backend message to assistant-ui ThreadMessageLike format. + * Migrates legacy `thinking-steps` parts to `data-thinking-steps` (assistant-ui data parts). */ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { let content: ThreadMessageLike["content"]; @@ -11,26 +11,34 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { if (typeof msg.content === "string") { content = [{ type: "text", text: msg.content }]; } else if (Array.isArray(msg.content)) { - // 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 metadata parts not directly renderable by assistant-ui - return ( - partType !== "thinking-steps" && - partType !== "mentioned-documents" && - partType !== "attachments" - ); - }); + const convertedContent = msg.content + .filter((part: unknown) => { + if (typeof part !== "object" || part === null || !("type" in part)) return true; + const partType = (part as { type: string }).type; + return partType !== "mentioned-documents" && partType !== "attachments"; + }) + .map((part: unknown) => { + if ( + typeof part === "object" && + part !== null && + "type" in part && + (part as { type: string }).type === "thinking-steps" + ) { + return { + type: "data-thinking-steps", + data: { steps: (part as { steps: unknown[] }).steps ?? [] }, + }; + } + return part; + }); content = - filteredContent.length > 0 - ? (filteredContent as ThreadMessageLike["content"]) + convertedContent.length > 0 + ? (convertedContent as ThreadMessageLike["content"]) : [{ type: "text", text: "" }]; } else { content = [{ type: "text", text: String(msg.content) }]; } - // Build metadata.custom for author display in shared chats const metadata = msg.author_id ? { custom: { diff --git a/surfsense_web/lib/chat/streaming-state.ts b/surfsense_web/lib/chat/streaming-state.ts index 4364fd515..3f1c498b6 100644 --- a/surfsense_web/lib/chat/streaming-state.ts +++ b/surfsense_web/lib/chat/streaming-state.ts @@ -15,6 +15,10 @@ export type ContentPart = toolName: string; args: Record; result?: unknown; + } + | { + type: "data-thinking-steps"; + data: { steps: ThinkingStepData[] }; }; export interface ContentPartsState { @@ -23,6 +27,32 @@ export interface ContentPartsState { toolCallIndices: Map; } +export function updateThinkingSteps( + state: ContentPartsState, + steps: Map +): void { + const stepsArray = Array.from(steps.values()); + const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps"); + + if (existingIdx >= 0) { + state.contentParts[existingIdx] = { + type: "data-thinking-steps", + data: { steps: stepsArray }, + }; + } else { + state.contentParts.unshift({ + type: "data-thinking-steps", + data: { steps: stepsArray }, + }); + if (state.currentTextPartIndex >= 0) { + state.currentTextPartIndex += 1; + } + for (const [id, idx] of state.toolCallIndices) { + state.toolCallIndices.set(id, idx + 1); + } + } +} + export function appendText(state: ContentPartsState, delta: string): void { if ( state.currentTextPartIndex >= 0 && @@ -75,6 +105,7 @@ export function buildContentForUI( const filtered = state.contentParts.filter((part) => { if (part.type === "text") return part.text.length > 0; if (part.type === "tool-call") return toolsWithUI.has(part.toolName); + if (part.type === "data-thinking-steps") return true; return false; }); return filtered.length > 0 @@ -84,23 +115,17 @@ export function buildContentForUI( export function buildContentForPersistence( state: ContentPartsState, - toolsWithUI: Set, - currentThinkingSteps: Map + toolsWithUI: Set ): unknown[] { const parts: unknown[] = []; - if (currentThinkingSteps.size > 0) { - parts.push({ - type: "thinking-steps", - steps: Array.from(currentThinkingSteps.values()), - }); - } - for (const part of state.contentParts) { if (part.type === "text" && part.text.length > 0) { parts.push(part); } else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) { parts.push(part); + } else if (part.type === "data-thinking-steps") { + parts.push(part); } } From eed792c19a6485a682aa70586b074b3e9e36e728 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:56:42 +0530 Subject: [PATCH 03/18] refactor: remove deprecated DeepAgentThinking component and redefine ThinkingStep interface for improved clarity --- .../assistant-ui/thinking-steps.tsx | 12 +- .../components/tool-ui/deepagent-thinking.tsx | 400 ------------------ surfsense_web/components/tool-ui/index.ts | 7 - 3 files changed, 9 insertions(+), 410 deletions(-) delete mode 100644 surfsense_web/components/tool-ui/deepagent-thinking.tsx diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx index 219e06ae5..cf0c4ce52 100644 --- a/surfsense_web/components/assistant-ui/thinking-steps.tsx +++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx @@ -4,9 +4,15 @@ import type { FC } from "react"; import { useCallback, useEffect, useState } from "react"; import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; -import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { cn } from "@/lib/utils"; +export interface ThinkingStep { + id: string; + title: string; + items: string[]; + status: "pending" | "in_progress" | "completed"; +} + /** * Chain of thought display component - single collapsible dropdown design */ @@ -120,8 +126,8 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: {step.items && step.items.length > 0 && (
- {step.items.map((item, idx) => ( - + {step.items.map((item) => ( + {item} ))} diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx deleted file mode 100644 index c495c1723..000000000 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ /dev/null @@ -1,400 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react"; -import type { FC, ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { z } from "zod"; -import { - ChainOfThought, - ChainOfThoughtContent, - ChainOfThoughtItem, - ChainOfThoughtStep, - ChainOfThoughtTrigger, -} from "@/components/prompt-kit/chain-of-thought"; -import { cn } from "@/lib/utils"; - -// ============================================================================ -// 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([STEP_STATUS.PENDING, STEP_STATUS.IN_PROGRESS, STEP_STATUS.COMPLETED]) - .default(STEP_STATUS.PENDING), -}); - -const DeepAgentThinkingArgsSchema = z.object({ - query: z.string().nullish(), - context: z.string().nullish(), -}); - -const DeepAgentThinkingResultSchema = z.object({ - 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 */ -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 - */ -export function parseThinkingStep(data: unknown): ThinkingStep { - const result = ThinkingStepSchema.safeParse(data); - if (!result.success) { - console.warn("Invalid thinking step data:", result.error.issues); - return DEFAULT_FALLBACK_STEP; - } - return result.data; -} - -/** - * Parse and validate thinking result - */ -export function parseThinkingResult(data: unknown): DeepAgentThinkingResult { - const result = DeepAgentThinkingResultSchema.safeParse(data); - if (!result.success) { - console.warn("Invalid thinking result data:", result.error.issues); - return {}; - } - return result.data; -} - -// ============================================================================ -// Icon Utilities -// ============================================================================ - -/** - * Check if title contains any of the keywords - */ -function titleContainsKeywords(title: string, keywords: readonly string[]): boolean { - const titleLower = title.toLowerCase(); - return keywords.some((keyword) => titleLower.includes(keyword)); -} - -/** - * Get icon based on step status and title - */ -function getStepIcon(status: StepStatus, title: string): ReactNode { - if (status === STEP_STATUS.IN_PROGRESS) { - return ; - } - - if (status === STEP_STATUS.COMPLETED) { - return ; - } - - // Default icons based on step type keywords - if (titleContainsKeywords(title, STEP_KEYWORDS.SEARCH)) { - return ; - } - - if (titleContainsKeywords(title, STEP_KEYWORDS.ANALYSIS)) { - return ; - } - - return ; -} - -// ============================================================================ -// Sub-Components -// ============================================================================ - -interface ThinkingStepDisplayProps { - step: ThinkingStep; - isOpen: boolean; - onToggle: () => void; -} - -/** - * 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 isInProgress = step.status === STEP_STATUS.IN_PROGRESS; - const isCompleted = step.status === STEP_STATUS.COMPLETED; - - return ( - - - {step.title} - - - {step.items.map((item, index) => ( - {item} - ))} - - - ); -}; - -interface ThinkingLoadingStateProps { - status?: ThinkingStatus | string; -} - -/** - * Loading state with animated thinking indicator - */ -const ThinkingLoadingState: FC = ({ status }) => { - const statusText = useMemo(() => { - if (status && status in STATUS_TEXT_MAP) { - return STATUS_TEXT_MAP[status]; - } - return STATUS_TEXT_MAP[THINKING_STATUS.THINKING]; - }, [status]); - - return ( -
-
- - - - - -
- {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 - */ -const SmartChainOfThought: FC = ({ steps }) => { - // Track which steps the user has manually toggled - const [manualOverrides, setManualOverrides] = useState({}); - // Track previous step statuses to detect changes - const prevStatusesRef = useRef({}); - - // Clear manual overrides when a step's status changes - useEffect(() => { - const currentStatuses: StepStatusHistory = {}; - steps.forEach((step) => { - currentStatuses[step.id] = step.status; - // If status changed, clear any manual override for this step - const prevStatus = prevStatusesRef.current[step.id]; - if (prevStatus && prevStatus !== step.status) { - setManualOverrides((prev) => { - const next = { ...prev }; - delete next[step.id]; - return next; - }); - } - }); - prevStatusesRef.current = currentStatuses; - }, [steps]); - - 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 = useCallback((stepId: string, currentOpen: boolean) => { - setManualOverrides((prev) => ({ - ...prev, - [stepId]: !currentOpen, - })); - }, []); - - return ( - - {steps.map((step) => { - const isOpen = getStepOpenState(step); - return ( - handleToggle(step.id, isOpen)} - /> - ); - })} - - ); -}; - -/** - * DeepAgent Thinking Tool UI Component - * - * This component displays the agent's chain-of-thought reasoning - * when the deepagent is processing a query. It shows thinking steps - * in a collapsible, hierarchical format. - */ -export const DeepAgentThinkingToolUI = ({ result, status }: ToolCallMessagePartProps) => { - // Loading state - tool is still running - if (status.type === "running" || status.type === "requires-action") { - return ; - } - - // Incomplete/cancelled state - if (status.type === "incomplete") { - if (status.reason === "cancelled") { - return null; // Don't show anything if cancelled - } - if (status.reason === "error") { - return null; // Don't show error for thinking - it's not critical - } - } - - // No result or no steps - don't render anything - if (!result?.steps || result.steps.length === 0) { - return null; - } - - // Render the chain of thought - return ( -
- -
- ); -}; - -// ============================================================================ -// 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 const InlineThinkingDisplay: FC = ({ - steps, - isStreaming = false, - className, -}) => { - if (steps.length === 0 && !isStreaming) { - return null; - } - - return ( -
- {isStreaming && steps.length === 0 ? ( - - ) : ( - - )} -
- ); -}; - -// ============================================================================ -// Exports -// ============================================================================ - -export type { - ThinkingStep, - DeepAgentThinkingArgs, - DeepAgentThinkingResult, - StepStatus, - ThinkingStatus, -}; - -export { STEP_STATUS, THINKING_STATUS }; diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 65c0ca497..c9ea4df41 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -16,13 +16,6 @@ export { type SerializableArticle, } from "./article"; export { Audio } from "./audio"; -export { - type DeepAgentThinkingArgs, - type DeepAgentThinkingResult, - DeepAgentThinkingToolUI, - InlineThinkingDisplay, - type ThinkingStep, -} from "./deepagent-thinking"; export { type DisplayImageArgs, DisplayImageArgsSchema, From 6c507989d2835bad60d5dca3e7a194d95122b585 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:28:11 +0530 Subject: [PATCH 04/18] refactor: remove display_image tool and update related components to streamline image handling --- .../app/agents/new_chat/__init__.py | 2 - .../app/agents/new_chat/chat_deepagent.py | 1 - .../app/agents/new_chat/system_prompt.py | 58 +--- .../app/agents/new_chat/tools/__init__.py | 3 - .../agents/new_chat/tools/generate_image.py | 10 +- .../app/agents/new_chat/tools/registry.py | 8 - .../app/services/public_chat_service.py | 2 +- .../app/tasks/chat/stream_new_chat.py | 50 ++-- .../new-chat/[[...chat_id]]/page.tsx | 1 + .../assistant-ui/assistant-message.tsx | 2 + .../components/assistant-ui/image.tsx | 268 ++++++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 60 +++- .../components/assistant-ui/thread.tsx | 2 +- .../components/public-chat/public-thread.tsx | 2 + surfsense_web/components/tool-ui/index.ts | 7 + surfsense_web/contracts/enums/toolIcons.tsx | 2 - 16 files changed, 385 insertions(+), 93 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/image.tsx diff --git a/surfsense_backend/app/agents/new_chat/__init__.py b/surfsense_backend/app/agents/new_chat/__init__.py index eccb7a5c3..96f4b399b 100644 --- a/surfsense_backend/app/agents/new_chat/__init__.py +++ b/surfsense_backend/app/agents/new_chat/__init__.py @@ -37,7 +37,6 @@ from .tools import ( BUILTIN_TOOLS, ToolDefinition, build_tools, - create_display_image_tool, create_generate_podcast_tool, create_link_preview_tool, create_scrape_webpage_tool, @@ -63,7 +62,6 @@ __all__ = [ # LLM config "create_chat_litellm_from_config", # Tool factories - "create_display_image_tool", "create_generate_podcast_tool", "create_link_preview_tool", "create_scrape_webpage_tool", diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index c69ba1063..e073a24cf 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -151,7 +151,6 @@ async def create_surfsense_deep_agent( - generate_podcast: Generate audio podcasts from content - generate_image: Generate images from text descriptions using AI models - link_preview: Fetch rich previews for URLs - - display_image: Display images in chat - scrape_webpage: Extract content from webpages - save_memory: Store facts/preferences about the user - recall_memory: Retrieve relevant user memories diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index f8ac62787..69cb8f40a 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -199,33 +199,6 @@ _TOOL_INSTRUCTIONS["link_preview"] = """ - The preview card will automatically be displayed in the chat. """ -_TOOL_INSTRUCTIONS["display_image"] = """ -- display_image: Display an image in the chat with metadata. - - Use this tool ONLY when you have a valid public HTTP/HTTPS image URL to show. - - This displays the image with an optional title, description, and source attribution. - - Valid use cases: - * Showing an image from a URL the user explicitly mentioned in their message - * Displaying images found in scraped webpage content (from scrape_webpage tool) - * Showing a publicly accessible diagram or chart from a known URL - * Displaying an AI-generated image after calling the generate_image tool (ALWAYS required) - - CRITICAL - NEVER USE THIS TOOL FOR USER-UPLOADED ATTACHMENTS: - When a user uploads/attaches an image file to their message: - * The image is ALREADY VISIBLE in the chat UI as a thumbnail on their message - * You do NOT have a URL for their uploaded image - only extracted text/description - * Calling display_image will FAIL and show "Image not available" error - * Simply analyze the image content and respond with your analysis - DO NOT try to display it - * The user can already see their own uploaded image - they don't need you to show it again - - - Args: - - src: The URL of the image (MUST be a valid public HTTP/HTTPS URL that you know exists) - - alt: Alternative text describing the image (for accessibility) - - title: Optional title to display below the image - - description: Optional description providing context about the image - - Returns: An image card with the image, title, and description - - The image will automatically be displayed in the chat. -""" - _TOOL_INSTRUCTIONS["generate_image"] = """ - generate_image: Generate images from text descriptions using AI image models. - Use this when the user asks you to create, generate, draw, design, or make an image. @@ -233,10 +206,7 @@ _TOOL_INSTRUCTIONS["generate_image"] = """ - Args: - prompt: A detailed text description of the image to generate. Be specific about subject, style, colors, composition, and mood. - n: Number of images to generate (1-4, default: 1) - - Returns: A dictionary with the generated image URL in the "src" field, along with metadata. - - CRITICAL: After calling generate_image, you MUST call `display_image` with the returned "src" URL - to actually show the image in the chat. The generate_image tool only generates the image and returns - the URL — it does NOT display anything. You must always follow up with display_image. + - Returns: A dictionary with the generated image metadata. The image will automatically be displayed in the chat. - IMPORTANT: Write a detailed, descriptive prompt for best results. Don't just pass the user's words verbatim - expand and improve the prompt with specific details about style, lighting, composition, and mood. - If the user's request is vague (e.g., "make me an image of a cat"), enhance the prompt with artistic details. @@ -270,7 +240,7 @@ _TOOL_INSTRUCTIONS["scrape_webpage"] = """ - Returns: The page title, description, full content (in markdown), word count, and metadata - After scraping, you will have the full article text and can analyze, summarize, or answer questions about it. - IMAGES: The scraped content may contain image URLs in markdown format like `![alt text](image_url)`. - * When you find relevant/important images in the scraped content, use the `display_image` tool to show them to the user. + * When you find relevant/important images in the scraped content, include them in your response using standard markdown image syntax: `![alt text](image_url)`. * This makes your response more visual and engaging. * Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content. * Don't show every image - just the most relevant 1-3 images that enhance understanding. @@ -487,21 +457,18 @@ _TOOL_EXAMPLES["scrape_webpage"] = """ - IMPORTANT: Always attempt scraping first. Never refuse before trying the tool. """ -_TOOL_EXAMPLES["display_image"] = """ -- User: "Show me this image: https://example.com/image.png" - - Call: `display_image(src="https://example.com/image.png", alt="User shared image")` -- User uploads an image file and asks: "What is this image about?" - - DO NOT call display_image! The user's uploaded image is already visible in the chat. - - Simply analyze the image content and respond directly. -""" - _TOOL_EXAMPLES["generate_image"] = """ - User: "Generate an image of a cat" - - Step 1: `generate_image(prompt="A fluffy orange tabby cat sitting on a windowsill, bathed in warm golden sunlight, soft bokeh background with green houseplants, photorealistic style, cozy atmosphere")` - - Step 2: Use the returned "src" URL to display it: `display_image(src="", alt="A fluffy orange tabby cat on a windowsill", title="Generated Image")` + - Call: `generate_image(prompt="A fluffy orange tabby cat sitting on a windowsill, bathed in warm golden sunlight, soft bokeh background with green houseplants, photorealistic style, cozy atmosphere")` + - The generated image will automatically be displayed in the chat. - User: "Draw me a logo for a coffee shop called Bean Dream" - - Step 1: `generate_image(prompt="Minimalist modern logo design for a coffee shop called 'Bean Dream', featuring a stylized coffee bean with dream-like swirls of steam, clean vector style, warm brown and cream color palette, white background, professional branding")` - - Step 2: `display_image(src="", alt="Bean Dream coffee shop logo", title="Generated Image")` + - Call: `generate_image(prompt="Minimalist modern logo design for a coffee shop called 'Bean Dream', featuring a stylized coffee bean with dream-like swirls of steam, clean vector style, warm brown and cream color palette, white background, professional branding")` + - The generated image will automatically be displayed in the chat. +- User: "Show me this image: https://example.com/image.png" + - Simply include it in your response using markdown: `![Image](https://example.com/image.png)` +- User uploads an image file and asks: "What is this image about?" + - The user's uploaded image is already visible in the chat. + - Simply analyze the image content and respond directly. """ _TOOL_EXAMPLES["web_search"] = """ @@ -523,7 +490,6 @@ _ALL_TOOL_NAMES_ORDERED = [ "generate_video_presentation", "generate_report", "link_preview", - "display_image", "generate_image", "scrape_webpage", "save_memory", @@ -764,7 +730,7 @@ Do not use the sandbox for: When your code creates output files (images, CSVs, PDFs, etc.) in the sandbox: - **Print the absolute path** at the end of your script so the user can download the file. Example: `print("SANDBOX_FILE: /tmp/chart.png")` -- **DO NOT call `display_image`** for files created inside the sandbox. Sandbox files are not accessible via public URLs, so `display_image` will always show "Image not available". The frontend automatically renders a download button from the `SANDBOX_FILE:` marker. +- **DO NOT use markdown image syntax** for files created inside the sandbox. Sandbox files are not accessible via public URLs and will show "Image not available". The frontend automatically renders a download button from the `SANDBOX_FILE:` marker. - You can output multiple files, one per line: `print("SANDBOX_FILE: /tmp/report.csv")`, `print("SANDBOX_FILE: /tmp/chart.png")` - Always describe what the file contains in your response text so the user knows what they are downloading. - IMPORTANT: Every `execute` call that saves a file MUST print the `SANDBOX_FILE: ` marker. Without it the user cannot download the file. diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py index 5002e69bb..de84cdfb1 100644 --- a/surfsense_backend/app/agents/new_chat/tools/__init__.py +++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py @@ -11,7 +11,6 @@ Available tools: - generate_video_presentation: Generate video presentations with slides and narration - generate_image: Generate images from text descriptions using AI models - link_preview: Fetch rich previews for URLs -- display_image: Display images in chat - scrape_webpage: Extract content from webpages - save_memory: Store facts/preferences about the user - recall_memory: Retrieve relevant user memories @@ -19,7 +18,6 @@ Available tools: # Registry exports # Tool factory exports (for direct use) -from .display_image import create_display_image_tool from .generate_image import create_generate_image_tool from .knowledge_base import ( CONNECTOR_DESCRIPTIONS, @@ -50,7 +48,6 @@ __all__ = [ "ToolDefinition", "build_tools", # Tool factories - "create_display_image_tool", "create_generate_image_tool", "create_generate_podcast_tool", "create_generate_video_presentation_tool", diff --git a/surfsense_backend/app/agents/new_chat/tools/generate_image.py b/surfsense_backend/app/agents/new_chat/tools/generate_image.py index 8ffa4ecde..d94d55b1a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/generate_image.py +++ b/surfsense_backend/app/agents/new_chat/tools/generate_image.py @@ -2,8 +2,7 @@ Image generation tool for the SurfSense agent. This module provides a tool that generates images using litellm.aimage_generation() -and returns the result via the existing display_image tool format so the frontend -renders the generated image inline in the chat. +and returns the result directly in a format the frontend Image component can render. Config resolution: 1. Uses the search space's image_generation_config_id preference @@ -11,6 +10,7 @@ Config resolution: 3. Supports global YAML configs (negative IDs) and user DB configs (positive IDs) """ +import hashlib import logging from typing import Any @@ -222,11 +222,17 @@ def create_generate_image_tool( else: return {"error": "No displayable image data in the response"} + image_id = f"image-{hashlib.md5(image_url.encode()).hexdigest()[:12]}" + return { + "id": image_id, + "assetId": image_url, "src": image_url, "alt": revised_prompt or prompt, "title": "Generated Image", "description": revised_prompt if revised_prompt != prompt else None, + "domain": "ai-generated", + "ratio": "auto", "generated": True, "prompt": prompt, "image_count": len(images), diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 4ee8023d2..29ef75641 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -50,7 +50,6 @@ from .confluence import ( create_delete_confluence_page_tool, create_update_confluence_page_tool, ) -from .display_image import create_display_image_tool from .generate_image import create_generate_image_tool from .gmail import ( create_create_gmail_draft_tool, @@ -194,13 +193,6 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ factory=lambda deps: create_link_preview_tool(), requires=[], ), - # Display image tool - shows images in the chat - ToolDefinition( - name="display_image", - description="Display an image in the chat with metadata", - factory=lambda deps: create_display_image_tool(), - requires=[], - ), # Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.) ToolDefinition( name="generate_image", diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index a75eb73f8..9f0c76b9c 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -38,7 +38,7 @@ from app.db import ( from app.utils.rbac import check_permission UI_TOOLS = { - "display_image", + "generate_image", "link_preview", "generate_podcast", "generate_report", diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 82d98da18..e683ea106 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -351,22 +351,19 @@ async def _stream_agent_events( status="in_progress", items=last_active_step_items, ) - elif tool_name == "display_image": - src = ( - tool_input.get("src", "") + elif tool_name == "generate_image": + prompt = ( + tool_input.get("prompt", "") if isinstance(tool_input, dict) else str(tool_input) ) - title = ( - tool_input.get("title", "") if isinstance(tool_input, dict) else "" - ) - last_active_step_title = "Analyzing the image" + last_active_step_title = "Generating image" last_active_step_items = [ - f"Analyzing: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}" + f"Prompt: {prompt[:80]}{'...' if len(prompt) > 80 else ''}" ] yield streaming_service.format_thinking_step( step_id=tool_step_id, - title="Analyzing the image", + title="Generating image", status="in_progress", items=last_active_step_items, ) @@ -531,20 +528,22 @@ async def _stream_agent_events( status="completed", items=completed_items, ) - elif tool_name == "display_image": - if isinstance(tool_output, dict): - title = tool_output.get("title", "") - alt = tool_output.get("alt", "Image") - display_name = title or alt + elif tool_name == "generate_image": + if isinstance(tool_output, dict) and not tool_output.get("error"): completed_items = [ *last_active_step_items, - f"Analyzed: {display_name[:50]}{'...' if len(display_name) > 50 else ''}", + "Image generated successfully", ] else: - completed_items = [*last_active_step_items, "Image analyzed"] + error_msg = ( + tool_output.get("error", "Generation failed") + if isinstance(tool_output, dict) + else "Generation failed" + ) + completed_items = [*last_active_step_items, f"Error: {error_msg}"] yield streaming_service.format_thinking_step( step_id=original_step_id, - title="Analyzing the image", + title="Generating image", status="completed", items=completed_items, ) @@ -842,7 +841,7 @@ async def _stream_agent_events( f"Link preview failed: {error_msg}", "error", ) - elif tool_name == "display_image": + elif tool_name == "generate_image": yield streaming_service.format_tool_output_available( tool_call_id, tool_output @@ -850,11 +849,16 @@ async def _stream_agent_events( else {"result": tool_output}, ) if isinstance(tool_output, dict): - title = tool_output.get("title") or tool_output.get("alt", "Image") - yield streaming_service.format_terminal_info( - f"Image analyzed: {title[:40]}{'...' if len(title) > 40 else ''}", - "success", - ) + if tool_output.get("error"): + yield streaming_service.format_terminal_info( + f"Image generation failed: {tool_output['error'][:60]}", + "error", + ) + else: + yield streaming_service.format_terminal_info( + "Image generated successfully", + "success", + ) elif tool_name == "scrape_webpage": if isinstance(tool_output, dict): display_output = { 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 b303a45f5..7ed59c0bf 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 @@ -133,6 +133,7 @@ const TOOLS_WITH_UI = new Set([ "generate_video_presentation", "link_preview", "display_image", + "generate_image", "delete_notion_page", "scrape_webpage", "create_notion_page", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 223c4fc37..0a506d23b 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -18,6 +18,7 @@ import { CommentPanelContainer } from "@/components/chat-comments/comment-panel- import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CreateConfluencePageToolUI, DeleteConfluencePageToolUI, UpdateConfluencePageToolUI } from "@/components/tool-ui/confluence"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; @@ -60,6 +61,7 @@ const AssistantMessageInner: FC = () => { link_preview: LinkPreviewToolUI, multi_link_preview: MultiLinkPreviewToolUI, display_image: DisplayImageToolUI, + generate_image: GenerateImageToolUI, scrape_webpage: ScrapeWebpageToolUI, save_memory: SaveMemoryToolUI, recall_memory: RecallMemoryToolUI, diff --git a/surfsense_web/components/assistant-ui/image.tsx b/surfsense_web/components/assistant-ui/image.tsx new file mode 100644 index 000000000..e610d70aa --- /dev/null +++ b/surfsense_web/components/assistant-ui/image.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { + memo, + useState, + useEffect, + useRef, + type PropsWithChildren, +} from "react"; +import { createPortal } from "react-dom"; +import { cva, type VariantProps } from "class-variance-authority"; +import { ImageIcon, ImageOffIcon } from "lucide-react"; +import type { ImageMessagePartComponent } from "@assistant-ui/react"; +import { cn } from "@/lib/utils"; + +const imageVariants = cva( + "aui-image-root relative overflow-hidden rounded-lg", + { + variants: { + variant: { + outline: "border border-border", + ghost: "", + muted: "bg-muted/50", + }, + size: { + sm: "max-w-64", + default: "max-w-96", + lg: "max-w-[512px]", + full: "w-full", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + }, + }, +); + +export type ImageRootProps = React.ComponentProps<"div"> & + VariantProps; + +function ImageRoot({ + className, + variant, + size, + children, + ...props +}: ImageRootProps) { + return ( +
+ {children} +
+ ); +} + +type ImagePreviewProps = Omit, "children"> & { + containerClassName?: string; +}; + +function ImagePreview({ + className, + containerClassName, + onLoad, + onError, + alt = "Image content", + src, + ...props +}: ImagePreviewProps) { + const imgRef = useRef(null); + const [loadedSrc, setLoadedSrc] = useState(undefined); + const [errorSrc, setErrorSrc] = useState(undefined); + + const loaded = loadedSrc === src; + const error = errorSrc === src; + + useEffect(() => { + if ( + typeof src === "string" && + imgRef.current?.complete && + imgRef.current.naturalWidth > 0 + ) { + setLoadedSrc(src); + } + }, [src]); + + return ( +
+ {!loaded && !error && ( +
+ +
+ )} + {error ? ( +
+ +
+ ) : ( + // biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs + {alt} { + if (typeof src === "string") setLoadedSrc(src); + onLoad?.(e); + }} + onError={(e) => { + if (typeof src === "string") setErrorSrc(src); + onError?.(e); + }} + {...props} + /> + )} +
+ ); +} + +function ImageFilename({ + className, + children, + ...props +}: React.ComponentProps<"span">) { + if (!children) return null; + + return ( + + {children} + + ); +} + +type ImageZoomProps = PropsWithChildren<{ + src: string; + alt?: string; +}>; + +function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) { + const [isMounted, setIsMounted] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isOpen]); + + return ( + <> + + {isMounted && + isOpen && + createPortal( + , + document.body, + )} + + ); +} + +const ImageImpl: ImageMessagePartComponent = ({ image, filename }) => { + return ( + + + + + {filename} + + ); +}; + +const Image = memo(ImageImpl) as unknown as ImageMessagePartComponent & { + Root: typeof ImageRoot; + Preview: typeof ImagePreview; + Filename: typeof ImageFilename; + Zoom: typeof ImageZoom; +}; + +Image.displayName = "Image"; +Image.Root = ImageRoot; +Image.Preview = ImagePreview; +Image.Filename = ImageFilename; +Image.Zoom = ImageZoom; + +export { + Image, + ImageRoot, + ImagePreview, + ImageFilename, + ImageZoom, + imageVariants, +}; diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 10ce85c5a..8c855bf17 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -8,8 +8,9 @@ import { unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock, } from "@assistant-ui/react-markdown"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; import { type FC, memo, type ReactNode, useState } from "react"; +import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; @@ -188,17 +189,17 @@ const useCopyToClipboard = ({ copiedDuration = 3000 }: { copiedDuration?: number function processChildrenWithCitations(children: ReactNode): ReactNode { if (typeof children === "string") { const parsed = parseTextWithCitations(children); - return parsed.length === 1 && typeof parsed[0] === "string" ? children : <>{parsed}; + return parsed.length === 1 && typeof parsed[0] === "string" ? children : parsed; } if (Array.isArray(children)) { - return children.map((child, index) => { + return children.map((child) => { if (typeof child === "string") { const parsed = parseTextWithCitations(child); return parsed.length === 1 && typeof parsed[0] === "string" ? ( child ) : ( - {parsed} + {parsed} ); } return child; @@ -208,6 +209,56 @@ function processChildrenWithCitations(children: ReactNode): ReactNode { return children; } +function extractDomain(url: string): string { + try { + const parsed = new URL(url); + return parsed.hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { + if (!src) return null; + + const domain = extractDomain(src); + + return ( +
+ + + + + + +
+
+ {alt && alt !== "Image" && ( +

{alt}

+ )} + {domain && ( +

{domain}

+ )} +
+ e.stopPropagation()} + > + Open + + +
+
+ ); +} + const defaultComponents = memoizeMarkdownComponents({ h1: ({ className, children, ...props }) => (

), + img: ({ src, alt }) => , CodeHeader, }); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 02e57ba20..5e8a251d2 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1058,7 +1058,7 @@ const TOOL_GROUPS: ToolGroup[] = [ }, { label: "Generate", - tools: ["generate_podcast", "generate_video_presentation", "generate_report", "generate_image", "display_image"], + tools: ["generate_podcast", "generate_video_presentation", "generate_report", "generate_image"], }, { label: "Memory", diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 3e6cf663c..20afa8e95 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -13,6 +13,7 @@ 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 { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; @@ -152,6 +153,7 @@ const PublicAssistantMessage: FC = () => { generate_video_presentation: GenerateVideoPresentationToolUI, link_preview: LinkPreviewToolUI, display_image: DisplayImageToolUI, + generate_image: GenerateImageToolUI, scrape_webpage: ScrapeWebpageToolUI, }, Fallback: ToolFallback, diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index c9ea4df41..979489ffe 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -23,6 +23,13 @@ export { DisplayImageResultSchema, DisplayImageToolUI, } from "./display-image"; +export { + type GenerateImageArgs, + GenerateImageArgsSchema, + type GenerateImageResult, + GenerateImageResultSchema, + GenerateImageToolUI, +} from "./generate-image"; export { GeneratePodcastToolUI } from "./generate-podcast"; export { GenerateReportToolUI } from "./generate-report"; export { GenerateVideoPresentationToolUI } from "./video-presentation"; diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx index 4d92713f8..d2b804e23 100644 --- a/surfsense_web/contracts/enums/toolIcons.tsx +++ b/surfsense_web/contracts/enums/toolIcons.tsx @@ -5,7 +5,6 @@ import { FileText, Film, Globe, - ImageIcon, Link2, type LucideIcon, Podcast, @@ -20,7 +19,6 @@ const TOOL_ICONS: Record = { generate_video_presentation: Film, generate_report: FileText, link_preview: Link2, - display_image: ImageIcon, generate_image: Sparkles, scrape_webpage: ScanLine, web_search: Globe, From a009cae62ac269c1b32addeada943aba2d39c5e2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:15:29 +0530 Subject: [PATCH 05/18] refactor: remove link_preview tool and associated components to streamline agent functionality --- .../app/agents/new_chat/__init__.py | 4 +- .../app/agents/new_chat/chat_deepagent.py | 3 +- .../app/agents/new_chat/system_prompt.py | 22 +- .../app/agents/new_chat/tools/__init__.py | 3 - .../app/agents/new_chat/tools/link_preview.py | 465 ------------------ .../app/agents/new_chat/tools/registry.py | 10 +- .../app/services/public_chat_service.py | 2 - .../app/tasks/chat/stream_new_chat.py | 63 --- .../new-chat/[[...chat_id]]/page.tsx | 1 - .../assistant-ui/assistant-message.tsx | 3 - .../components/assistant-ui/thread.tsx | 2 +- .../components/public-chat/public-thread.tsx | 2 - surfsense_web/components/tool-ui/index.ts | 21 - .../components/tool-ui/link-preview.tsx | 250 ---------- .../components/tool-ui/media-card/index.tsx | 354 ------------- surfsense_web/contracts/enums/toolIcons.tsx | 2 - 16 files changed, 5 insertions(+), 1202 deletions(-) delete mode 100644 surfsense_backend/app/agents/new_chat/tools/link_preview.py delete mode 100644 surfsense_web/components/tool-ui/link-preview.tsx delete mode 100644 surfsense_web/components/tool-ui/media-card/index.tsx diff --git a/surfsense_backend/app/agents/new_chat/__init__.py b/surfsense_backend/app/agents/new_chat/__init__.py index 96f4b399b..4238d17de 100644 --- a/surfsense_backend/app/agents/new_chat/__init__.py +++ b/surfsense_backend/app/agents/new_chat/__init__.py @@ -5,7 +5,7 @@ This module provides the SurfSense deep agent with configurable tools for knowledge base search, podcast generation, and more. Directory Structure: -- tools/: All agent tools (knowledge_base, podcast, link_preview, etc.) +- tools/: All agent tools (knowledge_base, podcast, generate_image, etc.) - chat_deepagent.py: Main agent factory - system_prompt.py: System prompts and instructions - context.py: Context schema for the agent @@ -38,7 +38,6 @@ from .tools import ( ToolDefinition, build_tools, create_generate_podcast_tool, - create_link_preview_tool, create_scrape_webpage_tool, create_search_knowledge_base_tool, format_documents_for_context, @@ -63,7 +62,6 @@ __all__ = [ "create_chat_litellm_from_config", # Tool factories "create_generate_podcast_tool", - "create_link_preview_tool", "create_scrape_webpage_tool", "create_search_knowledge_base_tool", # Agent factory diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index e073a24cf..2857be4a7 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -150,7 +150,6 @@ async def create_surfsense_deep_agent( - search_knowledge_base: Search the user's personal knowledge base - generate_podcast: Generate audio podcasts from content - generate_image: Generate images from text descriptions using AI models - - link_preview: Fetch rich previews for URLs - scrape_webpage: Extract content from webpages - save_memory: Store facts/preferences about the user - recall_memory: Retrieve relevant user memories @@ -206,7 +205,7 @@ async def create_surfsense_deep_agent( # Create agent with only specific tools agent = create_surfsense_deep_agent( llm, search_space_id, db_session, ..., - enabled_tools=["search_knowledge_base", "link_preview"] + enabled_tools=["search_knowledge_base", "scrape_webpage"] ) # Create agent without podcast generation diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 69cb8f40a..77df3acfd 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -184,21 +184,6 @@ _TOOL_INSTRUCTIONS["generate_report"] = """ - AFTER CALLING THIS TOOL: Do NOT repeat, summarize, or reproduce the report content in the chat. The report is already displayed as an interactive card that the user can open, read, copy, and export. Simply confirm that the report was generated (e.g., "I've generated your report on [topic]. You can view the Markdown report now, and export it in various formats from the card."). NEVER write out the report text in the chat. """ -_TOOL_INSTRUCTIONS["link_preview"] = """ -- link_preview: Fetch metadata for a URL to display a rich preview card. - - IMPORTANT: Use this tool WHENEVER the user shares or mentions a URL/link in their message. - - This fetches the page's Open Graph metadata (title, description, thumbnail) to show a preview card. - - NOTE: This tool only fetches metadata, NOT the full page content. It cannot read the article text. - - Trigger scenarios: - * User shares a URL (e.g., "Check out https://example.com") - * User pastes a link in their message - * User asks about a URL or link - - Args: - - url: The URL to fetch metadata for (must be a valid HTTP/HTTPS URL) - - Returns: A rich preview card with title, description, thumbnail, and domain - - The preview card will automatically be displayed in the chat. -""" - _TOOL_INSTRUCTIONS["generate_image"] = """ - generate_image: Generate images from text descriptions using AI image models. - Use this when the user asks you to create, generate, draw, design, or make an image. @@ -215,14 +200,11 @@ _TOOL_INSTRUCTIONS["generate_image"] = """ _TOOL_INSTRUCTIONS["scrape_webpage"] = """ - scrape_webpage: Scrape and extract the main content from a webpage. - Use this when the user wants you to READ and UNDERSTAND the actual content of a webpage. - - IMPORTANT: This is different from link_preview: - * link_preview: Only fetches metadata (title, description, thumbnail) for display - * scrape_webpage: Actually reads the FULL page content so you can analyze/summarize it - CRITICAL — WHEN TO USE (always attempt scraping, never refuse before trying): * When a user asks to "get", "fetch", "pull", "grab", "scrape", or "read" content from a URL * When the user wants live/dynamic data from a specific webpage (e.g., tables, scores, stats, prices) * When a URL was mentioned earlier in the conversation and the user asks for its actual content - * When link_preview or search_knowledge_base returned insufficient data and the user wants more + * When search_knowledge_base returned insufficient data and the user wants more - Trigger scenarios: * "Read this article and summarize it" * "What does this page say about X?" @@ -446,7 +428,6 @@ _TOOL_EXAMPLES["generate_report"] = """ _TOOL_EXAMPLES["scrape_webpage"] = """ - User: "Check out https://dev.to/some-article" - - Call: `link_preview(url="https://dev.to/some-article")` - Call: `scrape_webpage(url="https://dev.to/some-article")` - Then provide your analysis of the content. - User: "Read this article and summarize it for me: https://example.com/blog/ai-trends" @@ -489,7 +470,6 @@ _ALL_TOOL_NAMES_ORDERED = [ "generate_podcast", "generate_video_presentation", "generate_report", - "link_preview", "generate_image", "scrape_webpage", "save_memory", diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py index de84cdfb1..404926d19 100644 --- a/surfsense_backend/app/agents/new_chat/tools/__init__.py +++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py @@ -10,7 +10,6 @@ Available tools: - generate_podcast: Generate audio podcasts from content - generate_video_presentation: Generate video presentations with slides and narration - generate_image: Generate images from text descriptions using AI models -- link_preview: Fetch rich previews for URLs - scrape_webpage: Extract content from webpages - save_memory: Store facts/preferences about the user - recall_memory: Retrieve relevant user memories @@ -25,7 +24,6 @@ from .knowledge_base import ( format_documents_for_context, search_knowledge_base_async, ) -from .link_preview import create_link_preview_tool from .podcast import create_generate_podcast_tool from .registry import ( BUILTIN_TOOLS, @@ -51,7 +49,6 @@ __all__ = [ "create_generate_image_tool", "create_generate_podcast_tool", "create_generate_video_presentation_tool", - "create_link_preview_tool", "create_recall_memory_tool", "create_save_memory_tool", "create_scrape_webpage_tool", diff --git a/surfsense_backend/app/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py deleted file mode 100644 index 81d91d54c..000000000 --- a/surfsense_backend/app/agents/new_chat/tools/link_preview.py +++ /dev/null @@ -1,465 +0,0 @@ -""" -Link preview tool for the SurfSense agent. - -This module provides a tool for fetching URL metadata (title, description, -Open Graph image, etc.) to display rich link previews in the chat UI. -""" - -import asyncio -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_core.tools import tool -from playwright.sync_api import sync_playwright - -from app.utils.proxy_config import get_playwright_proxy, get_residential_proxy_url - -logger = logging.getLogger(__name__) - - -def extract_domain(url: str) -> str: - """Extract the domain from a URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove 'www.' prefix if present - if domain.startswith("www."): - domain = domain[4:] - return domain - except Exception: - return "" - - -def extract_og_content(html: str, property_name: str) -> str | None: - """Extract Open Graph meta content from HTML.""" - # Try og:property first - pattern = rf']+property=["\']og:{property_name}["\'][^>]+content=["\']([^"\']+)["\']' - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1) - - # Try content before property - pattern = rf']+content=["\']([^"\']+)["\'][^>]+property=["\']og:{property_name}["\']' - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1) - - return None - - -def extract_twitter_content(html: str, name: str) -> str | None: - """Extract Twitter Card meta content from HTML.""" - pattern = ( - rf']+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']' - ) - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1) - - # Try content before name - pattern = ( - rf']+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:{name}["\']' - ) - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1) - - return None - - -def extract_meta_description(html: str) -> str | None: - """Extract meta description from HTML.""" - pattern = r']+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']' - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1) - - # Try content before name - pattern = r']+content=["\']([^"\']+)["\'][^>]+name=["\']description["\']' - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1) - - return None - - -def extract_title(html: str) -> str | None: - """Extract title from HTML.""" - # Try og:title first - og_title = extract_og_content(html, "title") - if og_title: - return og_title - - # Try twitter:title - twitter_title = extract_twitter_content(html, "title") - if twitter_title: - return twitter_title - - # Fall back to tag - pattern = r"<title[^>]*>([^<]+)" - match = re.search(pattern, html, re.IGNORECASE) - if match: - return match.group(1).strip() - - return None - - -def extract_description(html: str) -> str | None: - """Extract description from HTML.""" - # Try og:description first - og_desc = extract_og_content(html, "description") - if og_desc: - return og_desc - - # Try twitter:description - twitter_desc = extract_twitter_content(html, "description") - if twitter_desc: - return twitter_desc - - # Fall back to meta description - return extract_meta_description(html) - - -def extract_image(html: str) -> str | None: - """Extract image URL from HTML.""" - # Try og:image first - og_image = extract_og_content(html, "image") - if og_image: - return og_image - - # Try twitter:image - twitter_image = extract_twitter_content(html, "image") - if twitter_image: - return twitter_image - - return None - - -def generate_preview_id(url: str) -> str: - """Generate a unique ID for a link preview.""" - hash_val = hashlib.md5(url.encode()).hexdigest()[:12] - 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 via Playwright. - Used as a fallback when simple HTTP requests are blocked (403, etc.). - - Runs the sync Playwright API in a thread so it works on any event - loop, including Windows ``SelectorEventLoop``. - - Args: - url: URL to fetch - - Returns: - Dict with title, description, image, and raw_html, or None if failed - """ - try: - return await asyncio.to_thread(_fetch_with_chromium_sync, url) - except Exception as e: - logger.error(f"[link_preview] Chromium fallback failed for {url}: {e}") - return None - - -def _fetch_with_chromium_sync(url: str) -> dict[str, Any] | None: - """Synchronous Playwright fetch executed in a worker thread.""" - logger.info(f"[link_preview] Falling back to Chromium for {url}") - - ua = UserAgent() - user_agent = ua.random - - playwright_proxy = get_playwright_proxy() - - with sync_playwright() as p: - launch_kwargs: dict = {"headless": True} - if playwright_proxy: - launch_kwargs["proxy"] = playwright_proxy - browser = p.chromium.launch(**launch_kwargs) - context = browser.new_context(user_agent=user_agent) - page = context.new_page() - - try: - page.goto(url, wait_until="domcontentloaded", timeout=30000) - raw_html = page.content() - finally: - browser.close() - - if not raw_html or len(raw_html.strip()) == 0: - logger.warning(f"[link_preview] Chromium returned empty content for {url}") - return None - - trafilatura_metadata = trafilatura.extract_metadata(raw_html) - - image = extract_image(raw_html) - - result: dict[str, Any] = { - "title": None, - "description": None, - "image": image, - "raw_html": raw_html, - } - - if trafilatura_metadata: - result["title"] = trafilatura_metadata.title - result["description"] = trafilatura_metadata.description - - 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 - - -def create_link_preview_tool(): - """ - Factory function to create the link_preview tool. - - Returns: - A configured tool function for fetching link previews. - """ - - @tool - async def link_preview(url: str) -> dict[str, Any]: - """ - Fetch metadata for a URL to display a rich link preview. - - Use this tool when the user shares a URL or asks about a specific webpage. - This tool fetches the page's Open Graph metadata (title, description, image) - to display a nice preview card in the chat. - - Common triggers include: - - User shares a URL in the chat - - User asks "What's this link about?" or similar - - User says "Show me a preview of this page" - - User wants to preview an article or webpage - - Args: - url: The URL to fetch metadata for. Must be a valid HTTP/HTTPS URL. - - Returns: - A dictionary containing: - - id: Unique identifier for this preview - - assetId: The URL itself (for deduplication) - - kind: "link" (type of media card) - - href: The URL to open when clicked - - title: Page title - - description: Page description (if available) - - thumb: Thumbnail/preview image URL (if available) - - domain: The domain name - - error: Error message (if fetch failed) - """ - preview_id = generate_preview_id(url) - domain = extract_domain(url) - - # Validate URL - if not url.startswith(("http://", "https://")): - url = f"https://{url}" - - try: - # Generate a random User-Agent to avoid bot detection - ua = UserAgent() - user_agent = ua.random - - # Use residential proxy if configured - proxy_url = get_residential_proxy_url() - - # Use a browser-like User-Agent to fetch Open Graph metadata. - # 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, - proxy=proxy_url, - headers={ - "User-Agent": user_agent, - "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) - response.raise_for_status() - - # Get content type to ensure it's HTML - content_type = response.headers.get("content-type", "") - if "text/html" not in content_type.lower(): - # Not an HTML page, return basic info - return { - "id": preview_id, - "assetId": url, - "kind": "link", - "href": url, - "title": url.split("/")[-1] or domain, - "description": f"File from {domain}", - "domain": domain, - } - - html = response.text - - # Extract metadata - title = extract_title(html) or domain - description = extract_description(html) - image = extract_image(html) - - # Make sure image URL is absolute - if image: - image = _make_absolute_url(image, url) - - # Clean up title and description (unescape HTML entities) - if title: - title = _unescape_html(title) - if description: - description = _unescape_html(description) - # Truncate long descriptions - if len(description) > 200: - description = description[:197] + "..." - - return { - "id": preview_id, - "assetId": url, - "kind": "link", - "href": url, - "title": title, - "description": description, - "thumb": image, - "domain": domain, - } - - 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, - "kind": "link", - "href": url, - "title": domain or "Link", - "domain": domain, - "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, - "kind": "link", - "href": url, - "title": domain or "Link", - "domain": domain, - "error": f"HTTP {status_code}", - } - except Exception as e: - error_message = str(e) - logger.error(f"[link_preview] Error fetching {url}: {error_message}") - return { - "id": preview_id, - "assetId": url, - "kind": "link", - "href": url, - "title": domain or "Link", - "domain": domain, - "error": f"Failed to fetch: {error_message[:50]}", - } - - return link_preview diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 29ef75641..7700d47d3 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -77,7 +77,6 @@ from .linear import ( create_delete_linear_issue_tool, create_update_linear_issue_tool, ) -from .link_preview import create_link_preview_tool from .mcp_tool import load_mcp_tools from .notion import ( create_create_notion_page_tool, @@ -186,13 +185,6 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ # are optional — when missing, source_strategy="kb_search" degrades # gracefully to "provided" ), - # Link preview tool - fetches Open Graph metadata for URLs - ToolDefinition( - name="link_preview", - description="Fetch metadata for a URL to display a rich preview card", - factory=lambda deps: create_link_preview_tool(), - requires=[], - ), # Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.) ToolDefinition( name="generate_image", @@ -559,7 +551,7 @@ def build_tools( tools = build_tools(deps) # Use only specific tools - tools = build_tools(deps, enabled_tools=["search_knowledge_base", "link_preview"]) + tools = build_tools(deps, enabled_tools=["search_knowledge_base"]) # Use defaults but disable podcast tools = build_tools(deps, disabled_tools=["generate_podcast"]) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 9f0c76b9c..763ae64c3 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -39,12 +39,10 @@ from app.utils.rbac import check_permission UI_TOOLS = { "generate_image", - "link_preview", "generate_podcast", "generate_report", "generate_video_presentation", "scrape_webpage", - "multi_link_preview", } diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index e683ea106..1f3eaa179 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -335,22 +335,6 @@ async def _stream_agent_events( status="in_progress", items=last_active_step_items, ) - elif tool_name == "link_preview": - url = ( - tool_input.get("url", "") - if isinstance(tool_input, dict) - else str(tool_input) - ) - last_active_step_title = "Fetching link preview" - last_active_step_items = [ - f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" - ] - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title="Fetching link preview", - status="in_progress", - items=last_active_step_items, - ) elif tool_name == "generate_image": prompt = ( tool_input.get("prompt", "") @@ -504,30 +488,6 @@ async def _stream_agent_events( status="completed", items=completed_items, ) - elif tool_name == "link_preview": - if isinstance(tool_output, dict): - title = tool_output.get("title", "Link") - domain = tool_output.get("domain", "") - has_error = "error" in tool_output - if has_error: - completed_items = [ - *last_active_step_items, - f"Error: {tool_output.get('error', 'Failed to fetch')}", - ] - else: - completed_items = [ - *last_active_step_items, - f"Title: {title[:60]}{'...' if len(title) > 60 else ''}", - f"Domain: {domain}" if domain else "Preview loaded", - ] - else: - completed_items = [*last_active_step_items, "Preview loaded"] - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title="Fetching link preview", - status="completed", - items=completed_items, - ) elif tool_name == "generate_image": if isinstance(tool_output, dict) and not tool_output.get("error"): completed_items = [ @@ -818,29 +778,6 @@ async def _stream_agent_events( f"Presentation generation failed: {error_msg}", "error", ) - elif tool_name == "link_preview": - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - if isinstance(tool_output, dict) and "error" not in tool_output: - title = tool_output.get("title", "Link") - yield streaming_service.format_terminal_info( - f"Link preview loaded: {title[:50]}{'...' if len(title) > 50 else ''}", - "success", - ) - else: - error_msg = ( - tool_output.get("error", "Failed to fetch") - if isinstance(tool_output, dict) - else "Failed to fetch" - ) - yield streaming_service.format_terminal_info( - f"Link preview failed: {error_msg}", - "error", - ) elif tool_name == "generate_image": yield streaming_service.format_tool_output_available( tool_call_id, 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 7ed59c0bf..b3cc4fa6c 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 @@ -131,7 +131,6 @@ const TOOLS_WITH_UI = new Set([ "generate_podcast", "generate_report", "generate_video_presentation", - "link_preview", "display_image", "generate_image", "delete_notion_page", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 0a506d23b..fa3aec45a 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -27,7 +27,6 @@ import { CreateCalendarEventToolUI, DeleteCalendarEventToolUI, UpdateCalendarEve import { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "@/components/tool-ui/google-drive"; import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, UpdateJiraIssueToolUI } from "@/components/tool-ui/jira"; import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, UpdateLinearIssueToolUI } from "@/components/tool-ui/linear"; -import { LinkPreviewToolUI, MultiLinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "@/components/tool-ui/notion"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; @@ -58,8 +57,6 @@ const AssistantMessageInner: FC = () => { generate_report: GenerateReportToolUI, generate_podcast: GeneratePodcastToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - link_preview: LinkPreviewToolUI, - multi_link_preview: MultiLinkPreviewToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, scrape_webpage: ScrapeWebpageToolUI, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 5e8a251d2..f160114ba 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1054,7 +1054,7 @@ interface ToolGroup { const TOOL_GROUPS: ToolGroup[] = [ { label: "Research", - tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"], + tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage"], }, { label: "Generate", diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 20afa8e95..8076188c0 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -17,7 +17,6 @@ import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; -import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; interface PublicThreadProps { @@ -151,7 +150,6 @@ const PublicAssistantMessage: FC = () => { generate_podcast: GeneratePodcastToolUI, generate_report: GenerateReportToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - link_preview: LinkPreviewToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, scrape_webpage: ScrapeWebpageToolUI, diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 979489ffe..0f1126847 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -48,27 +48,6 @@ export { DeleteLinearIssueToolUI, UpdateLinearIssueToolUI, } from "./linear"; -export { - type LinkPreviewArgs, - LinkPreviewArgsSchema, - type LinkPreviewResult, - LinkPreviewResultSchema, - LinkPreviewToolUI, - type MultiLinkPreviewArgs, - MultiLinkPreviewArgsSchema, - type MultiLinkPreviewResult, - MultiLinkPreviewResultSchema, - MultiLinkPreviewToolUI, -} from "./link-preview"; -export { - MediaCard, - MediaCardErrorBoundary, - MediaCardLoading, - type MediaCardProps, - MediaCardSkeleton, - parseSerializableMediaCard, - type SerializableMediaCard, -} from "./media-card"; export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion"; export { Plan, diff --git a/surfsense_web/components/tool-ui/link-preview.tsx b/surfsense_web/components/tool-ui/link-preview.tsx deleted file mode 100644 index 7af00c5ba..000000000 --- a/surfsense_web/components/tool-ui/link-preview.tsx +++ /dev/null @@ -1,250 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react"; -import { z } from "zod"; -import { - MediaCard, - MediaCardErrorBoundary, - MediaCardLoading, - parseSerializableMediaCard, - type SerializableMediaCard, -} from "@/components/tool-ui/media-card"; - -// ============================================================================ -// Zod Schemas -// ============================================================================ - -/** - * Schema for link_preview tool arguments - */ -const LinkPreviewArgsSchema = z.object({ - url: z.string(), - title: z.string().nullish(), -}); - -/** - * Schema for link_preview tool result - */ -const LinkPreviewResultSchema = z.object({ - id: z.string(), - assetId: z.string(), - kind: z.literal("link"), - href: z.string(), - title: z.string(), - description: z.string().nullish(), - thumb: z.string().nullish(), - domain: z.string().nullish(), - error: z.string().nullish(), -}); - -// ============================================================================ -// Types -// ============================================================================ - -type LinkPreviewArgs = z.infer; -type LinkPreviewResult = z.infer; - -/** - * Error state component shown when link preview fails - */ -function LinkPreviewErrorState({ url, error }: { url: string; error: string }) { - return ( -
-
-
- -
-
-

Failed to load preview

-

{url}

-

{error}

-
-
-
- ); -} - -/** - * Cancelled state component - */ -function LinkPreviewCancelledState({ url }: { url: string }) { - return ( -
-

- - Preview: {url} -

-
- ); -} - -/** - * Parsed MediaCard component with error handling - */ -function ParsedMediaCard({ result }: { result: unknown }) { - const card = parseSerializableMediaCard(result); - - return ( - { - if (id === "open" && card.href) { - window.open(card.href, "_blank", "noopener,noreferrer"); - } - }} - /> - ); -} - -/** - * Link Preview Tool UI Component - * - * This component is registered with assistant-ui to render a rich - * link preview card when the link_preview tool is called by the agent. - * - * It displays website metadata including: - * - Title and description - * - Thumbnail/Open Graph image - * - Domain name - * - Clickable link to open in new tab - */ -export const LinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { - const url = args.url || "Unknown URL"; - - // Loading state - tool is still running - if (status.type === "running" || status.type === "requires-action") { - return ( -
- -
- ); - } - - // Incomplete/cancelled state - if (status.type === "incomplete") { - if (status.reason === "cancelled") { - return ; - } - if (status.reason === "error") { - return ( - - ); - } - } - - // No result yet - if (!result) { - return ( -
- -
- ); - } - - // Error result from the tool - if (result.error) { - return ; - } - - // Success - render the media card - return ( -
- - - -
- ); -}; - -// ============================================================================ -// Multi Link Preview Schemas -// ============================================================================ - -/** - * Schema for multi_link_preview tool arguments - */ -const MultiLinkPreviewArgsSchema = z.object({ - urls: z.array(z.string()), -}); - -/** - * Schema for error items in multi_link_preview result - */ -const MultiLinkPreviewErrorSchema = z.object({ - url: z.string(), - error: z.string(), -}); - -/** - * Schema for multi_link_preview tool result - */ -const MultiLinkPreviewResultSchema = z.object({ - previews: z.array(LinkPreviewResultSchema), - errors: z.array(MultiLinkPreviewErrorSchema).nullish(), -}); - -type MultiLinkPreviewArgs = z.infer; -type MultiLinkPreviewResult = z.infer; - -export const MultiLinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { - const urls = args.urls || []; - - // Loading state - if (status.type === "running" || status.type === "requires-action") { - return ( -
- {urls.slice(0, 4).map((url, index) => ( - - ))} -
- ); - } - - // Incomplete state - if (status.type === "incomplete") { - return ( -
-

- - Link previews cancelled -

-
- ); - } - - // No result - if (!result || !result.previews) { - return null; - } - - // Render grid of previews - return ( -
- {result.previews.map((preview) => ( - - - - ))} - {result.errors?.map((err) => ( - - ))} -
- ); -}; - -export { - LinkPreviewArgsSchema, - LinkPreviewResultSchema, - MultiLinkPreviewArgsSchema, - MultiLinkPreviewResultSchema, - type LinkPreviewArgs, - type LinkPreviewResult, - type MultiLinkPreviewArgs, - type MultiLinkPreviewResult, -}; diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx deleted file mode 100644 index c7c8cfdf2..000000000 --- a/surfsense_web/components/tool-ui/media-card/index.tsx +++ /dev/null @@ -1,354 +0,0 @@ -"use client"; - -import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react"; -import Image from "next/image"; -import { Component, type ReactNode } from "react"; -import { z } from "zod"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -/** - * Zod schemas for runtime validation - */ -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"]).nullish(), - confirmLabel: z.string().nullish(), -}); - -const SerializableMediaCardSchema = z.object({ - id: z.string(), - assetId: z.string(), - kind: MediaCardKindSchema, - href: z.string().nullish(), - src: z.string().nullish(), - title: z.string(), - description: z.string().nullish(), - thumb: z.string().nullish(), - ratio: AspectRatioSchema.nullish(), - domain: z.string().nullish(), -}); - -/** - * Types derived from Zod schemas - */ -type AspectRatio = z.infer; -type MediaCardKind = z.infer; -type ResponseAction = z.infer; -export type SerializableMediaCard = z.infer; - -/** - * Props for the MediaCard component - */ -export interface MediaCardProps { - id: string; - assetId: string; - kind: MediaCardKind; - href?: string; - src?: string; - title: string; - description?: string; - thumb?: string; - ratio?: AspectRatio; - domain?: string; - maxWidth?: string; - alt?: string; - className?: string; - responseActions?: ResponseAction[]; - onResponseAction?: (id: string) => void; -} - -/** - * Parse and validate serializable media card from tool result - */ -export function parseSerializableMediaCard(result: unknown): SerializableMediaCard { - const parsed = SerializableMediaCardSchema.safeParse(result); - - if (!parsed.success) { - console.warn("Invalid media card data:", parsed.error.issues); - throw new Error(`Invalid media card: ${parsed.error.issues.map((i) => i.message).join(", ")}`); - } - - return parsed.data; -} - -/** - * Get aspect ratio class based on ratio prop - */ -function getAspectRatioClass(ratio?: AspectRatio): string { - switch (ratio) { - case "1:1": - return "aspect-square"; - case "4:3": - 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": - default: - return "aspect-[2/1]"; - } -} - -/** - * Get icon based on media card kind - */ -function getKindIcon(kind: MediaCardKind) { - switch (kind) { - case "link": - return ; - case "image": - return ; - case "video": - case "audio": - return ; - default: - return ; - } -} - -/** - * Error boundary for MediaCard - */ -interface MediaCardErrorBoundaryState { - hasError: boolean; - error?: Error; -} - -export class MediaCardErrorBoundary extends Component< - { children: ReactNode }, - MediaCardErrorBoundaryState -> { - constructor(props: { children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(error: Error): MediaCardErrorBoundaryState { - return { hasError: true, error }; - } - - render() { - if (this.state.hasError) { - return ( - - -
- -
-
-

Failed to load preview

-

- {this.state.error?.message || "An error occurred"} -

-
-
-
- ); - } - - return this.props.children; - } -} - -/** - * Loading skeleton for MediaCard - */ -export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) { - return ( - -
- -
-
-
- - - ); -} - -/** - * MediaCard Component - * - * A rich media card for displaying link previews, images, and other media - * in AI chat applications. Supports thumbnails, descriptions, and actions. - */ -export function MediaCard({ - id, - kind, - href, - title, - description, - thumb, - ratio = "auto", - domain, - maxWidth = "420px", - alt, - className, - responseActions, - onResponseAction, -}: MediaCardProps) { - const aspectRatioClass = getAspectRatioClass(ratio); - const displayDomain = domain || (href ? new URL(href).hostname.replace("www.", "") : undefined); - - const handleCardClick = () => { - if (href) { - window.open(href, "_blank", "noopener,noreferrer"); - } - }; - - return ( - - { - if (href && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - handleCardClick(); - } - }} - > - {/* Thumbnail */} - {thumb && ( -
- {alt { - // Hide broken images - e.currentTarget.style.display = "none"; - }} - /> - {/* Gradient overlay */} -
-
- )} - - {/* Fallback when no thumbnail */} - {!thumb && ( -
-
- {getKindIcon(kind)} - {kind === "link" ? "Link Preview" : kind} -
-
- )} - - {/* Content */} - -
- {/* Domain favicon placeholder */} -
- -
- -
- {/* Domain badge */} - {displayDomain && ( -
- - {displayDomain} - - {href && ( - - )} -
- )} - - {/* Title */} -

- {title} -

- - {/* Description */} - {description && ( -

- {description} -

- )} -
-
- - {/* Response Actions */} - {responseActions && responseActions.length > 0 && ( -
- {responseActions.map((action) => ( - - - - - {action.confirmLabel && ( - -

{action.confirmLabel}

-
- )} -
- ))} -
- )} -
- - - ); -} - -/** - * MediaCard Loading State - */ -export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) { - return ( - -
- -
- -
-
-
-
-
-
-
-

{title}

- - - ); -} diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx index d2b804e23..90ec7a544 100644 --- a/surfsense_web/contracts/enums/toolIcons.tsx +++ b/surfsense_web/contracts/enums/toolIcons.tsx @@ -5,7 +5,6 @@ import { FileText, Film, Globe, - Link2, type LucideIcon, Podcast, ScanLine, @@ -18,7 +17,6 @@ const TOOL_ICONS: Record = { generate_podcast: Podcast, generate_video_presentation: Film, generate_report: FileText, - link_preview: Link2, generate_image: Sparkles, scrape_webpage: ScanLine, web_search: Globe, From 3f4e1a7dfd45181beb7fcc41e050438d3c53d4e5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:55:06 +0530 Subject: [PATCH 06/18] refactor: remove frontend of `scrape_webpage` tool --- .../app/agents/new_chat/system_prompt.py | 12 +- .../app/services/public_chat_service.py | 1 - .../new-chat/[[...chat_id]]/page.tsx | 1 - .../assistant-ui/assistant-message.tsx | 2 - .../components/assistant-ui/tool-fallback.tsx | 151 +++++-- .../components/public-chat/public-thread.tsx | 2 - .../components/tool-ui/article/index.tsx | 425 ------------------ surfsense_web/components/tool-ui/index.ts | 16 - .../components/tool-ui/scrape-webpage.tsx | 163 ------- 9 files changed, 118 insertions(+), 655 deletions(-) delete mode 100644 surfsense_web/components/tool-ui/article/index.tsx delete mode 100644 surfsense_web/components/tool-ui/scrape-webpage.tsx diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 77df3acfd..b53251a1d 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -220,7 +220,8 @@ _TOOL_INSTRUCTIONS["scrape_webpage"] = """ - url: The URL of the webpage to scrape (must be HTTP/HTTPS) - max_length: Maximum content length to return (default: 50000 chars) - Returns: The page title, description, full content (in markdown), word count, and metadata - - After scraping, you will have the full article text and can analyze, summarize, or answer questions about it. + - After scraping, provide a comprehensive, well-structured summary with key takeaways using headings or bullet points. + - Reference the source using markdown links [descriptive text](url) — never bare URLs. - IMAGES: The scraped content may contain image URLs in markdown format like `![alt text](image_url)`. * When you find relevant/important images in the scraped content, include them in your response using standard markdown image syntax: `![alt text](image_url)`. * This makes your response more visual and engaging. @@ -244,6 +245,8 @@ _TOOL_INSTRUCTIONS["web_search"] = """ - Args: - query: The search query - use specific, descriptive terms - top_k: Number of results to retrieve (default: 10, max: 50) + - If search snippets are insufficient for the user's question, use `scrape_webpage` on the most relevant result URL for full content. + - When presenting results, reference sources as markdown links [descriptive text](url) — never bare URLs. """ # Memory tool instructions have private and shared variants. @@ -429,13 +432,16 @@ _TOOL_EXAMPLES["generate_report"] = """ _TOOL_EXAMPLES["scrape_webpage"] = """ - User: "Check out https://dev.to/some-article" - Call: `scrape_webpage(url="https://dev.to/some-article")` - - Then provide your analysis of the content. + - Respond with a structured analysis — key points, takeaways. - User: "Read this article and summarize it for me: https://example.com/blog/ai-trends" - Call: `scrape_webpage(url="https://example.com/blog/ai-trends")` - - Then provide a summary based on the scraped text. + - Respond with a thorough summary using headings and bullet points. - User: (after discussing https://example.com/stats) "Can you get the live data from that page?" - Call: `scrape_webpage(url="https://example.com/stats")` - IMPORTANT: Always attempt scraping first. Never refuse before trying the tool. +- User: "https://example.com/blog/weekend-recipes" + - Call: `scrape_webpage(url="https://example.com/blog/weekend-recipes")` + - When a user sends just a URL with no instructions, scrape it and provide a concise summary of the content. """ _TOOL_EXAMPLES["generate_image"] = """ diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 763ae64c3..376db974f 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -42,7 +42,6 @@ UI_TOOLS = { "generate_podcast", "generate_report", "generate_video_presentation", - "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 b3cc4fa6c..3f6893169 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 @@ -134,7 +134,6 @@ const TOOLS_WITH_UI = new Set([ "display_image", "generate_image", "delete_notion_page", - "scrape_webpage", "create_notion_page", "update_notion_page", "create_linear_issue", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index fa3aec45a..14fb18bf9 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -29,7 +29,6 @@ import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, UpdateJiraIssueToolUI } f import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, UpdateLinearIssueToolUI } from "@/components/tool-ui/linear"; import { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "@/components/tool-ui/notion"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; -import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; @@ -59,7 +58,6 @@ const AssistantMessageInner: FC = () => { generate_video_presentation: GenerateVideoPresentationToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, - scrape_webpage: ScrapeWebpageToolUI, save_memory: SaveMemoryToolUI, recall_memory: RecallMemoryToolUI, execute: SandboxExecuteToolUI, diff --git a/surfsense_web/components/assistant-ui/tool-fallback.tsx b/surfsense_web/components/assistant-ui/tool-fallback.tsx index 636b43c36..d12ffb5d6 100644 --- a/surfsense_web/components/assistant-ui/tool-fallback.tsx +++ b/surfsense_web/components/assistant-ui/tool-fallback.tsx @@ -1,8 +1,14 @@ import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { getToolIcon } from "@/contracts/enums/toolIcons"; + +function formatToolName(name: string): string { + return name + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} export const ToolFallback: ToolCallMessagePartComponent = ({ toolName, @@ -10,66 +16,127 @@ export const ToolFallback: ToolCallMessagePartComponent = ({ result, status, }) => { - const [isCollapsed, setIsCollapsed] = useState(true); + const [isExpanded, setIsExpanded] = useState(false); const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; + const isError = status?.type === "incomplete" && status.reason === "error"; + const isRunning = status?.type === "running" || status?.type === "requires-action"; const cancelledReason = isCancelled && status.error ? typeof status.error === "string" ? status.error : JSON.stringify(status.error) : null; + const errorReason = + isError && status.error + ? typeof status.error === "string" + ? status.error + : JSON.stringify(status.error) + : null; + + const Icon = getToolIcon(toolName); + const displayName = formatToolName(toolName); return (
-
- {isCancelled ? ( - - ) : ( - - )} -

setIsExpanded(!isExpanded)} + className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none" + > +

- {isCancelled ? "Cancelled tool: " : "Used tool: "} - {toolName} -

- -
- {!isCollapsed && ( -
- {cancelledReason && ( -
-

- Cancelled reason: -

-

- {cancelledReason} -

-
- )} -
-
{argsText}
-
- {!isCancelled && result !== undefined && ( -
-

Result:

-
-								{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
-							
-
+ {isError ? ( + + ) : isCancelled ? ( + + ) : isRunning ? ( + + ) : ( + )}
+ +
+

+ {isRunning + ? displayName + : isCancelled + ? `Cancelled: ${displayName}` + : isError + ? `Failed: ${displayName}` + : displayName} +

+ {isRunning && ( +

Running...

+ )} + {cancelledReason && ( +

{cancelledReason}

+ )} + {errorReason && ( +

{errorReason}

+ )} +
+ + {!isRunning && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} + + + {isExpanded && !isRunning && ( + <> +
+
+ {argsText && ( +
+

Arguments

+
+									{argsText}
+								
+
+ )} + {!isCancelled && result !== undefined && ( + <> +
+
+

Result

+
+										{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+									
+
+ + )} +
+ )}
); diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 8076188c0..9b1fe7e49 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -17,7 +17,6 @@ import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; -import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; interface PublicThreadProps { footer?: ReactNode; @@ -152,7 +151,6 @@ const PublicAssistantMessage: FC = () => { generate_video_presentation: GenerateVideoPresentationToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, - scrape_webpage: ScrapeWebpageToolUI, }, Fallback: ToolFallback, }, diff --git a/surfsense_web/components/tool-ui/article/index.tsx b/surfsense_web/components/tool-ui/article/index.tsx deleted file mode 100644 index 43ea7c4c9..000000000 --- a/surfsense_web/components/tool-ui/article/index.tsx +++ /dev/null @@ -1,425 +0,0 @@ -"use client"; - -import { - AlertCircleIcon, - BookOpenIcon, - CalendarIcon, - ExternalLinkIcon, - FileTextIcon, - UserIcon, -} from "lucide-react"; -import Image from "next/image"; -import { Component, type ReactNode, useCallback, useState } from "react"; -import { z } from "zod"; -import { Card, CardContent } from "@/components/ui/card"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -/** - * Zod schema for serializable article data (from backend) - */ -const SerializableArticleSchema = z.object({ - id: z.string().default("article-unknown"), - assetId: z.string().nullish(), - kind: z.literal("article").nullish(), - title: z.string().default("Untitled Article"), - 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(), -}); - -/** - * Serializable article data type (from backend) - */ -export type SerializableArticle = z.infer; - -/** - * Article component props - */ -export interface ArticleProps { - /** Unique identifier for the article */ - id: string; - /** Asset identifier (usually the URL) */ - assetId?: string; - /** Article title */ - title: string; - /** Brief description or excerpt */ - description?: string; - /** Full content of the article (markdown) */ - content?: string; - /** URL to the original article */ - href?: string; - /** Domain of the article source */ - domain?: string; - /** Author name */ - author?: string; - /** Publication date */ - date?: string; - /** Word count */ - wordCount?: number; - /** Whether content was truncated */ - wasTruncated?: boolean; - /** Optional max width */ - maxWidth?: string; - /** Optional error message */ - error?: string; - /** Optional className */ - className?: string; - /** Response actions */ - responseActions?: Array<{ - id: string; - label: string; - variant?: "default" | "outline"; - }>; - /** Response action handler */ - onResponseAction?: (actionId: string) => void; -} - -/** - * Parse and validate serializable article data to ArticleProps - */ -export function parseSerializableArticle(data: unknown): ArticleProps { - const result = SerializableArticleSchema.safeParse(data); - - if (!result.success) { - console.warn("Invalid article data:", result.error.issues); - // Return fallback with basic info - const obj = (data && typeof data === "object" ? data : {}) as Record; - return { - id: String(obj.id || "article-unknown"), - title: String(obj.title || "Untitled Article"), - error: "Failed to parse article data", - }; - } - - const parsed = result.data; - return { - id: parsed.id, - assetId: parsed.assetId, - title: parsed.title, - description: parsed.description, - content: parsed.content, - href: parsed.href, - domain: parsed.domain, - author: parsed.author, - date: parsed.date, - wordCount: parsed.word_count ?? parsed.wordCount, - wasTruncated: parsed.was_truncated ?? parsed.wasTruncated, - error: parsed.error, - }; -} - -/** - * Format word count for display - */ -function formatWordCount(count: number): string { - if (count >= 1000) { - return `${(count / 1000).toFixed(1)}k words`; - } - return `${count} words`; -} - -/** - * Favicon component that fetches the site icon via Google's favicon service, - * falling back to BookOpenIcon on error. - */ -function SiteFavicon({ domain }: { domain: string }) { - const [failed, setFailed] = useState(false); - - if (failed) { - return ; - } - - return ( - {`${domain} setFailed(true)} - unoptimized - /> - ); -} - -/** - * Article card component for displaying scraped webpage content - */ -export function Article({ - id, - title, - description, - content, - href, - domain, - author, - date, - wordCount, - wasTruncated, - maxWidth = "100%", - error, - className, - responseActions, - onResponseAction, -}: ArticleProps) { - const handleCardClick = useCallback(() => { - if (href) { - window.open(href, "_blank", "noopener,noreferrer"); - } - }, [href]); - - // Error state - if (error) { - return ( - - -
-
- -
-
-

Failed to scrape webpage

- {href &&

{href}

} -

{error}

-
-
-
-
- ); - } - - return ( - - { - if (href && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - handleCardClick(); - } - }} - > - {/* Header */} - -
- {/* Favicon / Icon */} - {domain ? ( -
- -
- ) : ( -
- -
- )} - - {/* Content */} -
- {/* Title */} -

- {title} -

- - {/* Description */} - {description && ( -

- {description} -

- )} - - {/* Metadata row */} -
- {domain && ( - - - - - {domain} - - - -

Source: {domain}

-
-
- )} - - {author && ( - - - - - {author} - - - -

Author: {author}

-
-
- )} - - {date && ( - - - {date} - - )} - - {wordCount && ( - - - - - {formatWordCount(wordCount)} - {wasTruncated && (truncated)} - - - -

- {wasTruncated - ? "Content was truncated due to length" - : "Full article content available"} -

-
-
- )} -
-
-
- - {/* Response actions */} - {responseActions && responseActions.length > 0 && ( -
- {responseActions.map((action) => ( - - ))} -
- )} -
-
-
- ); -} - -/** - * Loading state for article component - */ -export function ArticleLoading({ title = "Loading article..." }: { title?: string }) { - return ( - - -
-
-
-
-
-
-
-
-

{title}

- - - ); -} - -/** - * Skeleton for article component - */ -export function ArticleSkeleton() { - return ( - - -
-
-
-
-
-
-
-
- - - ); -} - -/** - * Error boundary props - */ -interface ErrorBoundaryProps { - children: ReactNode; - fallback?: ReactNode; -} - -interface ErrorBoundaryState { - hasError: boolean; -} - -/** - * Error boundary for article component - */ -export class ArticleErrorBoundary extends Component { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(): ErrorBoundaryState { - return { hasError: true }; - } - - render() { - if (this.state.hasError) { - return ( - this.props.fallback || ( - - -
- -

Failed to render article

-
-
-
- ) - ); - } - - return this.props.children; - } -} diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 0f1126847..b2978f8f3 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -6,15 +6,6 @@ * rich UI when specific tools are called by the agent. */ -export { - Article, - ArticleErrorBoundary, - ArticleLoading, - type ArticleProps, - ArticleSkeleton, - parseSerializableArticle, - type SerializableArticle, -} from "./article"; export { Audio } from "./audio"; export { type DisplayImageArgs, @@ -65,13 +56,6 @@ export { ExecuteResultSchema, SandboxExecuteToolUI, } from "./sandbox-execute"; -export { - type ScrapeWebpageArgs, - ScrapeWebpageArgsSchema, - type ScrapeWebpageResult, - ScrapeWebpageResultSchema, - ScrapeWebpageToolUI, -} from "./scrape-webpage"; export { type MemoryItem, type RecallMemoryArgs, diff --git a/surfsense_web/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx deleted file mode 100644 index a17c56734..000000000 --- a/surfsense_web/components/tool-ui/scrape-webpage.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import { AlertCircleIcon, FileTextIcon } from "lucide-react"; -import { z } from "zod"; -import { - Article, - ArticleErrorBoundary, - ArticleLoading, - parseSerializableArticle, -} from "@/components/tool-ui/article"; - -// ============================================================================ -// Zod Schemas -// ============================================================================ - -/** - * Schema for scrape_webpage tool arguments - */ -const ScrapeWebpageArgsSchema = z.object({ - url: z.string(), - max_length: z.number().nullish(), -}); - -/** - * Schema for scrape_webpage tool result - */ -const ScrapeWebpageResultSchema = z.object({ - id: z.string(), - assetId: z.string(), - kind: z.literal("article"), - href: z.string(), - title: z.string(), - description: z.string().nullish(), - content: z.string().nullish(), - domain: z.string().nullish(), - author: z.string().nullish(), - date: z.string().nullish(), - word_count: z.number().nullish(), - was_truncated: z.boolean().nullish(), - crawler_type: z.string().nullish(), - error: z.string().nullish(), -}); - -// ============================================================================ -// Types -// ============================================================================ - -type ScrapeWebpageArgs = z.infer; -type ScrapeWebpageResult = z.infer; - -/** - * Error state component shown when webpage scraping fails - */ -function ScrapeErrorState({ url, error }: { url: string; error: string }) { - return ( -
-
-
- -
-
-

Failed to scrape webpage

-

{url}

-

{error}

-
-
-
- ); -} - -/** - * Cancelled state component - */ -function ScrapeCancelledState({ url }: { url: string }) { - return ( -
-

- - Scraping: {url} -

-
- ); -} - -/** - * Parsed Article component with error handling - */ -function ParsedArticle({ result }: { result: unknown }) { - const { description, ...article } = parseSerializableArticle(result); - - return
; -} - -/** - * Scrape Webpage Tool UI Component - * - * This component is registered with assistant-ui to render an article card - * when the scrape_webpage tool is called by the agent. - * - * It displays scraped webpage content including: - * - Title and description - * - Author and date (if available) - * - Word count - * - Link to original source - */ -export const ScrapeWebpageToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { - const url = args.url || "Unknown URL"; - - // Loading state - tool is still running - if (status.type === "running" || status.type === "requires-action") { - return ( -
- -
- ); - } - - // Incomplete/cancelled state - if (status.type === "incomplete") { - if (status.reason === "cancelled") { - return ; - } - if (status.reason === "error") { - return ( - - ); - } - } - - // No result yet - if (!result) { - return ( -
- -
- ); - } - - // Error result from the tool - if (result.error) { - return ; - } - - // Success - render the article card - return ( -
- - - -
- ); -}; - -export { - ScrapeWebpageArgsSchema, - ScrapeWebpageResultSchema, - type ScrapeWebpageArgs, - type ScrapeWebpageResult, -}; From 337bab365082a97bcb21aa326ca2b563af5c45fd Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:57:14 +0530 Subject: [PATCH 07/18] refactor: add placeholder implementations for link_preview, multi_link_preview, and scrape_webpage tools in assistant-message and public-thread components --- surfsense_web/components/assistant-ui/assistant-message.tsx | 3 +++ surfsense_web/components/public-chat/public-thread.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 14fb18bf9..8ac2dcb01 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -82,6 +82,9 @@ const AssistantMessageInner: FC = () => { create_confluence_page: CreateConfluencePageToolUI, update_confluence_page: UpdateConfluencePageToolUI, delete_confluence_page: DeleteConfluencePageToolUI, + link_preview: () => null, + multi_link_preview: () => null, + scrape_webpage: () => null, }, Fallback: ToolFallback, }, diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 9b1fe7e49..0fb34aeb2 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -151,6 +151,9 @@ const PublicAssistantMessage: FC = () => { generate_video_presentation: GenerateVideoPresentationToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, + link_preview: () => null, + multi_link_preview: () => null, + scrape_webpage: () => null, }, Fallback: ToolFallback, }, From c926c3f62e7264486d0d27e91af8953a2309d2a5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:00:55 +0530 Subject: [PATCH 08/18] refactor: remove display_image tool and associated UI components to streamline chat functionality --- .../agents/new_chat/tools/display_image.py | 111 ------------ .../new-chat/[[...chat_id]]/page.tsx | 1 - .../assistant-ui/assistant-message.tsx | 3 +- .../components/public-chat/public-thread.tsx | 3 +- .../components/tool-ui/display-image.tsx | 162 ------------------ surfsense_web/components/tool-ui/index.ts | 7 - 6 files changed, 2 insertions(+), 285 deletions(-) delete mode 100644 surfsense_backend/app/agents/new_chat/tools/display_image.py delete mode 100644 surfsense_web/components/tool-ui/display-image.tsx diff --git a/surfsense_backend/app/agents/new_chat/tools/display_image.py b/surfsense_backend/app/agents/new_chat/tools/display_image.py deleted file mode 100644 index 4424cc0d3..000000000 --- a/surfsense_backend/app/agents/new_chat/tools/display_image.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Display image tool for the SurfSense agent. - -This module provides a tool for displaying images in the chat UI -with metadata like title, description, and source attribution. -""" - -import hashlib -from typing import Any -from urllib.parse import urlparse - -from langchain_core.tools import tool - - -def extract_domain(url: str) -> str: - """Extract the domain from a URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove 'www.' prefix if present - if domain.startswith("www."): - domain = domain[4:] - return domain - except Exception: - return "" - - -def generate_image_id(src: str) -> str: - """Generate a unique ID for an image.""" - hash_val = hashlib.md5(src.encode()).hexdigest()[:12] - return f"image-{hash_val}" - - -def create_display_image_tool(): - """ - Factory function to create the display_image tool. - - Returns: - A configured tool function for displaying images. - """ - - @tool - async def display_image( - src: str, - alt: str = "Image", - title: str | None = None, - description: str | None = None, - ) -> dict[str, Any]: - """ - Display an image in the chat with metadata. - - Use this tool when you want to show an image to the user. - This displays the image with an optional title, description, - and source attribution. - - Common use cases: - - Showing an image from a URL the user mentioned - - Displaying a diagram or chart you're referencing - - Showing example images when explaining concepts - - Args: - src: The URL of the image to display (must be a valid HTTP/HTTPS URL) - alt: Alternative text describing the image (for accessibility) - title: Optional title to display below the image - description: Optional description providing context about the image - - Returns: - A dictionary containing image metadata for the UI to render: - - id: Unique identifier for this image - - assetId: The image URL (for deduplication) - - src: The image URL - - alt: Alt text for accessibility - - title: Image title (if provided) - - description: Image description (if provided) - - domain: Source domain - """ - image_id = generate_image_id(src) - - # Ensure URL has protocol - if not src.startswith(("http://", "https://")): - src = f"https://{src}" - - domain = extract_domain(src) - - # Determine aspect ratio based on image source - # AI-generated images should use "auto" to preserve their native ratio - is_generated = "/image-generations/" in src - if is_generated: - ratio = "auto" - domain = "ai-generated" - elif "unsplash.com" in src or "pexels.com" in src: - ratio = "16:9" - elif ( - "imgur.com" in src or "github.com" in src or "githubusercontent.com" in src - ): - ratio = "auto" - else: - ratio = "auto" - - return { - "id": image_id, - "assetId": src, - "src": src, - "alt": alt, - "title": title, - "description": description, - "domain": domain, - "ratio": ratio, - } - - return display_image 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 3f6893169..a6c01dd40 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 @@ -131,7 +131,6 @@ const TOOLS_WITH_UI = new Set([ "generate_podcast", "generate_report", "generate_video_presentation", - "display_image", "generate_image", "delete_notion_page", "create_notion_page", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 8ac2dcb01..8e1b455a2 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -17,7 +17,6 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CreateConfluencePageToolUI, DeleteConfluencePageToolUI, UpdateConfluencePageToolUI } from "@/components/tool-ui/confluence"; -import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; @@ -56,7 +55,7 @@ const AssistantMessageInner: FC = () => { generate_report: GenerateReportToolUI, generate_podcast: GeneratePodcastToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: DisplayImageToolUI, + display_image: () => null, generate_image: GenerateImageToolUI, save_memory: SaveMemoryToolUI, recall_memory: RecallMemoryToolUI, diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 0fb34aeb2..99221ec2e 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -12,7 +12,6 @@ import { type FC, type ReactNode, useState } from "react"; 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 { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; @@ -149,7 +148,7 @@ const PublicAssistantMessage: FC = () => { generate_podcast: GeneratePodcastToolUI, generate_report: GenerateReportToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: DisplayImageToolUI, + display_image: () => null, generate_image: GenerateImageToolUI, link_preview: () => null, multi_link_preview: () => null, diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx deleted file mode 100644 index 824ce0628..000000000 --- a/surfsense_web/components/tool-ui/display-image.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import { AlertCircleIcon, ImageIcon } from "lucide-react"; -import { z } from "zod"; -import { - Image, - ImageErrorBoundary, - ImageLoading, - parseSerializableImage, -} from "@/components/tool-ui/image"; - -// ============================================================================ -// Zod Schemas -// ============================================================================ - -/** - * Schema for display_image tool arguments - */ -const DisplayImageArgsSchema = z.object({ - src: z.string(), - alt: z.string().nullish(), - title: z.string().nullish(), - description: z.string().nullish(), -}); - -/** - * Schema for display_image tool result - */ -const DisplayImageResultSchema = z.object({ - id: z.string(), - assetId: z.string(), - src: z.string(), - alt: z.string().nullish(), - title: z.string().nullish(), - description: z.string().nullish(), - domain: z.string().nullish(), - ratio: z.string().nullish(), - error: z.string().nullish(), -}); - -// ============================================================================ -// Types -// ============================================================================ - -type DisplayImageArgs = z.infer; -type DisplayImageResult = z.infer; - -/** - * Error state component shown when image display fails - */ -function ImageErrorState({ src, error }: { src: string; error: string }) { - return ( -
-
-
- -
-
-

Failed to display image

-

{src}

-

{error}

-
-
-
- ); -} - -/** - * Cancelled state component - */ -function ImageCancelledState({ src }: { src: string }) { - return ( -
-

- - Image: {src} -

-
- ); -} - -/** - * Parsed Image component with error handling - * Note: Image component has built-in click handling via href/src, - * so no additional responseActions needed. - */ -function ParsedImage({ result }: { result: unknown }) { - const image = parseSerializableImage(result); - - return ; -} - -/** - * Display Image Tool UI Component - * - * This component is registered with assistant-ui to render an image - * when the display_image tool is called by the agent. - * - * It displays images with: - * - Title and description - * - Source attribution - * - Hover overlay effects - * - Click to open full size - */ -export const DisplayImageToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { - const src = args.src || "Unknown"; - - // Loading state - tool is still running - if (status.type === "running" || status.type === "requires-action") { - return ( -
- -
- ); - } - - // Incomplete/cancelled state - if (status.type === "incomplete") { - if (status.reason === "cancelled") { - return ; - } - if (status.reason === "error") { - return ( - - ); - } - } - - // No result yet - if (!result) { - return ( -
- -
- ); - } - - // Error result from the tool - if (result.error) { - return ; - } - - // Success - render the image - return ( -
- - - -
- ); -}; - -export { - DisplayImageArgsSchema, - DisplayImageResultSchema, - type DisplayImageArgs, - type DisplayImageResult, -}; diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index b2978f8f3..dee880aee 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -7,13 +7,6 @@ */ export { Audio } from "./audio"; -export { - type DisplayImageArgs, - DisplayImageArgsSchema, - type DisplayImageResult, - DisplayImageResultSchema, - DisplayImageToolUI, -} from "./display-image"; export { type GenerateImageArgs, GenerateImageArgsSchema, From 9eb8e4c48d16b6f34fd7591ad7094e2c9d5425c0 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:13:54 +0530 Subject: [PATCH 09/18] refactor: reintroduce display_image tool for legacy AI generated images handling --- .../[search_space_id]/new-chat/[[...chat_id]]/page.tsx | 1 + surfsense_web/components/assistant-ui/assistant-message.tsx | 2 +- surfsense_web/components/public-chat/public-thread.tsx | 3 ++- 3 files changed, 4 insertions(+), 2 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 a6c01dd40..3f6893169 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 @@ -131,6 +131,7 @@ const TOOLS_WITH_UI = new Set([ "generate_podcast", "generate_report", "generate_video_presentation", + "display_image", "generate_image", "delete_notion_page", "create_notion_page", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 8e1b455a2..781bc8e3a 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -55,7 +55,7 @@ const AssistantMessageInner: FC = () => { generate_report: GenerateReportToolUI, generate_podcast: GeneratePodcastToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: () => null, + display_image: GenerateImageToolUI, generate_image: GenerateImageToolUI, save_memory: SaveMemoryToolUI, recall_memory: RecallMemoryToolUI, diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 99221ec2e..1510aa4ce 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -79,6 +79,7 @@ const UserAvatar: FC void } if (avatarUrl && !hasError) { return ( + // biome-ignore lint/performance/noImgElement: external OAuth/profile avatar URL {displayName { generate_podcast: GeneratePodcastToolUI, generate_report: GenerateReportToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: () => null, + display_image: GenerateImageToolUI, generate_image: GenerateImageToolUI, link_preview: () => null, multi_link_preview: () => null, From a99791009a33912c5d6a669f2b5134b7571bfe84 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:24:30 +0530 Subject: [PATCH 10/18] refactor: enhance markdown rendering with syntax highlighting and theme support --- .../components/assistant-ui/markdown-text.tsx | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 8c855bf17..8c4cff96f 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -3,13 +3,16 @@ import "@assistant-ui/react-markdown/styles/dot.css"; import { - type CodeHeaderProps, MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock, } from "@assistant-ui/react-markdown"; import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; import { type FC, memo, type ReactNode, useState } from "react"; +import { useTheme } from "next-themes"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import type { CSSProperties } from "react"; import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; @@ -19,6 +22,23 @@ import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-ci import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; +function stripThemeBackgrounds( + theme: Record, +): Record { + const cleaned: Record = {}; + for (const key of Object.keys(theme)) { + const { background, backgroundColor, ...rest } = theme[key] as CSSProperties & { + background?: string; + backgroundColor?: string; + }; + cleaned[key] = rest; + } + return cleaned; +} + +const cleanMaterialDark = stripThemeBackgrounds(materialDark); +const cleanMaterialLight = stripThemeBackgrounds(materialLight); + // Storage for URL citations replaced during preprocess to avoid GFM autolink interference. // Populated in preprocessMarkdown, consumed in parseTextWithCitations. let _pendingUrlCitations = new Map(); @@ -150,7 +170,7 @@ const MarkdownTextImpl = () => { export const MarkdownText = memo(MarkdownTextImpl); -const CodeHeader: FC = ({ language, code }) => { +const InlineCodeHeader: FC<{ language: string; code: string }> = ({ language, code }) => { const { isCopied, copyToClipboard } = useCopyToClipboard(); const onCopy = () => { if (!code || isCopied) return; @@ -158,8 +178,8 @@ const CodeHeader: FC = ({ language, code }) => { }; return ( -
- {language} +
+ {language} {!isCopied && } {isCopied && } @@ -391,25 +411,33 @@ const defaultComponents = memoizeMarkdownComponents({ sup: ({ className, ...props }) => ( a]:text-xs [&>a]:no-underline", className)} {...props} /> ), - pre: ({ className, ...props }) => ( -
-	),
-	code: function Code({ className, ...props }) {
+	pre: ({ children }) => <>{children},
+	code: function Code({ className, children, ...props }) {
 		const isCodeBlock = useIsMarkdownCodeBlock();
+		const { resolvedTheme } = useTheme();
+		if (!isCodeBlock) {
+			return (
+				
+			);
+		}
+		const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
+		const codeString = String(children).replace(/\n$/, "");
+		const syntaxStyle = resolvedTheme === "dark" ? cleanMaterialDark : cleanMaterialLight;
 		return (
-			
+			
+ + + {codeString} + +
); }, strong: ({ className, children, ...props }) => ( @@ -423,5 +451,5 @@ const defaultComponents = memoizeMarkdownComponents({ ), img: ({ src, alt }) => , - CodeHeader, + CodeHeader: () => null, }); From 15a81dbf41325e59e0c83128e2388a11381bdb38 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:03:00 +0530 Subject: [PATCH 11/18] feat: add GenerateImageToolUI component for rendering generated images with error handling and loading states --- .../components/tool-ui/generate-image.tsx | 138 ++++++++++++++++++ .../components/tool-ui/image/index.tsx | 58 ++++---- 2 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 surfsense_web/components/tool-ui/generate-image.tsx diff --git a/surfsense_web/components/tool-ui/generate-image.tsx b/surfsense_web/components/tool-ui/generate-image.tsx new file mode 100644 index 000000000..fd2e9992e --- /dev/null +++ b/surfsense_web/components/tool-ui/generate-image.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { AlertCircleIcon, ImageIcon } from "lucide-react"; +import { z } from "zod"; +import { + Image, + ImageErrorBoundary, + ImageLoading, + parseSerializableImage, +} from "@/components/tool-ui/image"; + +const GenerateImageArgsSchema = z.object({ + prompt: z.string(), + n: z.number().nullish(), +}); + +const GenerateImageResultSchema = z.object({ + id: z.string(), + assetId: z.string(), + src: z.string(), + alt: z.string().nullish(), + title: z.string().nullish(), + description: z.string().nullish(), + domain: z.string().nullish(), + ratio: z.string().nullish(), + generated: z.boolean().nullish(), + prompt: z.string().nullish(), + image_count: z.number().nullish(), + error: z.string().nullish(), +}); + +type GenerateImageArgs = z.infer; +type GenerateImageResult = z.infer; + +function ImageErrorState({ prompt, error }: { prompt: string; error: string }) { + return ( +
+
+
+ +
+
+

Image generation failed

+

{prompt}

+

{error}

+
+
+
+ ); +} + +function ImageCancelledState({ prompt }: { prompt: string }) { + return ( +
+

+ + Generate: {prompt} +

+
+ ); +} + +function ParsedImage({ result }: { result: unknown }) { + const image = parseSerializableImage(result); + return ( + {image.alt} + ); +} + +/** + * Tool UI for generate_image — renders the generated image directly + * from the tool result directly. + */ +export const GenerateImageToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { + const prompt = args.prompt || "Generating image..."; + + if (status.type === "running" || status.type === "requires-action") { + return ( +
+ +
+ ); + } + + if (status.type === "incomplete") { + if (status.reason === "cancelled") { + return ; + } + if (status.reason === "error") { + return ( + + ); + } + } + + if (!result) { + return ( +
+ +
+ ); + } + + if (result.error) { + return ; + } + + return ( +
+ + + +
+ ); +}; + +export { + GenerateImageArgsSchema, + GenerateImageResultSchema, + type GenerateImageArgs, + type GenerateImageResult, +}; diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx index ec04b779f..b8e37b620 100644 --- a/surfsense_web/components/tool-ui/image/index.tsx +++ b/surfsense_web/components/tool-ui/image/index.tsx @@ -6,7 +6,7 @@ import { Component, type ReactNode, useState } from "react"; import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { cn } from "@/lib/utils"; /** @@ -145,14 +145,14 @@ export class ImageErrorBoundary extends Component< render() { if (this.state.hasError) { return ( - -
-
- -

Failed to load image

-
+ +
+
+ +

Failed to load image

- +
+
); } @@ -165,7 +165,7 @@ export class ImageErrorBoundary extends Component< */ export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) { return ( - +
@@ -176,14 +176,11 @@ export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) { /** * Image Loading State */ -export function ImageLoading({ title = "Loading image..." }: { title?: string }) { +export function ImageLoading({ title = "Loading", maxWidth = "512px" }: { title?: string; maxWidth?: string }) { return ( - +
-
- -

{title}

-
+
); @@ -214,8 +211,8 @@ export function Image({ const [isHovered, setIsHovered] = useState(false); const [imageError, setImageError] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); - const displayDomain = domain || source?.label; const isGenerated = domain === "ai-generated"; + const displayDomain = isGenerated ? "AI Generated" : (domain || source?.label); const isAutoRatio = !ratio || ratio === "auto"; const handleClick = () => { @@ -227,7 +224,7 @@ export function Image({ if (imageError) { return ( - +
@@ -242,8 +239,7 @@ export function Image({ - {!imageLoaded && ( -
- -
- )} - {/* eslint-disable-next-line @next/next/no-img-element */} - + +
+ )} + setImageLoaded(true)} onError={() => setImageError(true)} /> @@ -316,11 +316,9 @@ export function Image({ {description && (

{description}

)} - {displayDomain && ( + {displayDomain && !isGenerated && (
- {isGenerated ? ( - - ) : source?.iconUrl ? ( + {source?.iconUrl ? ( Date: Tue, 24 Mar 2026 23:42:12 +0530 Subject: [PATCH 12/18] refactor: integrate custom table components into markdown rendering in chat --- .../components/assistant-ui/markdown-text.tsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 8c4cff96f..651aa0cd5 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -20,6 +20,7 @@ import remarkMath from "remark-math"; import "katex/dist/katex.min.css"; import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; function stripThemeBackgrounds( @@ -370,43 +371,40 @@ const defaultComponents = memoizeMarkdownComponents({
), table: ({ className, ...props }) => ( -
- +
+
), + thead: ({ className, ...props }) => ( + + ), + tbody: ({ className, ...props }) => ( + + ), th: ({ className, children, ...props }) => ( - + ), td: ({ className, children, ...props }) => ( - + ), tr: ({ className, ...props }) => ( - td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg", - className - )} - {...props} - /> + ), sup: ({ className, ...props }) => ( a]:text-xs [&>a]:no-underline", className)} {...props} /> From d0dcb8a98bcba26d9a494ba84adcb8808ee36d50 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:53:51 +0530 Subject: [PATCH 13/18] refactor: simplify video presentation UI components and enhance loading/error states --- .../video-presentation/combined-player.tsx | 2 +- .../generate-video-presentation.tsx | 294 +++++++----------- 2 files changed, 119 insertions(+), 177 deletions(-) diff --git a/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx b/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx index f8e79f677..84147e94e 100644 --- a/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx +++ b/surfsense_web/components/tool-ui/video-presentation/combined-player.tsx @@ -119,7 +119,7 @@ export function CombinedPlayer({ slides }: CombinedPlayerProps) { ); return ( -
+
-
-
-
- -
-
-
-
-

- {title} -

-
- - - Generating video presentation. This may take a few minutes. - -
-
-
-
-
-
-
+
+
+

{title}

+
); @@ -115,20 +90,14 @@ function GeneratingState({ title }: { title: string }) { function ErrorState({ title, error }: { title: string; error: string }) { return ( -
-
-
- -
-
-

- {title} -

-

- Failed to generate video presentation -

-

{error}

-
+
+
+

Video Generation Failed

+
+
+
+

{title}

+

{error}

); @@ -136,20 +105,10 @@ function ErrorState({ title, error }: { title: string; error: string }) { function CompilationLoadingState({ title }: { title: string }) { return ( -
-
-
- -
-
-

- {title} -

-
- - Compiling scenes... -
-
+
+
+

{title}

+
); @@ -170,7 +129,6 @@ function VideoPresentationPlayer({ const [isRendering, setIsRendering] = useState(false); const [renderProgress, setRenderProgress] = useState(null); - const [renderError, setRenderError] = useState(null); const [renderFormat, setRenderFormat] = useState(null); const abortControllerRef = useRef(null); @@ -292,7 +250,6 @@ function VideoPresentationPlayer({ setIsRendering(true); setRenderProgress(0); - setRenderError(null); setRenderFormat(null); const controller = new AbortController(); @@ -363,10 +320,10 @@ function VideoPresentationPlayer({ document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { - if ((err as Error).name === "AbortError") { - // User cancelled - } else { - setRenderError(err instanceof Error ? err.message : "Failed to render video"); + if ((err as Error).name !== "AbortError") { + toast.error("Download Failed", { + description: err instanceof Error ? err.message : "Failed to render video", + }); } } finally { setIsRendering(false); @@ -384,7 +341,6 @@ function VideoPresentationPlayer({ setIsPptxExporting(true); setPptxProgress("Preparing..."); - setRenderError(null); try { const { exportToPptx } = await import("dom-to-pptx"); @@ -437,10 +393,12 @@ function VideoPresentationPlayer({ fileName: "presentation.pptx", }); - roots.forEach((r) => r.unmount()); + for (const r of roots) r.unmount(); document.body.removeChild(offscreen); } catch (err) { - setRenderError(err instanceof Error ? err.message : "Failed to export PPTX"); + toast.error("PPTX Export Failed", { + description: err instanceof Error ? err.message : "Failed to export PPTX", + }); } finally { setIsPptxExporting(false); setPptxProgress(null); @@ -456,97 +414,86 @@ function VideoPresentationPlayer({ } return ( -
- {/* Title bar with actions */} -
-
-
- -
-
-

{title}

-

- {compiledSlides.length} slides · {totalDuration.toFixed(1)}s ·{" "} - {FPS}fps -

-
-
- -
- {isRendering ? ( - <> -
- - - Rendering {renderFormat ?? ""}{" "} - {renderProgress !== null - ? `${Math.round(renderProgress * 100)}%` - : "..."} - -
-
-
-
- - - ) : ( - <> - - - - )} -
+
+ {/* Header */} +
+

{title}

+

+ {compiledSlides.length} slides {totalDuration.toFixed(1)}s {FPS}fps +

- {/* Render error */} - {renderError && ( -
- -
-

Download Failed

-

- {renderError} -

-
-
- )} +
+ + {/* Remotion Player */} +
+ +
+ +
+ + {/* Action buttons */} +
+ {isRendering ? ( + <> +
+ + + Rendering {renderFormat ?? ""}{" "} + {renderProgress !== null + ? `${Math.round(renderProgress * 100)}%` + : "..."} + +
+
+
+
+ + + ) : ( + <> + + + + )} +
- {/* Combined Remotion Player */} -
); } @@ -636,11 +583,13 @@ export const GenerateVideoPresentationToolUI = ({ args, result, status }: ToolCa if (status.type === "incomplete") { if (status.reason === "cancelled") { return ( -
-

- - Presentation generation cancelled -

+
+
+

Presentation Cancelled

+

+ Presentation generation was cancelled +

+
); } @@ -664,19 +613,12 @@ export const GenerateVideoPresentationToolUI = ({ args, result, status }: ToolCa if (result.status === "generating") { return ( -
-
-
- -
-
-

- Presentation already in progress -

-

- Please wait for the current presentation to complete. -

-
+
+
+

Presentation already in progress

+

+ Please wait for the current presentation to complete. +

); From fde8faec7e806c4dd68c47ffccc223b4c5294aeb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:54:04 +0530 Subject: [PATCH 14/18] refactor: update UserAvatar component to use Next.js Image --- surfsense_web/components/assistant-ui/user-message.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 9613b9964..019a72343 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,6 +1,7 @@ import { ActionBarPrimitive, MessagePrimitive, useAuiState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { FileText, Pen } from "lucide-react"; +import Image from "next/image"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -24,18 +25,21 @@ const UserAvatar: FC = ({ displayName, avatarUrl }) => { if (avatarUrl && !hasError) { return ( - {displayName setHasError(true)} + unoptimized /> ); } return ( -
+
{initials}
); From c894185102e3e319ddf3ccaee8f43746b4187a75 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:57:32 +0530 Subject: [PATCH 15/18] refactor: implement error handling for video download and PPTX export in video presentation component --- .../tool-ui/video-presentation/errors.ts | 38 +++++++++++++++++++ .../generate-video-presentation.tsx | 12 +++--- 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 surfsense_web/components/tool-ui/video-presentation/errors.ts diff --git a/surfsense_web/components/tool-ui/video-presentation/errors.ts b/surfsense_web/components/tool-ui/video-presentation/errors.ts new file mode 100644 index 000000000..75d769522 --- /dev/null +++ b/surfsense_web/components/tool-ui/video-presentation/errors.ts @@ -0,0 +1,38 @@ +export function getVideoDownloadErrorToast(err: unknown): { title: string; description: string } { + const msg = err instanceof Error ? err.message.toLowerCase() : ""; + + if (msg.includes("webcodecs") || msg.includes("canrendermediaonweb") || msg.includes("not support")) { + return { + title: "Browser Not Supported", + description: "Video rendering requires Chrome, Edge, or Firefox 130+.", + }; + } + + if (msg.includes("memory") || msg.includes("oom") || msg.includes("allocation")) { + return { + title: "Out of Memory", + description: "The presentation is too large to render. Try closing other tabs.", + }; + } + + return { + title: "Download Failed", + description: "Something went wrong while rendering. Please try again.", + }; +} + +export function getPptxExportErrorToast(err: unknown): { title: string; description: string } { + const msg = err instanceof Error ? err.message.toLowerCase() : ""; + + if (msg.includes("dynamically imported") || msg.includes("failed to fetch") || msg.includes("network")) { + return { + title: "Export Unavailable", + description: "Could not load the export module. Check your network and try again.", + }; + } + + return { + title: "PPTX Export Failed", + description: "Something went wrong while exporting. Please try again.", + }; +} diff --git a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx index 41ad489c0..061e0200a 100644 --- a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx +++ b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx @@ -18,6 +18,7 @@ import { buildSlideWithWatermark, type CompiledSlide, } from "./combined-player"; +import { getVideoDownloadErrorToast, getPptxExportErrorToast } from "./errors"; const GenerateVideoPresentationArgsSchema = z.object({ source_content: z.string(), @@ -68,6 +69,7 @@ type GenerateVideoPresentationArgs = z.infer; type VideoPresentationStatusResponse = z.infer; + function parseStatusResponse(data: unknown): VideoPresentationStatusResponse | null { const result = VideoPresentationStatusResponseSchema.safeParse(data); if (!result.success) { @@ -321,9 +323,8 @@ function VideoPresentationPlayer({ URL.revokeObjectURL(url); } catch (err) { if ((err as Error).name !== "AbortError") { - toast.error("Download Failed", { - description: err instanceof Error ? err.message : "Failed to render video", - }); + const { title, description } = getVideoDownloadErrorToast(err); + toast.error(title, { description }); } } finally { setIsRendering(false); @@ -396,9 +397,8 @@ function VideoPresentationPlayer({ for (const r of roots) r.unmount(); document.body.removeChild(offscreen); } catch (err) { - toast.error("PPTX Export Failed", { - description: err instanceof Error ? err.message : "Failed to export PPTX", - }); + const { title, description } = getPptxExportErrorToast(err); + toast.error(title, { description }); } finally { setIsPptxExporting(false); setPptxProgress(null); From eafc0c38088fb56f2e246dd2d0a66987bae1c21c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:16:52 +0530 Subject: [PATCH 16/18] refactor: enhance UserMessage component with improved layout and action bar functionality --- .../components/assistant-ui/user-message.tsx | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 019a72343..c01e8e486 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,6 +1,6 @@ -import { ActionBarPrimitive, MessagePrimitive, useAuiState } from "@assistant-ui/react"; +import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { FileText, Pen } from "lucide-react"; +import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react"; import Image from "next/image"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; @@ -54,43 +54,39 @@ export const UserMessage: FC = () => { return ( -
-
- {/* Display mentioned documents */} - {mentionedDocs && mentionedDocs.length > 0 && ( -
- {/* Mentioned documents as chips */} - {mentionedDocs?.map((doc) => ( - - - {doc.title} - - ))} -
- )} - {/* Message bubble with action bar positioned relative to it */} -
+
+
+
+ {mentionedDocs && mentionedDocs.length > 0 && ( +
+ {mentionedDocs?.map((doc) => ( + + + {doc.title} + + ))} +
+ )}
-
+
+ {author && ( +
+ +
+ )}
- {/* User avatar - only shown in shared chats */} - {author && ( -
- -
- )}
); @@ -122,13 +118,21 @@ const UserActionBar: FC = () => { return ( - {/* Only allow editing the last user message */} + + + message.isCopied}> + + + !message.isCopied}> + + + + {canEdit && ( - + From c674fb3054e6b658e1f20298687720230d087b98 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:27:24 +0530 Subject: [PATCH 17/18] chore: ran linting --- .../new-chat/[[...chat_id]]/page.tsx | 2 +- .../assistant-ui/assistant-message.tsx | 58 ++- .../components/assistant-ui/image.tsx | 403 ++++++++---------- .../components/assistant-ui/markdown-text.tsx | 29 +- .../assistant-ui/thinking-steps.tsx | 5 +- .../components/assistant-ui/thread.tsx | 20 +- .../components/assistant-ui/tool-fallback.tsx | 20 +- .../components/public-chat/public-thread.tsx | 8 +- surfsense_web/components/tool-ui/audio.tsx | 8 +- .../confluence/create-confluence-page.tsx | 59 +-- .../confluence/delete-confluence-page.tsx | 62 +-- .../confluence/update-confluence-page.tsx | 61 +-- .../components/tool-ui/generate-image.tsx | 6 +- .../components/tool-ui/generate-podcast.tsx | 146 +++---- .../components/tool-ui/generate-report.tsx | 95 +++-- .../tool-ui/google-drive/create-file.tsx | 8 +- .../tool-ui/google-drive/trash-file.tsx | 7 +- .../components/tool-ui/image/index.tsx | 55 ++- surfsense_web/components/tool-ui/index.ts | 2 +- .../tool-ui/jira/create-jira-issue.tsx | 59 +-- .../tool-ui/jira/delete-jira-issue.tsx | 62 +-- .../tool-ui/jira/update-jira-issue.tsx | 61 +-- .../tool-ui/linear/create-linear-issue.tsx | 5 +- .../tool-ui/linear/delete-linear-issue.tsx | 7 +- .../tool-ui/linear/update-linear-issue.tsx | 24 +- .../tool-ui/notion/create-notion-page.tsx | 5 +- .../tool-ui/notion/delete-notion-page.tsx | 7 +- .../tool-ui/notion/update-notion-page.tsx | 5 +- .../components/tool-ui/sandbox-execute.tsx | 56 +-- .../components/tool-ui/user-memory.tsx | 304 ++++++------- .../video-presentation/combined-player.tsx | 13 +- .../tool-ui/video-presentation/errors.ts | 12 +- .../generate-video-presentation.tsx | 173 ++++---- surfsense_web/components/ui/hero-carousel.tsx | 3 +- .../hooks/use-connectors-electric.ts | 10 +- surfsense_web/lib/remotion/compile-check.ts | 30 +- surfsense_web/lib/remotion/dom-to-pptx.d.ts | 2 +- 37 files changed, 972 insertions(+), 920 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 3f6893169..f0de33826 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 @@ -33,8 +33,8 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Thread } from "@/components/assistant-ui/thread"; import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; +import { Thread } from "@/components/assistant-ui/thread"; import { MobileEditorPanel } from "@/components/editor-panel/editor-panel"; import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 781bc8e3a..9fefecb1c 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -16,19 +16,47 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; -import { CreateConfluencePageToolUI, DeleteConfluencePageToolUI, UpdateConfluencePageToolUI } from "@/components/tool-ui/confluence"; +import { + CreateConfluencePageToolUI, + DeleteConfluencePageToolUI, + UpdateConfluencePageToolUI, +} from "@/components/tool-ui/confluence"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; -import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; -import { CreateGmailDraftToolUI, SendGmailEmailToolUI, TrashGmailEmailToolUI, UpdateGmailDraftToolUI } from "@/components/tool-ui/gmail"; -import { CreateCalendarEventToolUI, DeleteCalendarEventToolUI, UpdateCalendarEventToolUI } from "@/components/tool-ui/google-calendar"; -import { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "@/components/tool-ui/google-drive"; -import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, UpdateJiraIssueToolUI } from "@/components/tool-ui/jira"; -import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, UpdateLinearIssueToolUI } from "@/components/tool-ui/linear"; -import { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "@/components/tool-ui/notion"; +import { + CreateGmailDraftToolUI, + SendGmailEmailToolUI, + TrashGmailEmailToolUI, + UpdateGmailDraftToolUI, +} from "@/components/tool-ui/gmail"; +import { + CreateCalendarEventToolUI, + DeleteCalendarEventToolUI, + UpdateCalendarEventToolUI, +} from "@/components/tool-ui/google-calendar"; +import { + CreateGoogleDriveFileToolUI, + DeleteGoogleDriveFileToolUI, +} from "@/components/tool-ui/google-drive"; +import { + CreateJiraIssueToolUI, + DeleteJiraIssueToolUI, + UpdateJiraIssueToolUI, +} from "@/components/tool-ui/jira"; +import { + CreateLinearIssueToolUI, + DeleteLinearIssueToolUI, + UpdateLinearIssueToolUI, +} from "@/components/tool-ui/linear"; +import { + CreateNotionPageToolUI, + DeleteNotionPageToolUI, + UpdateNotionPageToolUI, +} from "@/components/tool-ui/notion"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; +import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; @@ -246,13 +274,13 @@ const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); return ( - - + message.isCopied}> @@ -262,19 +290,19 @@ const AssistantActionBar: FC = () => { - + - {/* Only allow regenerating the last assistant message */} - {isLast && ( + {/* Only allow regenerating the last assistant message */} + {isLast && ( )} - - ); + + ); }; diff --git a/surfsense_web/components/assistant-ui/image.tsx b/surfsense_web/components/assistant-ui/image.tsx index e610d70aa..65059bcdc 100644 --- a/surfsense_web/components/assistant-ui/image.tsx +++ b/surfsense_web/components/assistant-ui/image.tsx @@ -1,255 +1,221 @@ "use client"; -import { - memo, - useState, - useEffect, - useRef, - type PropsWithChildren, -} from "react"; -import { createPortal } from "react-dom"; +import type { ImageMessagePartComponent } from "@assistant-ui/react"; import { cva, type VariantProps } from "class-variance-authority"; import { ImageIcon, ImageOffIcon } from "lucide-react"; -import type { ImageMessagePartComponent } from "@assistant-ui/react"; +import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; -const imageVariants = cva( - "aui-image-root relative overflow-hidden rounded-lg", - { - variants: { - variant: { - outline: "border border-border", - ghost: "", - muted: "bg-muted/50", - }, - size: { - sm: "max-w-64", - default: "max-w-96", - lg: "max-w-[512px]", - full: "w-full", - }, - }, - defaultVariants: { - variant: "outline", - size: "default", - }, - }, -); +const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", { + variants: { + variant: { + outline: "border border-border", + ghost: "", + muted: "bg-muted/50", + }, + size: { + sm: "max-w-64", + default: "max-w-96", + lg: "max-w-[512px]", + full: "w-full", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + }, +}); -export type ImageRootProps = React.ComponentProps<"div"> & - VariantProps; +export type ImageRootProps = React.ComponentProps<"div"> & VariantProps; -function ImageRoot({ - className, - variant, - size, - children, - ...props -}: ImageRootProps) { - return ( -
- {children} -
- ); +function ImageRoot({ className, variant, size, children, ...props }: ImageRootProps) { + return ( +
+ {children} +
+ ); } type ImagePreviewProps = Omit, "children"> & { - containerClassName?: string; + containerClassName?: string; }; function ImagePreview({ - className, - containerClassName, - onLoad, - onError, - alt = "Image content", - src, - ...props + className, + containerClassName, + onLoad, + onError, + alt = "Image content", + src, + ...props }: ImagePreviewProps) { - const imgRef = useRef(null); - const [loadedSrc, setLoadedSrc] = useState(undefined); - const [errorSrc, setErrorSrc] = useState(undefined); + const imgRef = useRef(null); + const [loadedSrc, setLoadedSrc] = useState(undefined); + const [errorSrc, setErrorSrc] = useState(undefined); - const loaded = loadedSrc === src; - const error = errorSrc === src; + const loaded = loadedSrc === src; + const error = errorSrc === src; - useEffect(() => { - if ( - typeof src === "string" && - imgRef.current?.complete && - imgRef.current.naturalWidth > 0 - ) { - setLoadedSrc(src); - } - }, [src]); + useEffect(() => { + if (typeof src === "string" && imgRef.current?.complete && imgRef.current.naturalWidth > 0) { + setLoadedSrc(src); + } + }, [src]); - return ( -
- {!loaded && !error && ( -
- -
- )} - {error ? ( -
- -
- ) : ( - // biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs - {alt} { - if (typeof src === "string") setLoadedSrc(src); - onLoad?.(e); - }} - onError={(e) => { - if (typeof src === "string") setErrorSrc(src); - onError?.(e); - }} - {...props} - /> - )} -
- ); + return ( +
+ {!loaded && !error && ( +
+ +
+ )} + {error ? ( +
+ +
+ ) : ( + // biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs + {alt} { + if (typeof src === "string") setLoadedSrc(src); + onLoad?.(e); + }} + onError={(e) => { + if (typeof src === "string") setErrorSrc(src); + onError?.(e); + }} + {...props} + /> + )} +
+ ); } -function ImageFilename({ - className, - children, - ...props -}: React.ComponentProps<"span">) { - if (!children) return null; +function ImageFilename({ className, children, ...props }: React.ComponentProps<"span">) { + if (!children) return null; - return ( - - {children} - - ); + return ( + + {children} + + ); } type ImageZoomProps = PropsWithChildren<{ - src: string; - alt?: string; + src: string; + alt?: string; }>; function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) { - const [isMounted, setIsMounted] = useState(false); - const [isOpen, setIsOpen] = useState(false); + const [isMounted, setIsMounted] = useState(false); + const [isOpen, setIsOpen] = useState(false); - useEffect(() => { - setIsMounted(true); - }, []); + useEffect(() => { + setIsMounted(true); + }, []); - const handleOpen = () => setIsOpen(true); - const handleClose = () => setIsOpen(false); + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); - useEffect(() => { - if (!isOpen) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsOpen(false); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); - useEffect(() => { - if (!isOpen) return; - const originalOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isOpen]); - return ( - <> - - {isMounted && - isOpen && - createPortal( - , - document.body, - )} - - ); + return ( + <> + + {isMounted && + isOpen && + createPortal( + , + document.body + )} + + ); } const ImageImpl: ImageMessagePartComponent = ({ image, filename }) => { - return ( - - - - - {filename} - - ); + return ( + + + + + {filename} + + ); }; const Image = memo(ImageImpl) as unknown as ImageMessagePartComponent & { - Root: typeof ImageRoot; - Preview: typeof ImagePreview; - Filename: typeof ImageFilename; - Zoom: typeof ImageZoom; + Root: typeof ImageRoot; + Preview: typeof ImagePreview; + Filename: typeof ImageFilename; + Zoom: typeof ImageZoom; }; Image.displayName = "Image"; @@ -258,11 +224,4 @@ Image.Preview = ImagePreview; Image.Filename = ImageFilename; Image.Zoom = ImageZoom; -export { - Image, - ImageRoot, - ImagePreview, - ImageFilename, - ImageZoom, - imageVariants, -}; +export { Image, ImageRoot, ImagePreview, ImageFilename, ImageZoom, imageVariants }; diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 651aa0cd5..3d33463b2 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -8,23 +8,30 @@ import { useIsMarkdownCodeBlock, } from "@assistant-ui/react-markdown"; import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; -import { type FC, memo, type ReactNode, useState } from "react"; import { useTheme } from "next-themes"; +import type { CSSProperties } from "react"; +import { type FC, memo, type ReactNode, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism"; -import type { CSSProperties } from "react"; -import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; +import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import "katex/dist/katex.min.css"; import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { cn } from "@/lib/utils"; function stripThemeBackgrounds( - theme: Record, + theme: Record ): Record { const cleaned: Record = {}; for (const key of Object.keys(theme)) { @@ -261,9 +268,7 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { {alt && alt !== "Image" && (

{alt}

)} - {domain && ( -

{domain}

- )} + {domain &&

{domain}

}
), - tr: ({ className, ...props }) => ( - - ), + tr: ({ className, ...props }) => , sup: ({ className, ...props }) => ( a]:text-xs [&>a]:no-underline", className)} {...props} /> ), @@ -448,6 +451,8 @@ const defaultComponents = memoizeMarkdownComponents({ {processChildrenWithCitations(children)} ), - img: ({ src, alt }) => , + img: ({ src, alt }) => ( + + ), CodeHeader: () => null, }); diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx index cf0c4ce52..900fc7b09 100644 --- a/surfsense_web/components/assistant-ui/thinking-steps.tsx +++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx @@ -126,8 +126,8 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: {step.items && step.items.length > 0 && (
- {step.items.map((item) => ( - + {step.items.map((item) => ( + {item} ))} @@ -169,4 +169,3 @@ export const ThinkingStepsDataUI = makeAssistantDataUI({ name: "thinking-steps", render: ThinkingStepsDataRenderer, }); - diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index f160114ba..89869dc7e 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -101,13 +101,13 @@ export const Thread: FC = () => { const ThreadContent: FC = () => { return ( - - @@ -135,8 +135,8 @@ const ThreadContent: FC = () => { - - ); + + ); }; const ThreadScrollToBottom: FC = () => { @@ -678,8 +678,8 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; return ( -
-
+
+
{!isDesktop ? ( <> @@ -983,13 +983,13 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false )}
- {!hasModelConfigured && ( + {!hasModelConfigured && (
Select a model
)} -
+
!thread.isRunning}> = ({ isBlockedByOtherUser = false
-
- ); +
+ ); }; /** Convert snake_case tool names to human-readable labels */ diff --git a/surfsense_web/components/assistant-ui/tool-fallback.tsx b/surfsense_web/components/assistant-ui/tool-fallback.tsx index d12ffb5d6..89498fbca 100644 --- a/surfsense_web/components/assistant-ui/tool-fallback.tsx +++ b/surfsense_web/components/assistant-ui/tool-fallback.tsx @@ -1,13 +1,11 @@ import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react"; import { useState } from "react"; -import { cn } from "@/lib/utils"; import { getToolIcon } from "@/contracts/enums/toolIcons"; +import { cn } from "@/lib/utils"; function formatToolName(name: string): string { - return name - .replace(/_/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()); + return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } export const ToolFallback: ToolCallMessagePartComponent = ({ @@ -42,7 +40,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({ className={cn( "my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none", isCancelled && "opacity-60", - isError && "border-destructive/20 bg-destructive/5", + isError && "border-destructive/20 bg-destructive/5" )} >
{processChildrenWithCitations(children)} - {processChildrenWithCitations(children)} -