"use client" import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react" import type { UIMessage } from "@ai-sdk/react" import { useQuery } from "@tanstack/react-query" import { Streamdown } from "streamdown" import { BookOpenIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ClockIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, GlobeIcon, ListIcon, Loader2, PlusIcon, SearchIcon, TerminalIcon, WrenchIcon, XCircleIcon, ZapIcon, } from "lucide-react" import { $fetch } from "@lib/api" import { cn } from "@lib/utils" import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons" import { GranolaConnectModal } from "@/components/granola-connect-modal" import { isWebSearchToolName } from "@/lib/chat-web-search-tools" import { deriveKnowledgeConnectorState, isNovaKnowledgeConnectionWindowOpen, isNovaKnowledgeBaseProvider, navigateReservedNovaKnowledgeConnectionWindow, releaseNovaKnowledgeConnectionWindow, reserveNovaKnowledgeConnectionWindow, type KnowledgeConnection, type NovaKnowledgeBaseProvider, type NovaKnowledgeBaseStatus, } from "@/lib/chat-knowledge-connectors" import { buildCitationIndex, fetchDocumentsByIds, getDocumentSourceUrl, isMemoryToolOutputReady, mapDocumentsByKnownIds, type CitationTarget, type DocumentWithMemories, extractMemoryToolOutputs, } from "@/lib/chat-memory-tools" import { isSafeSourceId, parseSourceAnnotatedMarkdown, stripSourceMarkup, } from "@/lib/source-annotations" import { modelNames, type ModelId } from "@/lib/models" import { formatResearchDuration, normalizeResearchMarkdownForDisplay, type NovaResearchSource, } from "@/lib/nova-research" import { RelatedMemories } from "./related-memories" import { MessageActions } from "./message-actions" const TOOL_META: Record = { bash: { label: "Memory", icon: TerminalIcon }, recallContext: { label: "Recall Memories", icon: BookOpenIcon }, discoverSpaces: { label: "Discover Spaces", icon: SearchIcon }, web_search: { label: "Web search", icon: GlobeIcon }, google_search: { label: "Google search", icon: GlobeIcon }, // legacy tool names kept for existing persisted messages searchMemories: { label: "Search Memories", icon: SearchIcon }, addMemory: { label: "Add Memory", icon: PlusIcon }, fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon }, forgetMemory: { label: "Forget Memory", icon: XCircleIcon }, updateMemory: { label: "Update Memory", icon: BookOpenIcon }, forgetDocument: { label: "Forget Document", icon: XCircleIcon }, scheduleTask: { label: "Schedule Task", icon: ClockIcon }, listSchedules: { label: "List Schedules", icon: ListIcon }, cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon }, } type ToolCallDisplayPart = { type: string state?: string input?: unknown output?: unknown toolCallId?: string errorText?: string } type SourceUrlPart = { type: "source-url" sourceId: string url: string title?: string } type ActionSource = { id: string title: string subtitle?: string url?: string type: "memory" | "web" } function sourceHost(url: string): string { try { return new URL(url).hostname.replace(/^www\./, "") } catch { return url } } function faviconUrl(host: string): string { return `https://www.google.com/s2/favicons?sz=64&domain=${host}` } type NovaConnectorStatus = | "active" | "syncing" | "error" | "setup_pending" | "not_connected" | "upgrade_required" | "setup_available" type NovaConnectorStep = { title?: string description?: string code?: string link?: { url: string; label: string } createPluginKey?: boolean } type NovaConnectorCardData = { kind?: "plugin" | "mcp" | "knowledge" id?: string provider?: NovaKnowledgeBaseProvider name?: string icon?: string description?: string features?: string[] docsUrl?: string repoUrl?: string installSteps?: NovaConnectorStep[] status?: NovaConnectorStatus requiresPro?: boolean canGenerateKey?: boolean keyPluginId?: string connectMode?: "oauth" | "api_key" canConnect?: boolean disabledReason?: string connectionCount?: number documentCount?: number lastSyncStatus?: "running" | "completed" | "failed" lastSyncAt?: string syncError?: string } type NovaConnectorToolOutput = { success?: boolean error?: string kind?: string connectors?: NovaConnectorCardData[] connector?: NovaConnectorCardData keyReveal?: { pluginId: string; label?: string } | null available?: Array<{ kind: "plugin" | "mcp" | "knowledge" id: string name: string }> } const NOVA_CONNECTOR_TOOLS = new Set([ "listNovaConnectors", "getNovaConnectorSetup", "prepareNovaPluginSetup", "listNovaKnowledgeBases", "getNovaKnowledgeBase", "startNovaKnowledgeBaseConnection", ]) const CONNECTOR_ICON_FALLBACKS: Record = { codex: "/images/plugins/codex.png", cursor: "/images/plugins/cursor.png", mcp_cursor: "/mcp-supported-tools/cursor.png", } const KNOWLEDGE_CONNECTION_TIMEOUT_MS = 2 * 60 * 1000 function KnowledgeBaseProviderIcon({ provider, }: { provider: NovaKnowledgeBaseProvider }) { if (provider === "google-drive") { return } if (provider === "notion") { return } if (provider === "onedrive") { return } return } const STATUS_COPY: Record< NovaConnectorStatus, { label: string; className: string } > = { active: { label: "Active", className: "border-emerald-400/20 bg-emerald-400/10 text-emerald-300", }, syncing: { label: "Syncing", className: "border-blue-400/20 bg-blue-400/10 text-blue-300", }, error: { label: "Needs attention", className: "border-red-400/20 bg-red-400/10 text-red-300", }, setup_pending: { label: "Finish setup", className: "border-amber-400/20 bg-amber-400/10 text-amber-300", }, not_connected: { label: "Not connected", className: "border-white/10 bg-white/[0.05] text-white/55", }, upgrade_required: { label: "Pro required", className: "border-[#4BA0FA]/25 bg-[#4BA0FA]/10 text-[#4BA0FA]", }, setup_available: { label: "Setup available", className: "border-white/10 bg-white/[0.05] text-white/65", }, } function connectorToolName(part: ToolCallDisplayPart): string { return part.type.startsWith("tool-") ? part.type.slice("tool-".length) : part.type } function connectorToolNameFromPart(part: unknown): string | null { if (!part || typeof part !== "object") return null const record = part as { type?: string; toolName?: string } if (record.type === "dynamic-tool") return record.toolName ?? null if (record.type?.startsWith("tool-")) return record.type.slice("tool-".length) return null } function parseConnectorOutput(value: string): NovaConnectorToolOutput | null { try { return JSON.parse(value) as NovaConnectorToolOutput } catch { return null } } function safeExternalUrl(url: string | null | undefined): string | null { if (!url) return null if (url.startsWith("/") && !url.startsWith("//")) return url if (url.startsWith("#") && !url.startsWith("#sm-source:")) return url try { const parsed = new URL(url) return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null } catch { return null } } function unwrapToolOutput(output: unknown): NovaConnectorToolOutput | null { if (typeof output === "string") { return parseConnectorOutput(output) } if (!output || typeof output !== "object") return null const record = output as Record for (const key of ["value", "result", "data", "output"]) { const nested = record[key] if (nested && nested !== output) { const parsed = unwrapToolOutput(nested) if (parsed) return parsed } } if ( record.type === "json" && record.value && typeof record.value === "object" ) { return record.value as NovaConnectorToolOutput } if (record.type === "text" && typeof record.value === "string") { return parseConnectorOutput(record.value) } if (typeof record.text === "string") { return parseConnectorOutput(record.text) } return record as NovaConnectorToolOutput } function connectorIconSrc( connector: NovaConnectorCardData, ): string | undefined { if (connector.id && CONNECTOR_ICON_FALLBACKS[connector.id]) { return CONNECTOR_ICON_FALLBACKS[connector.id] } if (connector.icon?.endsWith("/codex.svg")) return CONNECTOR_ICON_FALLBACKS.codex if (connector.icon?.endsWith("/cursor.svg")) return CONNECTOR_ICON_FALLBACKS.cursor return connector.icon } function connectorCardKey(connector: NovaConnectorCardData): string { return `${connector.kind ?? "connector"}-${connector.id ?? connector.name ?? "unknown"}` } function connectorIdentity( output: NovaConnectorToolOutput | null, ): string | null { if (!output) return null if (output.connectors && output.connectors.length !== 1) return null const connector = output.connector ?? output.connectors?.[0] if (!connector) return null return `${connector.kind ?? "connector"}:${connector.id ?? connector.name ?? ""}` } function connectorOutputFromPart( part: unknown, ): NovaConnectorToolOutput | null { if (!part || typeof part !== "object") return null const record = part as { type?: string toolName?: string output?: unknown } const toolName = connectorToolNameFromPart(record) if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return null return unwrapToolOutput(record.output) } function connectorToolPriority(toolName: string | null): number { if (toolName === "prepareNovaPluginSetup") return 2 if ( toolName === "getNovaConnectorSetup" || toolName === "getNovaKnowledgeBase" || toolName === "startNovaKnowledgeBaseConnection" ) return 1 return 0 } function shouldSkipNovaConnectorPart(parts: unknown[], index: number): boolean { const part = parts[index] const toolName = connectorToolNameFromPart(part) if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return false const identity = connectorIdentity(connectorOutputFromPart(part)) if (!identity) return false const priority = connectorToolPriority(toolName) for (let i = 0; i < parts.length; i++) { if (i === index) continue const otherTool = connectorToolNameFromPart(parts[i]) if (!otherTool || !NOVA_CONNECTOR_TOOLS.has(otherTool)) continue const otherIdentity = connectorIdentity(connectorOutputFromPart(parts[i])) if (otherIdentity !== identity) continue const otherPriority = connectorToolPriority(otherTool) if (i < index && otherPriority >= priority) return true if (i > index && otherPriority > priority) return true } return false } function StatusPill({ status }: { status?: NovaConnectorStatus }) { const copy = STATUS_COPY[status ?? "not_connected"] ?? STATUS_COPY.not_connected return ( {copy.label} ) } function MiniCopyButton({ text, label }: { text: string; label?: string }) { const [copied, setCopied] = useState(false) return ( ) } function ConnectorCodeBlock({ code, apiKey, }: { code: string apiKey?: string }) { const rendered = apiKey ? code.replaceAll("sm_...", apiKey) : code return (
				{rendered}
			
) } function RevealPluginKeyButton({ pluginId, onReveal, }: { pluginId: string onReveal: (key: string) => void }) { const [state, setState] = useState<"idle" | "loading" | "copied" | "error">( "idle", ) return ( ) } function knowledgeFallbackStatus( status: NovaConnectorStatus | undefined, ): NovaKnowledgeBaseStatus { if ( status === "active" || status === "syncing" || status === "error" || status === "upgrade_required" ) { return status } return "not_connected" } function KnowledgeBaseConnectAction({ connector, onConnected, onPendingChange, attemptKey, }: { connector: NovaConnectorCardData onConnected: () => void onPendingChange?: (pending: boolean) => void attemptKey?: string }) { const [granolaOpen, setGranolaOpen] = useState(false) const [connectionState, setConnectionState] = useState< "idle" | "starting" | "waiting" | "error" >("idle") const [error, setError] = useState(null) const granolaSucceededRef = useRef(false) const startingConnectionCountRef = useRef(connector.connectionCount ?? 0) const provider = connector.provider const disabled = connector.canConnect === false || connectionState === "starting" || connectionState === "waiting" const buttonLabel = connector.status === "active" || connector.status === "syncing" ? "Add another" : connector.status === "error" ? "Reconnect" : "Connect" const clearPendingAttempt = useCallback(() => { if (attemptKey) sessionStorage.removeItem(attemptKey) }, [attemptKey]) const stopWaiting = useCallback( (message: string) => { if (provider) releaseNovaKnowledgeConnectionWindow(provider) clearPendingAttempt() onPendingChange?.(false) setError(message) setConnectionState("error") }, [clearPendingAttempt, onPendingChange, provider], ) const connect = useCallback( async (reserveTab = false) => { if (!provider || connector.kind !== "knowledge") return startingConnectionCountRef.current = connector.connectionCount ?? 0 setError(null) setConnectionState("starting") onPendingChange?.(true) if (provider === "granola") { granolaSucceededRef.current = false setGranolaOpen(true) setConnectionState("waiting") return } if (reserveTab) reserveNovaKnowledgeConnectionWindow(provider) try { const response = await $fetch("@post/connections/:provider", { params: { provider }, body: { redirectUrl: window.location.href, containerTags: [], }, }) if (response.error) { throw new Error( response.error.message || "Failed to start connection", ) } const data = response.data as { authLink?: string } | undefined const authLink = safeExternalUrl(data?.authLink) if (!authLink) throw new Error("Connection link was not returned") if (attemptKey) sessionStorage.setItem(attemptKey, String(Date.now())) if ( !navigateReservedNovaKnowledgeConnectionWindow(provider, authLink) ) { clearPendingAttempt() throw new Error( "Your browser blocked the connection tab. Allow pop-ups for Supermemory and try again.", ) } setConnectionState("waiting") } catch (cause) { stopWaiting( cause instanceof Error ? cause.message : "Failed to connect", ) } }, [ attemptKey, clearPendingAttempt, connector.connectionCount, connector.kind, onPendingChange, provider, stopWaiting, ], ) useEffect(() => { if (connectionState !== "waiting" || !provider || provider === "granola") { return } const closedCheck = window.setInterval(() => { if (!isNovaKnowledgeConnectionWindowOpen(provider)) { stopWaiting("Connection cancelled. You can try again.") } }, 500) const timeout = window.setTimeout(() => { stopWaiting("Connection timed out. You can try again.") }, KNOWLEDGE_CONNECTION_TIMEOUT_MS) return () => { window.clearInterval(closedCheck) window.clearTimeout(timeout) } }, [connectionState, provider, stopWaiting]) useEffect(() => { const connectionCompleted = (connector.connectionCount ?? 0) > startingConnectionCountRef.current || (startingConnectionCountRef.current === 0 && (connector.status === "active" || connector.status === "syncing")) if (connectionState !== "waiting" || !connectionCompleted) return clearPendingAttempt() if (provider) releaseNovaKnowledgeConnectionWindow(provider) setConnectionState("idle") onPendingChange?.(false) onConnected() }, [ clearPendingAttempt, connectionState, connector.connectionCount, connector.status, onConnected, onPendingChange, provider, ]) if (!provider || connector.kind !== "knowledge") return null return (
{connectionState === "starting" || connectionState === "waiting" ? (
{connectionState === "starting" ? "Opening…" : "Waiting…"}
{connectionState === "waiting" ? ( ) : null}
) : ( )} {connector.canConnect === false && connector.disabledReason ? (

{connector.disabledReason}

) : null} {error ? (

{error}

) : null} {provider === "granola" ? ( { setGranolaOpen(open) if (!open && !granolaSucceededRef.current) { stopWaiting("Connection cancelled. You can try again.") } }} containerTags={[]} onSuccess={() => { granolaSucceededRef.current = true clearPendingAttempt() setError(null) setConnectionState("idle") onPendingChange?.(false) onConnected() }} /> ) : null}
) } function NovaConnectorCard({ connector, onConnectionsChanged, onConnectionPendingChange, connectionAttemptKey, }: { connector: NovaConnectorCardData onConnectionsChanged?: () => void onConnectionPendingChange?: (pending: boolean) => void connectionAttemptKey?: string }) { const [revealedKey, setRevealedKey] = useState() const displayedConnector = connector const needsKey = Boolean( displayedConnector.canGenerateKey && displayedConnector.keyPluginId, ) const isUpgrade = displayedConnector.status === "upgrade_required" const iconSrc = connectorIconSrc(displayedConnector) if ( displayedConnector.kind === "knowledge" && isNovaKnowledgeBaseProvider(displayedConnector.provider) ) { const connectionCount = displayedConnector.connectionCount ?? 0 const indexedItems = displayedConnector.documentCount ?? 0 const statusText = displayedConnector.status === "syncing" ? "Syncing memories…" : displayedConnector.status === "error" ? (displayedConnector.syncError ?? "Connection needs attention") : connectionCount > 0 ? `${connectionCount} connection${connectionCount === 1 ? "" : "s"} · ${indexedItems} indexed items` : displayedConnector.description return (
{isUpgrade ? ( Pro ) : null}

{displayedConnector.name ?? "Knowledge base"}

{statusText ? (

{statusText}

) : null}
{isUpgrade ? (
Upgrade
) : ( onConnectionsChanged?.()} onPendingChange={onConnectionPendingChange} attemptKey={connectionAttemptKey} /> )}
) } return (
{iconSrc ? ( { const img = event.currentTarget const fallback = displayedConnector.id ? CONNECTOR_ICON_FALLBACKS[displayedConnector.id] : undefined if (fallback && img.dataset.fallbackApplied !== "true") { img.dataset.fallbackApplied = "true" img.src = fallback } else { img.style.display = "none" } }} /> ) : ( )}

{displayedConnector.name ?? displayedConnector.id ?? "Connector"}

{displayedConnector.description ? (

{displayedConnector.description}

) : null} {displayedConnector.kind === "knowledge" ? (

{displayedConnector.connectionCount ?? 0} connection {displayedConnector.connectionCount === 1 ? "" : "s"} ·{" "} {displayedConnector.documentCount ?? 0} indexed items

) : null} {displayedConnector.syncError ? (

{displayedConnector.syncError}

) : null}
{displayedConnector.installSteps?.length ? (
    {displayedConnector.installSteps.map((step, index) => (
  1. {index + 1}

    {step.title}

    {step.description ? (

    {step.description}

    ) : null} {step.code ? ( ) : null} {step.link ? ( {step.link.label} ) : null}
  2. ))}
) : null}
{needsKey && displayedConnector.keyPluginId && !isUpgrade ? ( ) : null} {isUpgrade ? ( Upgrade to connect ) : null} {displayedConnector.kind === "knowledge" && !isUpgrade ? ( onConnectionsChanged?.()} onPendingChange={onConnectionPendingChange} attemptKey={connectionAttemptKey} /> ) : null} {displayedConnector.docsUrl ? ( Docs ) : null}
) } function NovaConnectorCompactCard({ connector, expanded, onToggle, }: { connector: NovaConnectorCardData expanded: boolean onToggle: () => void }) { const iconSrc = connectorIconSrc(connector) return (
) } function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { const [expandedConnectorKey, setExpandedConnectorKey] = useState< string | null >(null) const [connectionPending, setConnectionPending] = useState(false) const toolName = connectorToolName(part) const output = unwrapToolOutput(part.output) const connectionAttemptKey = part.toolCallId ? `nova-knowledge-connect:${part.toolCallId}` : undefined const connectors = output?.connector ? [output.connector] : (output?.connectors ?? []) const hasKnowledgeBases = connectors.some( (connector) => connector.kind === "knowledge" && isNovaKnowledgeBaseProvider(connector.provider), ) const { data: liveConnections, refetch: refetchConnections } = useQuery({ queryKey: ["connections"], queryFn: async () => { const response = await $fetch("@post/connections/list", { body: { containerTags: [] }, }) if (response.error) { throw new Error(response.error.message || "Failed to load connections") } return response.data as KnowledgeConnection[] }, enabled: hasKnowledgeBases && output?.success !== false, staleTime: 30 * 1000, refetchInterval: (query) => { if (connectionPending) return 3000 const connections = query.state.data as KnowledgeConnection[] | undefined return connections?.some( (connection) => connection.lastSyncRun?.status === "running", ) ? 5000 : false }, refetchIntervalInBackground: connectionPending, }) const displayedConnectors = connectors.map((connector) => { if ( !liveConnections || connector.kind !== "knowledge" || !isNovaKnowledgeBaseProvider(connector.provider) ) { return connector } return { ...connector, ...deriveKnowledgeConnectorState({ provider: connector.provider, connections: liveConnections, fallbackStatus: knowledgeFallbackStatus(connector.status), }), } }) const isLoading = part.state === "input-streaming" || part.state === "input-available" const isError = part.state === "error" || part.state === "output-error" if (isLoading) { return (
{toolName === "listNovaKnowledgeBases" || toolName === "getNovaKnowledgeBase" || toolName === "startNovaKnowledgeBaseConnection" ? "Checking knowledge bases…" : "Checking Supermemory setup…"}
) } if (isError) { return (
Couldn't load connector setup.
) } if (!output) return null if (output.success === false) { return (

{output.error ?? "Connector not found"}

{output.available?.length ? (

Try one of: {output.available.map((item) => item.name).join(", ")}

) : null}
) } const isConnectorList = (toolName === "listNovaConnectors" || toolName === "listNovaKnowledgeBases") && displayedConnectors.length > 1 const expandedConnector = isConnectorList && expandedConnectorKey ? displayedConnectors.find( (connector) => connectorCardKey(connector) === expandedConnectorKey, ) : null return (
{isConnectorList ? (

{toolName === "listNovaKnowledgeBases" ? "Knowledge bases" : "Supermemory setup options"}

) : null}
{displayedConnectors.map((connector) => isConnectorList ? ( { const nextKey = connectorCardKey(connector) setExpandedConnectorKey((current) => current === nextKey ? null : nextKey, ) }} /> ) : ( void refetchConnections()} onConnectionPendingChange={setConnectionPending} connectionAttemptKey={connectionAttemptKey} /> ), )}
{expandedConnector ? (
void refetchConnections()} onConnectionPendingChange={setConnectionPending} connectionAttemptKey={connectionAttemptKey} />
) : null}
) } function isWebSearchPart(part: { type: string; toolName?: string }): boolean { if (part.type === "dynamic-tool") { return isWebSearchToolName(part.toolName ?? "") } if (part.type.startsWith("tool-")) { return isWebSearchToolName(part.type.slice("tool-".length)) } return false } function isMemoryRetrievalToolName(toolName: string): boolean { return ( toolName === "searchMemories" || toolName === "recallContext" || toolName === "discoverSpaces" ) } export function isChatToolDisplayPartType(type: string): boolean { return ( type === "tool-searchMemories" || type === "tool-recallContext" || type === "tool-discoverSpaces" || type === "tool-forgetMemory" || type === "tool-updateMemory" || type === "tool-forgetDocument" ) } function CitationLink({ href, label, source, }: { href: string label: string source?: SourceUrlPart }) { const url = safeExternalUrl(source?.url ?? href) ?? "" if (!url) return <>{label} const host = sourceHost(url) const rawTitle = source?.title?.trim() const hasTitle = !!rawTitle && rawTitle !== host && !/^https?:\/\//i.test(rawTitle) let path = "" try { path = new URL(url).pathname.replace(/\/$/, "") } catch {} return ( {label} {host} {hasTitle ? ( {rawTitle} ) : path ? ( {path} ) : null} ) } function sourceTitle( target: CitationTarget, document?: DocumentWithMemories, ): string { return ( document?.title?.trim() || target.title?.trim() || document?.customId || target.customId || target.documentId || target.sourceId ) } function sourceSummary( target: CitationTarget, document?: DocumentWithMemories, ): string | null { const summary = document?.summary || target.summary || (document as { content?: string } | undefined)?.content || null return summary ? summary.trim() : null } function sourceKind( target: CitationTarget, document?: DocumentWithMemories, ): string { return (document?.type || target.type || "memory").replaceAll("_", " ") } function SourceCitationLink({ sourceId, children, citationIndex, documentByKnownId, }: { sourceId: string children: ReactNode citationIndex: Map documentByKnownId: Map }) { const target = citationIndex.get(sourceId) if (!target) return <>{children} const document = (target.documentId ? documentByKnownId.get(target.documentId) : undefined) ?? (target.customId ? documentByKnownId.get(target.customId) : undefined) const url = safeExternalUrl( document ? getDocumentSourceUrl(document) : target.url, ) const title = sourceTitle(target, document) const summary = sourceSummary(target, document) const citationContent = typeof children === "string" && (children === sourceId || children === "memory") ? ( {sourceId} ) : ( <> {children} {sourceId} ) return ( {url ? ( {citationContent} ) : ( )} {title} {sourceKind(target, document)} {summary ? ( {summary} ) : null} {url ? ( Open source ) : null} ) } function makeMarkdownComponents( sources: SourceUrlPart[], citationIndex: Map, documentByKnownId: Map, ) { return { a: ({ href, children }: { href?: string; children?: ReactNode }) => { if (href?.startsWith("#sm-source:")) { const sourceId = (() => { try { return decodeURIComponent(href.slice("#sm-source:".length)) } catch { return null } })() if (!sourceId) return <>{children} return ( {children} ) } const label = typeof children === "string" ? children : Array.isArray(children) ? children.join("") : "" const match = label.match(/^\[?(\d+)\]?$/) const safeHref = safeExternalUrl(href) if (match && safeHref) { const n = Number(match[1]) const source = sources.find((s) => s.url === safeHref) ?? sources[n - 1] return } if (!safeHref) return <>{children} return ( {children} ) }, } } function SourcesPill({ sources }: { sources: ActionSource[] }) { const [expanded, setExpanded] = useState(false) const ref = useRef(null) useEffect(() => { if (!expanded) return const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setExpanded(false) } } document.addEventListener("mousedown", onDown) return () => document.removeEventListener("mousedown", onDown) }, [expanded]) if (sources.length === 0) return null const faviconHosts: string[] = [] for (const source of sources) { if (!source.url) continue const host = sourceHost(source.url) if (!faviconHosts.includes(host)) faviconHosts.push(host) if (faviconHosts.length >= 3) break } const count = sources.length return (
{expanded && (
    {sources.map((source) => { const host = source.url ? sourceHost(source.url) : null const content = ( <> {host ? ( ) : ( )} {source.title} {source.subtitle || host || "Saved memory"} ) return (
  • {source.url ? ( {content} ) : (
    {content}
    )}
  • ) })}
)}
) } function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) { const [expanded, setExpanded] = useState(false) const isLoading = part.state === "input-streaming" || part.state === "input-available" const isDone = part.state === "output-available" const isError = part.state === "error" || part.state === "output-error" const cmd = part.input && typeof part.input === "object" && "cmd" in part.input ? String((part.input as { cmd: string }).cmd) : undefined const output = isDone && part.output && typeof part.output === "object" ? (part.output as { stdout?: string; stderr?: string; exitCode?: number }) : undefined const hasOutput = output && ((output.stdout && output.stdout.length > 0) || (output.stderr && output.stderr.length > 0)) const errorText = part.errorText const hasExpandable = hasOutput || (isError && errorText) return (
{expanded && (hasOutput || (isError && errorText)) && (
{output?.stdout && output.stdout.length > 0 && (
							{output.stdout}
						
)} {output?.stderr && output.stderr.length > 0 && (
							{output.stderr}
						
)} {isError && errorText && (
							{errorText}
						
)}
)}
) } function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) { const [expanded, setExpanded] = useState(false) const toolName = connectorToolName(part) if (NOVA_CONNECTOR_TOOLS.has(toolName)) { return } if (toolName === "bash") { return } if (isWebSearchToolName(toolName)) { if (part.state === "output-available") return null if (part.state === "error" || part.state === "output-error") { return (
Web search failed
) } return (
Searching the web…
) } const meta = TOOL_META[toolName] ?? (isWebSearchToolName(toolName) ? { label: "Web search", icon: GlobeIcon } : undefined) const Icon = meta?.icon ?? WrenchIcon const label = meta?.label ?? toolName const isLoading = part.state === "input-streaming" || part.state === "input-available" const isDone = part.state === "output-available" const isError = part.state === "error" || part.state === "output-error" const errorText = part.errorText if (isMemoryRetrievalToolName(toolName) && isMemoryToolOutputReady(part)) { return null } return (
{expanded && (
{part.input !== undefined && (
Input
								{typeof part.input === "string"
									? part.input
									: JSON.stringify(part.input, null, 2)}
							
)} {isDone && part.output !== undefined && (
Output
								{typeof part.output === "string"
									? part.output
									: JSON.stringify(part.output, null, 2)}
							
)} {isError && errorText && (
Error
								{errorText}
							
)}
)}
) } function SaveResearchToMemoryButton({ runId, apiBase, className, }: { runId: string apiBase: string className?: string }) { const [saveState, setSaveState] = useState< "idle" | "saving" | "saved" | "error" >("idle") const { data } = useQuery({ queryKey: ["nova-research-memory-status", runId], queryFn: async () => { const response = await fetch(`${apiBase}/chat/research/${runId}`, { credentials: "include", }) if (!response.ok) throw new Error("Could not load research status") return (await response.json()) as { run?: { reportDocumentId?: string | null } } }, staleTime: 30 * 1000, }) const saved = saveState === "saved" || Boolean(data?.run?.reportDocumentId) const saving = saveState === "saving" const save = async () => { if (saved || saving) return setSaveState("saving") try { const response = await fetch( `${apiBase}/chat/research/${runId}/save-to-memory`, { method: "POST", credentials: "include" }, ) const result = (await response.json().catch(() => null)) as { error?: string saved?: boolean } | null if (!response.ok || !result?.saved) { throw new Error(result?.error || "Could not save this report") } setSaveState("saved") } catch { setSaveState("error") } } const label = saved ? "Saved to memory" : saveState === "error" ? "Try saving again" : "Save to memory" return ( ) } interface AgentMessageProps { message: UIMessage index: number messagesLength: number hoveredMessageId: string | null copiedMessageId: string | null messageFeedback: Record expandedMemories: string | null responseModel: ModelId | null onCopy: (messageId: string, text: string) => void onLike: (messageId: string) => void onDislike: (messageId: string) => void onToggleMemories: (messageId: string) => void } export function AgentMessage({ message, index, messagesLength, hoveredMessageId, copiedMessageId, messageFeedback, expandedMemories, responseModel, onCopy, onLike, onDislike, onToggleMemories, }: AgentMessageProps) { const isLastAgentMessage = index === messagesLength - 1 && message.role === "assistant" const isHovered = hoveredMessageId === message.id const messageText = message.parts .filter((part) => part.type === "text") .map((part) => part.text) .join(" ") const researchMetadata = ( message as UIMessage & { metadata?: { research?: { runId?: string title?: string artifact?: string durationMs?: number sources?: NovaResearchSource[] } } } ).metadata?.research const copyText = researchMetadata ? normalizeResearchMarkdownForDisplay(stripSourceMarkup(messageText)) : stripSourceMarkup(messageText) const memoryOutputs = useMemo( () => extractMemoryToolOutputs(message), [message], ) const citationIndex = useMemo(() => { const index = buildCitationIndex(memoryOutputs) for (const source of researchMetadata?.sources ?? []) { if ( source.type !== "memory" || !source.sourceId || !isSafeSourceId(source.sourceId) || index.has(source.sourceId) ) { continue } index.set(source.sourceId, { sourceId: source.sourceId, documentId: source.documentId, title: source.title ?? source.space, type: "memory", url: source.url, }) } return index }, [memoryOutputs, researchMetadata?.sources]) const allowedSourceIds = useMemo( () => new Set(citationIndex.keys()), [citationIndex], ) const sourceDocumentIds = useMemo(() => { const ids = new Set() for (const target of citationIndex.values()) { if (target.documentId) ids.add(target.documentId) if (target.customId) ids.add(target.customId) } return [...ids].sort() }, [citationIndex]) const { data: sourceDocuments = [] } = useQuery({ queryKey: ["chat-source-documents", sourceDocumentIds], queryFn: () => fetchDocumentsByIds(sourceDocumentIds), enabled: sourceDocumentIds.length > 0, staleTime: 5 * 60 * 1000, }) const documentByKnownId = useMemo( () => mapDocumentsByKnownIds(sourceDocuments), [sourceDocuments], ) const webSources = useMemo(() => { const seen = new Set() const out: SourceUrlPart[] = [] for (const part of message.parts) { if (part.type !== "source-url") continue const source = part as SourceUrlPart if (seen.has(source.url)) continue seen.add(source.url) out.push(source) } return out }, [message.parts]) const actionSources = useMemo(() => { const seen = new Set() const sources: ActionSource[] = [] for (const source of webSources) { const id = `web:${source.url}` if (seen.has(id)) continue seen.add(id) sources.push({ id, type: "web", title: source.title?.trim() || sourceHost(source.url), subtitle: sourceHost(source.url), url: source.url, }) } for (const source of researchMetadata?.sources ?? []) { const id = source.url ? `web:${source.url}` : source.id if (seen.has(id)) continue seen.add(id) sources.push({ id, type: source.type, title: source.title?.trim() || source.space || (source.type === "memory" ? "Saved memory" : "Web source"), subtitle: source.space || source.sourceId || source.documentId || undefined, url: source.url, }) } return sources }, [researchMetadata?.sources, webSources]) const hasAssistantText = message.parts.some( (p) => p.type === "text" && (p as { text?: string }).text?.trim(), ) const hasAutoStartKnowledgeConnection = message.parts.some( (part) => connectorToolNameFromPart(part) === "startNovaKnowledgeBaseConnection", ) const markdownComponents = useMemo( () => makeMarkdownComponents(webSources, citationIndex, documentByKnownId), [webSources, citationIndex, documentByKnownId], ) const responseModelLabel = responseModel ? `${modelNames[responseModel].name} ${modelNames[responseModel].version}` : null const researchApiBase = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const researchDuration = formatResearchDuration(researchMetadata?.durationMs) return (
{researchDuration ? (

Research completed in{" "} {researchDuration}

) : null} {message.parts.map((part, partIndex) => { if (part.type === "source-url") { return null } if (isWebSearchPart(part)) { return null } if (part.type === "source-document") { const doc = part as { type: "source-document" sourceId: string title: string filename?: string } return (
Document
{doc.title}
{doc.filename && (
{doc.filename}
)}
) } if (part.type === "text") { if (hasAutoStartKnowledgeConnection) return null // Skip fragments mid-run — source-url citations split one answer into // many text parts; rendering each separately tears markdown (lists etc.). let prev = partIndex - 1 while (prev >= 0 && message.parts[prev]?.type === "source-url") { prev-- } if (prev >= 0 && message.parts[prev]?.type === "text") { return null } let runText = "" for (let j = partIndex; j < message.parts.length; j++) { const p = message.parts[j] if (p?.type === "text") runText += p.text else if (p?.type === "source-url") continue else break } return (
{researchMetadata ? normalizeResearchMarkdownForDisplay( parseSourceAnnotatedMarkdown( runText, allowedSourceIds, ).markdown, ) : parseSourceAnnotatedMarkdown(runText, allowedSourceIds) .markdown}
) } if (part.type === "dynamic-tool") { if (shouldSkipNovaConnectorPart(message.parts, partIndex)) { return null } const dt = part as { type: "dynamic-tool" toolName: string toolCallId: string state?: string input?: unknown output?: unknown errorText?: string } const displayState = dt.state === "output-error" ? "error" : dt.state return ( ) } if (part.type.startsWith("tool-")) { if (shouldSkipNovaConnectorPart(message.parts, partIndex)) { return null } return ( ) } return null })}
{hasAssistantText && !hasAutoStartKnowledgeConnection && (
{actionSources.length > 0 && (
)} {researchMetadata?.runId ? ( <> ) : null} {responseModelLabel && ( {responseModelLabel} )}
)}
) }