- {SUGGESTIONS.map((suggestion, index) => (
-
-
-
-
-
- ))}
+
+ {/* Greeting positioned above the composer - fixed position */}
+
+
+ {getTimeBasedGreeting(user?.email)}
+
+
+ {/* Composer - top edge fixed, expands downward only */}
+
+
+
);
};
@@ -217,7 +422,7 @@ const Composer: FC = () => {
return (
-
+
{/* -------- Input field w/ refs and handlers -------- */}
{
value={inputValue}
onInput={handleInputOrKeyUp}
onKeyUp={handleInputOrKeyUp}
- placeholder="Send a message..."
+ placeholder="Ask SurfSense"
className="aui-composer-input mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
autoFocus
@@ -269,6 +474,147 @@ const Composer: FC = () => {
);
};
+const ConnectorIndicator: FC = () => {
+ const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
+ const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(false, searchSpaceId ? Number(searchSpaceId) : undefined);
+ const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom);
+ const [isOpen, setIsOpen] = useState(false);
+ const closeTimeoutRef = useRef(null);
+
+ const isLoading = connectorsLoading || documentTypesLoading;
+
+ // Get document types that have documents in the search space
+ const activeDocumentTypes = documentTypeCounts
+ ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
+ : [];
+
+ const hasConnectors = connectors.length > 0;
+ const hasSources = hasConnectors || activeDocumentTypes.length > 0;
+ const totalSourceCount = connectors.length + activeDocumentTypes.length;
+
+ const handleMouseEnter = useCallback(() => {
+ // Clear any pending close timeout
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ closeTimeoutRef.current = null;
+ }
+ setIsOpen(true);
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ // Delay closing by 150ms for better UX
+ closeTimeoutRef.current = setTimeout(() => {
+ setIsOpen(false);
+ }, 150);
+ }, []);
+
+ if (!searchSpaceId) return null;
+
+ return (
+
+
+
+
+
+ {hasSources ? (
+
+
+
+ Connected Sources
+
+
+ {totalSourceCount}
+
+
+
+ {/* Document types from the search space */}
+ {activeDocumentTypes.map(([docType, count]) => (
+
+ {getConnectorIcon(docType, "size-3.5")}
+ {getDocumentTypeLabel(docType)}
+
+ ))}
+ {/* Search source connectors */}
+ {connectors.map((connector) => (
+
+ {getConnectorIcon(connector.connector_type, "size-3.5")}
+ {connector.name}
+
+ ))}
+
+
+
+
+ Manage connectors
+
+
+
+
+ ) : (
+
+
No sources yet
+
+ Add documents or connect data sources to enhance search results.
+
+
+
+ Add Connector
+
+
+ )}
+
+
+ );
+};
+
const ComposerAction: FC = () => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
@@ -281,9 +627,20 @@ const ComposerAction: FC = () => {
})
);
+ // Check if composer text is empty
+ const isComposerEmpty = useAssistantState(({ composer }) => {
+ const text = composer.text?.trim() || "";
+ return text.length === 0;
+ });
+
+ const isSendDisabled = hasProcessingAttachments || isComposerEmpty;
+
return (
-
+
+
+
+
{/* Show processing indicator when attachments are being processed */}
{hasProcessingAttachments && (
@@ -294,19 +651,25 @@ const ComposerAction: FC = () => {
)}
!thread.isRunning}>
-
+
@@ -340,12 +703,25 @@ const MessageError: FC = () => {
);
};
-const AssistantMessage: FC = () => {
+const AssistantMessageInner: FC = () => {
+ const thinkingStepsMap = useContext(ThinkingStepsContext);
+
+ // Get the current message ID to look up thinking steps
+ const messageId = useMessage((m) => m.id);
+ const thinkingSteps = thinkingStepsMap.get(messageId) || [];
+
+ // Check if thread is still running (for stopping the spinner when cancelled)
+ const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+
return (
-
+ <>
+ {/* Show thinking steps BEFORE the text response */}
+ {thinkingSteps.length > 0 && (
+
+
+
+ )}
+
+ >
+ );
+};
+
+const AssistantMessage: FC = () => {
+ return (
+
+
);
};
diff --git a/surfsense_web/components/prompt-kit/chain-of-thought.tsx b/surfsense_web/components/prompt-kit/chain-of-thought.tsx
new file mode 100644
index 000000000..118ffeff7
--- /dev/null
+++ b/surfsense_web/components/prompt-kit/chain-of-thought.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import { cn } from "@/lib/utils"
+import { Brain, ChevronDown, Circle, Loader2, Search, Sparkles, Lightbulb, CheckCircle2 } from "lucide-react"
+import React from "react"
+
+export type ChainOfThoughtItemProps = React.ComponentProps<"div">
+
+export const ChainOfThoughtItem = ({
+ children,
+ className,
+ ...props
+}: ChainOfThoughtItemProps) => (
+
+ {children}
+
+)
+
+export type ChainOfThoughtTriggerProps = React.ComponentProps<
+ typeof CollapsibleTrigger
+> & {
+ leftIcon?: React.ReactNode
+ swapIconOnHover?: boolean
+}
+
+export const ChainOfThoughtTrigger = ({
+ children,
+ className,
+ leftIcon,
+ swapIconOnHover = true,
+ ...props
+}: ChainOfThoughtTriggerProps) => (
+
+
+ {leftIcon ? (
+
+
+ {leftIcon}
+
+ {swapIconOnHover && (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+ {children}
+
+ {!leftIcon && (
+
+ )}
+
+)
+
+export type ChainOfThoughtContentProps = React.ComponentProps<
+ typeof CollapsibleContent
+>
+
+export const ChainOfThoughtContent = ({
+ children,
+ className,
+ ...props
+}: ChainOfThoughtContentProps) => {
+ return (
+
+
+
+ )
+}
+
+export type ChainOfThoughtProps = {
+ children: React.ReactNode
+ className?: string
+}
+
+export function ChainOfThought({ children, className }: ChainOfThoughtProps) {
+ const childrenArray = React.Children.toArray(children)
+
+ return (
+
+ {childrenArray.map((child, index) => (
+
+ {React.isValidElement(child) &&
+ React.cloneElement(
+ child as React.ReactElement,
+ {
+ isLast: index === childrenArray.length - 1,
+ }
+ )}
+
+ ))}
+
+ )
+}
+
+export type ChainOfThoughtStepProps = {
+ children: React.ReactNode
+ className?: string
+ isLast?: boolean
+}
+
+export const ChainOfThoughtStep = ({
+ children,
+ className,
+ isLast = false,
+ ...props
+}: ChainOfThoughtStepProps & React.ComponentProps) => {
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/surfsense_web/components/tool-ui/article/index.tsx b/surfsense_web/components/tool-ui/article/index.tsx
new file mode 100644
index 000000000..62068d117
--- /dev/null
+++ b/surfsense_web/components/tool-ui/article/index.tsx
@@ -0,0 +1,425 @@
+"use client";
+
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import {
+ AlertCircleIcon,
+ BookOpenIcon,
+ CalendarIcon,
+ ExternalLinkIcon,
+ FileTextIcon,
+ UserIcon,
+} from "lucide-react";
+import { Component, type ReactNode, useCallback } from "react";
+import { z } from "zod";
+
+/**
+ * Zod schema for serializable article data (from backend)
+ */
+const SerializableArticleSchema = z.object({
+ id: z.string().default("article-unknown"),
+ assetId: z.string().optional(),
+ kind: z.literal("article").optional(),
+ title: z.string().default("Untitled Article"),
+ description: z.string().optional(),
+ content: z.string().optional(),
+ href: z.string().url().optional(),
+ domain: z.string().optional(),
+ author: z.string().optional(),
+ date: z.string().optional(),
+ word_count: z.number().optional(),
+ wordCount: z.number().optional(),
+ was_truncated: z.boolean().optional(),
+ wasTruncated: z.boolean().optional(),
+ error: z.string().optional(),
+});
+
+/**
+ * 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`;
+}
+
+/**
+ * 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 */}
+
+
+ {/* Icon */}
+
+
+
+
+ {/* 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"}
+
+
+
+ )}
+
+
+
+ {/* External link indicator */}
+ {href && (
+
+
+
+ )}
+
+
+ {/* 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<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ 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/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx
new file mode 100644
index 000000000..ff8baac9a
--- /dev/null
+++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx
@@ -0,0 +1,317 @@
+"use client";
+
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react";
+import { useMemo, useState, useEffect, useRef } from "react";
+import { z } from "zod";
+import {
+ ChainOfThought,
+ ChainOfThoughtContent,
+ ChainOfThoughtItem,
+ ChainOfThoughtStep,
+ ChainOfThoughtTrigger,
+} from "@/components/prompt-kit/chain-of-thought";
+import { cn } from "@/lib/utils";
+
+/**
+ * Zod schemas for runtime validation
+ */
+const ThinkingStepSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ items: z.array(z.string()).default([]),
+ status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
+});
+
+const DeepAgentThinkingArgsSchema = z.object({
+ query: z.string().optional(),
+ context: z.string().optional(),
+});
+
+const DeepAgentThinkingResultSchema = z.object({
+ steps: z.array(ThinkingStepSchema).optional(),
+ status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(),
+ summary: z.string().optional(),
+});
+
+/**
+ * Types derived from Zod schemas
+ */
+type ThinkingStep = z.infer;
+type DeepAgentThinkingArgs = z.infer;
+type DeepAgentThinkingResult = z.infer;
+
+/**
+ * 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 a fallback step
+ return {
+ id: "unknown",
+ title: "Processing...",
+ items: [],
+ status: "pending",
+ };
+ }
+ 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;
+}
+
+/**
+ * Get icon based on step status and type
+ */
+function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
+ // Check for specific step types based on title keywords
+ const titleLower = title.toLowerCase();
+
+ if (status === "in_progress") {
+ return ;
+ }
+
+ if (status === "completed") {
+ return ;
+ }
+
+ // Default icons based on step type
+ if (titleLower.includes("search") || titleLower.includes("knowledge")) {
+ return ;
+ }
+
+ if (titleLower.includes("analy") || titleLower.includes("understand")) {
+ return ;
+ }
+
+ return ;
+}
+
+/**
+ * Component to display a single thinking step with controlled open state
+ */
+function ThinkingStepDisplay({
+ step,
+ isOpen,
+ onToggle
+}: {
+ step: ThinkingStep;
+ isOpen: boolean;
+ onToggle: () => void;
+}) {
+ const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]);
+
+ return (
+
+
+ {step.title}
+
+
+ {step.items.map((item, index) => (
+
+ {item}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Loading state with animated thinking indicator
+ */
+function ThinkingLoadingState({ status }: { status?: string }) {
+ const statusText = useMemo(() => {
+ switch (status) {
+ case "searching":
+ return "Searching knowledge base...";
+ case "synthesizing":
+ return "Synthesizing response...";
+ case "thinking":
+ default:
+ return "Thinking...";
+ }
+ }, [status]);
+
+ return (
+
+
+
+
+
+
+
+
+
{statusText}
+
+ );
+}
+
+/**
+ * Smart chain of thought renderer with state management
+ */
+function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) {
+ // Track which steps the user has manually toggled
+ const [manualOverrides, setManualOverrides] = useState>({});
+ // Track previous step statuses to detect changes
+ const prevStatusesRef = useRef>({});
+
+ // Check if any step is currently in progress
+ const hasInProgressStep = steps.some(step => step.status === "in_progress");
+
+ // Find the last completed step index
+ const lastCompletedIndex = steps
+ .map((s, i) => s.status === "completed" ? i : -1)
+ .filter(i => i !== -1)
+ .pop();
+
+ // Clear manual overrides when a step's status changes
+ useEffect(() => {
+ const currentStatuses: Record = {};
+ steps.forEach(step => {
+ currentStatuses[step.id] = step.status;
+ // If status changed, clear any manual override for this step
+ if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
+ setManualOverrides(prev => {
+ const next = { ...prev };
+ delete next[step.id];
+ return next;
+ });
+ }
+ });
+ prevStatusesRef.current = currentStatuses;
+ }, [steps]);
+
+ const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
+ // If user has manually toggled, respect that
+ if (manualOverrides[step.id] !== undefined) {
+ return manualOverrides[step.id];
+ }
+ // Auto behavior: open if in progress
+ if (step.status === "in_progress") {
+ return true;
+ }
+ // Auto behavior: keep last completed step open if no in-progress step
+ if (!hasInProgressStep && index === lastCompletedIndex) {
+ return true;
+ }
+ // Default: collapsed
+ return false;
+ };
+
+ const handleToggle = (stepId: string, currentOpen: boolean) => {
+ setManualOverrides(prev => ({
+ ...prev,
+ [stepId]: !currentOpen,
+ }));
+ };
+
+ return (
+
+ {steps.map((step, index) => {
+ const isOpen = getStepOpenState(step, index);
+ 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 = makeAssistantToolUI<
+ DeepAgentThinkingArgs,
+ DeepAgentThinkingResult
+>({
+ toolName: "deepagent_thinking",
+ render: function DeepAgentThinkingUI({ result, status }) {
+ // 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 (
+
+
+
+ );
+ },
+});
+
+/**
+ * Inline Thinking Display Component
+ *
+ * A simpler version that can be used inline with the message content
+ * for displaying reasoning without the full tool UI infrastructure.
+ */
+export function InlineThinkingDisplay({
+ steps,
+ isStreaming = false,
+ className,
+}: {
+ steps: ThinkingStep[];
+ isStreaming?: boolean;
+ className?: string;
+}) {
+ if (steps.length === 0 && !isStreaming) {
+ return null;
+ }
+
+ return (
+
+ {isStreaming && steps.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult };
+
diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx
new file mode 100644
index 000000000..a9c87b29b
--- /dev/null
+++ b/surfsense_web/components/tool-ui/display-image.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { AlertCircleIcon, ImageIcon } from "lucide-react";
+import {
+ Image,
+ ImageErrorBoundary,
+ ImageLoading,
+ parseSerializableImage,
+} from "@/components/tool-ui/image";
+
+/**
+ * Type definitions for the display_image tool
+ */
+interface DisplayImageArgs {
+ src: string;
+ alt?: string;
+ title?: string;
+ description?: string;
+}
+
+interface DisplayImageResult {
+ id: string;
+ assetId: string;
+ src: string;
+ alt: string;
+ title?: string;
+ description?: string;
+ domain?: string;
+ ratio?: string;
+ error?: string;
+}
+
+/**
+ * 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 (
+
+ );
+}
+
+/**
+ * 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 = makeAssistantToolUI<
+ DisplayImageArgs,
+ DisplayImageResult
+>({
+ toolName: "display_image",
+ render: function DisplayImageUI({ args, result, status }) {
+ 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 type { DisplayImageArgs, DisplayImageResult };
+
diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx
index 669b11a57..862eab144 100644
--- a/surfsense_web/components/tool-ui/generate-podcast.tsx
+++ b/surfsense_web/components/tool-ui/generate-podcast.tsx
@@ -3,37 +3,79 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
+import { z } from "zod";
import { Audio } from "@/components/tool-ui/audio";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
/**
- * Type definitions for the generate_podcast tool
+ * Zod schemas for runtime validation
*/
-interface GeneratePodcastArgs {
- source_content: string;
- podcast_title?: string;
- user_prompt?: string;
+const GeneratePodcastArgsSchema = z.object({
+ source_content: z.string(),
+ podcast_title: z.string().optional(),
+ user_prompt: z.string().optional(),
+});
+
+const GeneratePodcastResultSchema = z.object({
+ status: z.enum(["processing", "already_generating", "success", "error"]),
+ task_id: z.string().optional(),
+ podcast_id: z.number().optional(),
+ title: z.string().optional(),
+ transcript_entries: z.number().optional(),
+ message: z.string().optional(),
+ error: z.string().optional(),
+});
+
+const TaskStatusResponseSchema = z.object({
+ status: z.enum(["processing", "success", "error"]),
+ podcast_id: z.number().optional(),
+ title: z.string().optional(),
+ transcript_entries: z.number().optional(),
+ state: z.string().optional(),
+ error: z.string().optional(),
+});
+
+const PodcastTranscriptEntrySchema = z.object({
+ speaker_id: z.number(),
+ dialog: z.string(),
+});
+
+const PodcastDetailsSchema = z.object({
+ podcast_transcript: z.array(PodcastTranscriptEntrySchema).optional(),
+});
+
+/**
+ * Types derived from Zod schemas
+ */
+type GeneratePodcastArgs = z.infer;
+type GeneratePodcastResult = z.infer;
+type TaskStatusResponse = z.infer;
+type PodcastTranscriptEntry = z.infer;
+
+/**
+ * Parse and validate task status response
+ */
+function parseTaskStatusResponse(data: unknown): TaskStatusResponse {
+ const result = TaskStatusResponseSchema.safeParse(data);
+ if (!result.success) {
+ console.warn("Invalid task status response:", result.error.issues);
+ return { status: "error", error: "Invalid response from server" };
+ }
+ return result.data;
}
-interface GeneratePodcastResult {
- status: "processing" | "already_generating" | "success" | "error";
- task_id?: string;
- podcast_id?: number;
- title?: string;
- transcript_entries?: number;
- message?: string;
- error?: string;
-}
-
-interface TaskStatusResponse {
- status: "processing" | "success" | "error";
- podcast_id?: number;
- title?: string;
- transcript_entries?: number;
- state?: string;
- error?: string;
+/**
+ * Parse and validate podcast details
+ */
+function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTranscriptEntry[] } {
+ const result = PodcastDetailsSchema.safeParse(data);
+ if (!result.success) {
+ console.warn("Invalid podcast details:", result.error.issues);
+ return {};
+ }
+ return result.data;
}
/**
@@ -112,14 +154,6 @@ function AudioLoadingState({ title }: { title: string }) {
/**
* Podcast Player Component - Fetches audio and transcript with authentication
*/
-/**
- * Transcript entry type for podcast transcripts
- */
-interface PodcastTranscriptEntry {
- speaker_id: number;
- dialog: string;
-}
-
function PodcastPlayer({
podcastId,
title,
@@ -163,12 +197,12 @@ function PodcastPlayer({
try {
// Fetch audio blob and podcast details in parallel
- const [audioResponse, podcastDetails] = await Promise.all([
+ const [audioResponse, rawPodcastDetails] = await Promise.all([
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
{ method: "GET", signal: controller.signal }
),
- baseApiService.get<{ podcast_transcript?: PodcastTranscriptEntry[] }>(
+ baseApiService.get(
`/api/v1/podcasts/${podcastId}`
),
]);
@@ -184,8 +218,9 @@ function PodcastPlayer({
objectUrlRef.current = objectUrl;
setAudioSrc(objectUrl);
- // Set transcript from podcast details
- if (podcastDetails?.podcast_transcript) {
+ // Parse and validate podcast details, then set transcript
+ const podcastDetails = parsePodcastDetails(rawPodcastDetails);
+ if (podcastDetails.podcast_transcript) {
setTranscript(podcastDetails.podcast_transcript);
}
} finally {
@@ -268,9 +303,10 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string })
useEffect(() => {
const pollStatus = async () => {
try {
- const response = await baseApiService.get(
+ const rawResponse = await baseApiService.get(
`/api/v1/podcasts/task/${taskId}/status`
);
+ const response = parseTaskStatusResponse(rawResponse);
setTaskStatus(response);
// Stop polling if task is complete or errored
diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx
new file mode 100644
index 000000000..c9ac72d2f
--- /dev/null
+++ b/surfsense_web/components/tool-ui/image/index.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react";
+import NextImage from "next/image";
+import { Component, type ReactNode, useState } from "react";
+import { z } from "zod";
+import { Badge } from "@/components/ui/badge";
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+/**
+ * Zod schemas for runtime validation
+ */
+const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "auto"]);
+const ImageFitSchema = z.enum(["cover", "contain"]);
+
+const ImageSourceSchema = z.object({
+ label: z.string(),
+ iconUrl: z.string().optional(),
+ url: z.string().optional(),
+});
+
+const SerializableImageSchema = z.object({
+ id: z.string(),
+ assetId: z.string(),
+ src: z.string(),
+ alt: z.string(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ href: z.string().optional(),
+ domain: z.string().optional(),
+ ratio: AspectRatioSchema.optional(),
+ source: ImageSourceSchema.optional(),
+});
+
+/**
+ * Types derived from Zod schemas
+ */
+type AspectRatio = z.infer;
+type ImageFit = z.infer;
+type ImageSource = z.infer;
+export type SerializableImage = z.infer;
+
+/**
+ * Props for the Image component
+ */
+export interface ImageProps {
+ id: string;
+ assetId: string;
+ src: string;
+ alt: string;
+ title?: string;
+ description?: string;
+ href?: string;
+ domain?: string;
+ ratio?: AspectRatio;
+ fit?: ImageFit;
+ source?: ImageSource;
+ maxWidth?: string;
+ className?: string;
+}
+
+/**
+ * Parse and validate serializable image from tool result
+ */
+export function parseSerializableImage(result: unknown): SerializableImage {
+ const parsed = SerializableImageSchema.safeParse(result);
+
+ if (!parsed.success) {
+ console.warn("Invalid image data:", parsed.error.issues);
+ // Try to extract basic info for error display
+ const obj = (result && typeof result === "object" ? result : {}) as Record;
+ throw new Error(`Invalid image: ${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 "auto":
+ default:
+ return "aspect-[4/3]";
+ }
+}
+
+/**
+ * Error boundary for Image component
+ */
+interface ImageErrorBoundaryState {
+ hasError: boolean;
+ error?: Error;
+}
+
+export class ImageErrorBoundary extends Component<
+ { children: ReactNode },
+ ImageErrorBoundaryState
+> {
+ constructor(props: { children: ReactNode }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): ImageErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
+
Failed to load image
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+/**
+ * Loading skeleton for Image
+ */
+export function ImageSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * Image Loading State
+ */
+export function ImageLoading({ title = "Loading image..." }: { title?: string }) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Image Component
+ *
+ * Display images with metadata and attribution.
+ * Features hover overlay with title and source attribution.
+ */
+export function Image({
+ id,
+ src,
+ alt,
+ title,
+ description,
+ href,
+ domain,
+ ratio = "4:3",
+ fit = "cover",
+ source,
+ maxWidth = "420px",
+ className,
+}: ImageProps) {
+ const [isHovered, setIsHovered] = useState(false);
+ const [imageError, setImageError] = useState(false);
+ const aspectRatioClass = getAspectRatioClass(ratio);
+ const displayDomain = domain || source?.label;
+
+ const handleClick = () => {
+ const targetUrl = href || source?.url || src;
+ if (targetUrl) {
+ window.open(targetUrl, "_blank", "noopener,noreferrer");
+ }
+ };
+
+ if (imageError) {
+ return (
+
+
+
+
+
Image not available
+
+
+
+ );
+ }
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleClick();
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+ {/* Image */}
+
setImageError(true)}
+ />
+
+ {/* Hover overlay - appears on hover */}
+
+ {/* Content at bottom */}
+
+ {/* Title */}
+ {title && (
+
+ {title}
+
+ )}
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Source attribution */}
+ {displayDomain && (
+
+ {source?.iconUrl ? (
+
+ ) : (
+
+ )}
+ {displayDomain}
+
+ )}
+
+
+
+ {/* Always visible domain badge (bottom right, shown when NOT hovered) */}
+ {displayDomain && !isHovered && (
+
+
+ {displayDomain}
+
+
+ )}
+
+
+ );
+}
diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts
index 6125f625f..163d279a9 100644
--- a/surfsense_web/components/tool-ui/index.ts
+++ b/surfsense_web/components/tool-ui/index.ts
@@ -8,3 +8,55 @@
export { Audio } from "./audio";
export { GeneratePodcastToolUI } from "./generate-podcast";
+export {
+ DeepAgentThinkingToolUI,
+ InlineThinkingDisplay,
+ type ThinkingStep,
+ type DeepAgentThinkingArgs,
+ type DeepAgentThinkingResult,
+} from "./deepagent-thinking";
+export {
+ LinkPreviewToolUI,
+ MultiLinkPreviewToolUI,
+ type LinkPreviewArgs,
+ type LinkPreviewResult,
+ type MultiLinkPreviewArgs,
+ type MultiLinkPreviewResult,
+} from "./link-preview";
+export {
+ MediaCard,
+ MediaCardErrorBoundary,
+ MediaCardLoading,
+ MediaCardSkeleton,
+ parseSerializableMediaCard,
+ type MediaCardProps,
+ type SerializableMediaCard,
+} from "./media-card";
+export {
+ Image,
+ ImageErrorBoundary,
+ ImageLoading,
+ ImageSkeleton,
+ parseSerializableImage,
+ type ImageProps,
+ type SerializableImage,
+} from "./image";
+export {
+ DisplayImageToolUI,
+ type DisplayImageArgs,
+ type DisplayImageResult,
+} from "./display-image";
+export {
+ Article,
+ ArticleErrorBoundary,
+ ArticleLoading,
+ ArticleSkeleton,
+ parseSerializableArticle,
+ type ArticleProps,
+ type SerializableArticle,
+} from "./article";
+export {
+ ScrapeWebpageToolUI,
+ type ScrapeWebpageArgs,
+ type ScrapeWebpageResult,
+} from "./scrape-webpage";
diff --git a/surfsense_web/components/tool-ui/link-preview.tsx b/surfsense_web/components/tool-ui/link-preview.tsx
new file mode 100644
index 000000000..c97b6820b
--- /dev/null
+++ b/surfsense_web/components/tool-ui/link-preview.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react";
+import {
+ MediaCard,
+ MediaCardErrorBoundary,
+ MediaCardLoading,
+ parseSerializableMediaCard,
+ type SerializableMediaCard,
+} from "@/components/tool-ui/media-card";
+
+/**
+ * Type definitions for the link_preview tool
+ */
+interface LinkPreviewArgs {
+ url: string;
+ title?: string;
+}
+
+interface LinkPreviewResult {
+ id: string;
+ assetId: string;
+ kind: "link";
+ href: string;
+ title: string;
+ description?: string;
+ thumb?: string;
+ domain?: string;
+ error?: string;
+}
+
+/**
+ * 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 (
+
+ );
+}
+
+/**
+ * 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 = makeAssistantToolUI<
+ LinkPreviewArgs,
+ LinkPreviewResult
+>({
+ toolName: "link_preview",
+ render: function LinkPreviewUI({ args, result, status }) {
+ 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 (
+
+ );
+ },
+});
+
+/**
+ * Multiple Link Previews Tool UI Component
+ *
+ * This component handles cases where multiple links need to be previewed.
+ * It renders a grid of link preview cards.
+ */
+interface MultiLinkPreviewArgs {
+ urls: string[];
+}
+
+interface MultiLinkPreviewResult {
+ previews: LinkPreviewResult[];
+ errors?: { url: string; error: string }[];
+}
+
+export const MultiLinkPreviewToolUI = makeAssistantToolUI<
+ MultiLinkPreviewArgs,
+ MultiLinkPreviewResult
+>({
+ toolName: "multi_link_preview",
+ render: function MultiLinkPreviewUI({ args, result, status }) {
+ 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 type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
+
diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx
new file mode 100644
index 000000000..dc3b9b59a
--- /dev/null
+++ b/surfsense_web/components/tool-ui/media-card/index.tsx
@@ -0,0 +1,356 @@
+"use client";
+
+import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } 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 { 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", "21:9", "auto"]);
+const MediaCardKindSchema = z.enum(["link", "image", "video", "audio"]);
+
+const ResponseActionSchema = z.object({
+ id: z.string(),
+ label: z.string(),
+ variant: z.enum(["default", "secondary", "outline", "destructive", "ghost"]).optional(),
+ confirmLabel: z.string().optional(),
+});
+
+const SerializableMediaCardSchema = z.object({
+ id: z.string(),
+ assetId: z.string(),
+ kind: MediaCardKindSchema,
+ href: z.string().optional(),
+ src: z.string().optional(),
+ title: z.string(),
+ description: z.string().optional(),
+ thumb: z.string().optional(),
+ ratio: AspectRatioSchema.optional(),
+ domain: z.string().optional(),
+});
+
+/**
+ * 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 "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 && (
+
+
{
+ // 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 && (
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {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/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx
new file mode 100644
index 000000000..c9ced3d80
--- /dev/null
+++ b/surfsense_web/components/tool-ui/scrape-webpage.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { makeAssistantToolUI } from "@assistant-ui/react";
+import { AlertCircleIcon, FileTextIcon } from "lucide-react";
+import {
+ Article,
+ ArticleErrorBoundary,
+ ArticleLoading,
+ parseSerializableArticle,
+} from "@/components/tool-ui/article";
+
+/**
+ * Type definitions for the scrape_webpage tool
+ */
+interface ScrapeWebpageArgs {
+ url: string;
+ max_length?: number;
+}
+
+interface ScrapeWebpageResult {
+ id: string;
+ assetId: string;
+ kind: "article";
+ href: string;
+ title: string;
+ description?: string;
+ content?: string;
+ domain?: string;
+ author?: string;
+ date?: string;
+ word_count?: number;
+ was_truncated?: boolean;
+ crawler_type?: string;
+ error?: string;
+}
+
+/**
+ * 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 article = parseSerializableArticle(result);
+
+ return (
+ {
+ if (id === "open" && article.href) {
+ window.open(article.href, "_blank", "noopener,noreferrer");
+ }
+ }}
+ />
+ );
+}
+
+/**
+ * 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 = makeAssistantToolUI<
+ ScrapeWebpageArgs,
+ ScrapeWebpageResult
+>({
+ toolName: "scrape_webpage",
+ render: function ScrapeWebpageUI({ args, result, status }) {
+ 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 type { ScrapeWebpageArgs, ScrapeWebpageResult };
+
diff --git a/surfsense_web/components/ui/collapsible.tsx b/surfsense_web/components/ui/collapsible.tsx
index 442972d7c..ae9fad04a 100644
--- a/surfsense_web/components/ui/collapsible.tsx
+++ b/surfsense_web/components/ui/collapsible.tsx
@@ -1,21 +1,33 @@
-"use client";
+"use client"
-import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
-function Collapsible({ ...props }: React.ComponentProps) {
- return ;
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
}
function CollapsibleTrigger({
- ...props
+ ...props
}: React.ComponentProps) {
- return ;
+ return (
+
+ )
}
function CollapsibleContent({
- ...props
+ ...props
}: React.ComponentProps) {
- return ;
+ return (
+
+ )
}
-export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }