diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index c1ca089d0..9015f7e51 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -1075,6 +1075,33 @@ async def _stream_agent_events(
"thread_id": thread_id_str,
},
)
+ elif tool_name == "web_search":
+ xml = tool_output.get("result", str(tool_output)) if isinstance(tool_output, dict) else str(tool_output)
+ citations: dict[str, dict[str, str]] = {}
+ for m in re.finditer(
+ r"
\s*",
+ xml,
+ ):
+ title, url = m.group(1).strip(), m.group(2).strip()
+ if url.startswith("http") and url not in citations:
+ citations[url] = {"title": title}
+ for m in re.finditer(
+ r"",
+ xml,
+ ):
+ chunk_url, content = m.group(1).strip(), m.group(2).strip()
+ if (
+ chunk_url.startswith("http")
+ and chunk_url in citations
+ and content
+ ):
+ citations[chunk_url]["snippet"] = (
+ content[:200] + "…" if len(content) > 200 else content
+ )
+ yield streaming_service.format_tool_output_available(
+ tool_call_id,
+ {"status": "completed", "citations": citations},
+ )
else:
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 8fdd9eecb..8c7b16c72 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
@@ -132,6 +132,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set([
+ "web_search",
"generate_podcast",
"generate_report",
"generate_video_presentation",
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index abd70e3f4..2223ff5dd 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -12,6 +12,7 @@ import type { FC } from "react";
import { useCallback, 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 { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
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";
@@ -78,7 +79,7 @@ export const MessageError: FC = () => {
const AssistantMessageInner: FC = () => {
return (
- <>
+
{
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
- link_preview: () => null,
- multi_link_preview: () => null,
- scrape_webpage: () => null,
+ web_search: () => null,
+ link_preview: () => null,
+ multi_link_preview: () => null,
+ scrape_webpage: () => null,
},
Fallback: ToolFallback,
},
@@ -130,7 +132,7 @@ const AssistantMessageInner: FC = () => {
- >
+
);
};
diff --git a/surfsense_web/components/assistant-ui/citation-metadata-context.tsx b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
new file mode 100644
index 000000000..7502b6951
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useAuiState } from "@assistant-ui/react";
+import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
+
+export interface CitationMeta {
+ title: string;
+ snippet?: string;
+}
+
+type CitationMetadataMap = ReadonlyMap;
+
+const CitationMetadataContext = createContext(new Map());
+
+interface ToolCallResult {
+ status?: string;
+ citations?: Record;
+}
+
+interface MessageContent {
+ type: string;
+ toolName?: string;
+ result?: unknown;
+}
+
+export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
+ const content = useAuiState(({ message }) => (message as { content?: MessageContent[] })?.content);
+
+ const metadataMap = useMemo(() => {
+ if (!content || !Array.isArray(content)) return new Map();
+
+ const merged = new Map();
+
+ for (const part of content) {
+ if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
+ continue;
+ }
+
+ const result = part.result as ToolCallResult;
+ const citations = result.citations;
+ if (!citations || typeof citations !== "object") continue;
+
+ for (const [url, meta] of Object.entries(citations)) {
+ if (url.startsWith("http") && meta.title && !merged.has(url)) {
+ merged.set(url, { title: meta.title, snippet: meta.snippet });
+ }
+ }
+ }
+
+ return merged;
+ }, [content]);
+
+ return (
+ {children}
+ );
+};
+
+export function useCitationMetadata(url: string): CitationMeta | undefined {
+ const map = useContext(CitationMetadataContext);
+ return map.get(url);
+}
diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx
index 1c9fa6ba4..52c679c23 100644
--- a/surfsense_web/components/assistant-ui/inline-citation.tsx
+++ b/surfsense_web/components/assistant-ui/inline-citation.tsx
@@ -1,9 +1,10 @@
"use client";
-import { ExternalLink } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
+import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
+import { Citation } from "@/components/tool-ui/citation";
interface InlineCitationProps {
chunkId: number;
@@ -55,21 +56,23 @@ interface UrlCitationProps {
/**
* Inline citation for live web search results (URL-based chunk IDs).
- * Renders a clickable badge showing the source domain that opens the URL in a new tab.
+ * Renders a compact chip with favicon + domain and a hover popover showing the
+ * page title and snippet (extracted deterministically from web_search tool results).
*/
export const UrlCitation: FC = ({ url }) => {
const domain = extractDomain(url);
+ const meta = useCitationMetadata(url);
return (
-
-
- {domain}
-
+ title={meta?.title || domain}
+ snippet={meta?.snippet}
+ domain={domain}
+ favicon={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
+ variant="inline"
+ type="webpage"
+ />
);
};
diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx
index bee0496f6..837350a84 100644
--- a/surfsense_web/components/public-chat/public-thread.tsx
+++ b/surfsense_web/components/public-chat/public-thread.tsx
@@ -10,6 +10,7 @@ import {
import { CheckIcon, CopyIcon } from "lucide-react";
import Image from "next/image";
import { type FC, type ReactNode, useState } from "react";
+import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
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";
@@ -142,30 +143,33 @@ const PublicAssistantMessage: FC = () => {
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
-
-
+
+ null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
+ },
+ Fallback: ToolFallback,
},
- Fallback: ToolFallback,
- },
- }}
- />
-
+ }}
+ />
+
-
+
+
);
};
diff --git a/surfsense_web/components/tool-ui/citation/schema.ts b/surfsense_web/components/tool-ui/citation/schema.ts
index ffe8c9ce5..d6db58018 100644
--- a/surfsense_web/components/tool-ui/citation/schema.ts
+++ b/surfsense_web/components/tool-ui/citation/schema.ts
@@ -1,5 +1,4 @@
import { z } from "zod";
-import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
@@ -37,16 +36,3 @@ export const SerializableCitationSchema = z.object({
});
export type SerializableCitation = z.infer;
-
-const SerializableCitationSchemaContract = defineToolUiContract(
- "Citation",
- SerializableCitationSchema,
-);
-
-export const parseSerializableCitation: (
- input: unknown,
-) => SerializableCitation = SerializableCitationSchemaContract.parse;
-
-export const safeParseSerializableCitation: (
- input: unknown,
-) => SerializableCitation | null = SerializableCitationSchemaContract.safeParse;
diff --git a/surfsense_web/components/tool-ui/shared/contract.ts b/surfsense_web/components/tool-ui/shared/contract.ts
deleted file mode 100644
index 82bc05f18..000000000
--- a/surfsense_web/components/tool-ui/shared/contract.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { z } from "zod";
-import { parseWithSchema, safeParseWithSchema } from "./parse";
-
-export interface ToolUiContract {
- schema: z.ZodType;
- parse: (input: unknown) => T;
- safeParse: (input: unknown) => T | null;
-}
-
-export function defineToolUiContract(
- componentName: string,
- schema: z.ZodType,
-): ToolUiContract {
- return {
- schema,
- parse: (input: unknown) => parseWithSchema(schema, input, componentName),
- safeParse: (input: unknown) => safeParseWithSchema(schema, input),
- };
-}
diff --git a/surfsense_web/components/tool-ui/shared/media/aspect-ratio.ts b/surfsense_web/components/tool-ui/shared/media/aspect-ratio.ts
deleted file mode 100644
index 21352fd04..000000000
--- a/surfsense_web/components/tool-ui/shared/media/aspect-ratio.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { z } from "zod";
-
-export const AspectRatioSchema = z
- .enum(["auto", "1:1", "4:3", "16:9", "9:16"])
- .default("auto");
-
-export type AspectRatio = z.infer;
-
-export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
-
-export type MediaFit = z.infer;
-
-export const RATIO_CLASS_MAP: Record = {
- auto: "",
- "1:1": "aspect-square",
- "4:3": "aspect-[4/3]",
- "16:9": "aspect-video",
- "9:16": "aspect-[9/16]",
-};
-
-export function getRatioClass(ratio: AspectRatio): string {
- return RATIO_CLASS_MAP[ratio];
-}
-
-export function getFitClass(fit: MediaFit): string {
- return fit === "cover" ? "object-cover" : "object-contain";
-}
diff --git a/surfsense_web/components/tool-ui/shared/media/format-utils.ts b/surfsense_web/components/tool-ui/shared/media/format-utils.ts
deleted file mode 100644
index b00757cb7..000000000
--- a/surfsense_web/components/tool-ui/shared/media/format-utils.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export function formatDuration(durationMs: number): string {
- const totalSeconds = Math.round(durationMs / 1000);
- const hours = Math.floor(totalSeconds / 3600);
- const minutes = Math.floor((totalSeconds % 3600) / 60);
- const seconds = totalSeconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
- .toString()
- .padStart(2, "0")}`;
- }
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
-}
-
-/**
- * Format file size in bytes to human-readable string.
- * @example formatFileSize(1024) => "1 KB"
- * @example formatFileSize(1536000) => "1.5 MB"
- */
-export function formatFileSize(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- const units = ["KB", "MB", "GB"];
- let size = bytes / 1024;
- let unit = 0;
- while (size >= 1024 && unit < units.length - 1) {
- size /= 1024;
- unit += 1;
- }
- return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
-}
diff --git a/surfsense_web/components/tool-ui/shared/media/index.ts b/surfsense_web/components/tool-ui/shared/media/index.ts
index 5b655c708..5a3166335 100644
--- a/surfsense_web/components/tool-ui/shared/media/index.ts
+++ b/surfsense_web/components/tool-ui/shared/media/index.ts
@@ -1,17 +1,3 @@
-export {
- AspectRatioSchema,
- MediaFitSchema,
- RATIO_CLASS_MAP,
- getRatioClass,
- getFitClass,
- type AspectRatio,
- type MediaFit,
-} from "./aspect-ratio";
-
-export { OVERLAY_GRADIENT } from "./overlay-gradient";
-
-export { formatDuration, formatFileSize } from "./format-utils";
-
export { sanitizeHref } from "./sanitize-href";
export {
resolveSafeNavigationHref,
diff --git a/surfsense_web/components/tool-ui/shared/media/overlay-gradient.ts b/surfsense_web/components/tool-ui/shared/media/overlay-gradient.ts
deleted file mode 100644
index f59aa44f4..000000000
--- a/surfsense_web/components/tool-ui/shared/media/overlay-gradient.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export const OVERLAY_GRADIENT = `linear-gradient(
- to bottom,
- hsl(0, 0%, 0%) 0%,
- hsla(0, 0%, 0%, 0.987) 8.3%,
- hsla(0, 0%, 0%, 0.951) 16.6%,
- hsla(0, 0%, 0%, 0.896) 24.6%,
- hsla(0, 0%, 0%, 0.825) 32.5%,
- hsla(0, 0%, 0%, 0.741) 40.1%,
- hsla(0, 0%, 0%, 0.648) 47.6%,
- hsla(0, 0%, 0%, 0.55) 54.8%,
- hsla(0, 0%, 0%, 0.45) 61.7%,
- hsla(0, 0%, 0%, 0.352) 68.3%,
- hsla(0, 0%, 0%, 0.259) 74.5%,
- hsla(0, 0%, 0%, 0.175) 80.4%,
- hsla(0, 0%, 0%, 0.104) 86%,
- hsla(0, 0%, 0%, 0.049) 91.1%,
- hsla(0, 0%, 0%, 0.013) 95.8%,
- hsla(0, 0%, 0%, 0) 100%
-)` as const;
diff --git a/surfsense_web/components/tool-ui/shared/parse.ts b/surfsense_web/components/tool-ui/shared/parse.ts
deleted file mode 100644
index 7214241a3..000000000
--- a/surfsense_web/components/tool-ui/shared/parse.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { z } from "zod";
-
-function formatZodPath(path: Array): string {
- if (path.length === 0) return "root";
- return path
- .map((segment) =>
- typeof segment === "number" ? `[${segment}]` : String(segment),
- )
- .join(".");
-}
-
-/**
- * Format Zod errors into a compact `path: message` string.
- */
-export function formatZodError(error: z.ZodError): string {
- const parts = error.issues.map((issue) => {
- const path = formatZodPath(issue.path);
- return `${path}: ${issue.message}`;
- });
-
- return Array.from(new Set(parts)).join("; ");
-}
-
-/**
- * Parse unknown input and throw a readable error.
- */
-export function parseWithSchema(
- schema: z.ZodType,
- input: unknown,
- name: string,
-): T {
- const res = schema.safeParse(input);
- if (!res.success) {
- throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
- }
- return res.data;
-}
-
-/**
- * Parse unknown input, returning `null` instead of throwing on failure.
- *
- * Use this in assistant-ui `render` functions where `args` stream in
- * incrementally and may be incomplete until the tool call finishes.
- */
-export function safeParseWithSchema(
- schema: z.ZodType,
- input: unknown,
-): T | null {
- const res = schema.safeParse(input);
- return res.success ? res.data : null;
-}