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"<!\[CDATA\[(.*?)\]\]>\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; -}