supermemory/apps/web/components/chat/message/agent-message.tsx
2026-08-11 18:58:25 +05:30

2186 lines
64 KiB
TypeScript

"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<string, { label: string; icon: typeof SearchIcon }> = {
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<string, string> = {
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 <GoogleDrive className="size-6 shrink-0 text-[#737373]" />
}
if (provider === "notion") {
return <Notion className="size-6 shrink-0 text-[#737373]" />
}
if (provider === "onedrive") {
return <OneDrive className="size-6 shrink-0 text-[#737373]" />
}
return <Granola className="size-6 shrink-0 text-[#737373]" />
}
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<string, unknown>
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 (
<span
className={cn(
"inline-flex h-6 shrink-0 items-center rounded-full border px-2 text-[11px] font-medium",
copy.className,
)}
>
{copy.label}
</span>
)
}
function MiniCopyButton({ text, label }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
aria-label={`Copy ${label ?? "value"}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
} catch {
setCopied(false)
}
}}
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-white/45 transition-colors hover:text-white/80"
>
{copied ? (
<CheckIcon className="size-3.5 text-emerald-300" />
) : (
<CopyIcon className="size-3.5" />
)}
</button>
)
}
function ConnectorCodeBlock({
code,
apiKey,
}: {
code: string
apiKey?: string
}) {
const rendered = apiKey ? code.replaceAll("sm_...", apiKey) : code
return (
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#080B10] px-3 py-2.5">
<pre
className={cn(
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7]",
code.includes("sm_...") &&
!apiKey &&
"select-none blur-[4px] transition-[filter] group-hover:blur-none",
)}
>
{rendered}
</pre>
<MiniCopyButton text={rendered} label="setup step" />
</div>
)
}
function RevealPluginKeyButton({
pluginId,
onReveal,
}: {
pluginId: string
onReveal: (key: string) => void
}) {
const [state, setState] = useState<"idle" | "loading" | "copied" | "error">(
"idle",
)
return (
<button
type="button"
disabled={state === "loading"}
onClick={async () => {
setState("loading")
try {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const params = new URLSearchParams({ client: pluginId })
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to create plugin key")
const data = (await res.json()) as { key?: string }
if (!data.key) throw new Error("Plugin key missing")
onReveal(data.key)
await navigator.clipboard.writeText(data.key).catch(() => undefined)
setState("copied")
setTimeout(() => setState("idle"), 2200)
} catch {
setState("error")
setTimeout(() => setState("idle"), 2200)
}
}}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-60",
)}
>
{state === "loading" ? (
<Loader2 className="size-3.5 animate-spin" />
) : state === "copied" ? (
<CheckIcon className="size-3.5 text-emerald-300" />
) : state === "error" ? (
<XCircleIcon className="size-3.5 text-red-300" />
) : null}
{state === "copied"
? "Key copied"
: state === "error"
? "Try again"
: state === "loading"
? "Generating"
: "Generate key"}
</button>
)
}
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<string | null>(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 (
<div className="flex min-w-0 flex-col items-end gap-1.5">
{connectionState === "starting" || connectionState === "waiting" ? (
<div className="flex items-center gap-1.5">
<div className="inline-flex h-9 min-w-[116px] items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[14px] font-medium text-[#A1A1AA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]">
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA]" />
{connectionState === "starting" ? "Opening…" : "Waiting…"}
</div>
{connectionState === "waiting" ? (
<button
type="button"
onClick={() =>
stopWaiting("Connection cancelled. You can try again.")
}
className="h-9 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:bg-[#0D121A] hover:text-[#FAFAFA]"
>
Cancel
</button>
) : null}
</div>
) : (
<button
type="button"
disabled={disabled}
onClick={() => void connect(true)}
className="inline-flex h-9 min-w-[116px] items-center justify-center rounded-full bg-[#0D121A] px-5 text-[14px] font-medium text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
>
{connectionState === "error" ? "Try again" : buttonLabel}
</button>
)}
{connector.canConnect === false && connector.disabledReason ? (
<p className="basis-full text-[11px] text-[#A1A1AA]">
{connector.disabledReason}
</p>
) : null}
{error ? (
<p className="text-right text-[11px] text-red-300">{error}</p>
) : null}
{provider === "granola" ? (
<GranolaConnectModal
open={granolaOpen}
onOpenChange={(open) => {
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}
</div>
)
}
function NovaConnectorCard({
connector,
onConnectionsChanged,
onConnectionPendingChange,
connectionAttemptKey,
}: {
connector: NovaConnectorCardData
onConnectionsChanged?: () => void
onConnectionPendingChange?: (pending: boolean) => void
connectionAttemptKey?: string
}) {
const [revealedKey, setRevealedKey] = useState<string | undefined>()
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 (
<div className="flex min-h-[190px] w-full max-w-[520px] flex-col rounded-[12px] bg-[#14161A] p-4 text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex items-start justify-between gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
<KnowledgeBaseProviderIcon provider={displayedConnector.provider} />
</div>
{isUpgrade ? (
<span className="shrink-0 pt-1 text-[10px] font-semibold uppercase tracking-wide text-[#4BA0FA]">
Pro
</span>
) : null}
</div>
<div className="mt-4 min-w-0 flex-1">
<p className="truncate text-[16px] font-medium text-[#FAFAFA]">
{displayedConnector.name ?? "Knowledge base"}
</p>
{statusText ? (
<p className="mt-1 line-clamp-2 text-[14px] leading-snug text-[#A1A1AA]">
{statusText}
</p>
) : null}
</div>
<div className="mt-4 flex justify-end">
{isUpgrade ? (
<div className="inline-flex h-9 min-w-[116px] items-center justify-center rounded-full bg-[#0D121A] px-5 text-[14px] font-medium text-[#4BA0FA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]">
Upgrade
</div>
) : (
<KnowledgeBaseConnectAction
connector={displayedConnector}
onConnected={() => onConnectionsChanged?.()}
onPendingChange={onConnectionPendingChange}
attemptKey={connectionAttemptKey}
/>
)}
</div>
</div>
)
}
return (
<div className="rounded-xl border border-white/[0.08] bg-[#0D121A] p-3 text-sm text-white/90 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.55)]">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F]">
{iconSrc ? (
<img
src={iconSrc}
alt=""
className="size-6 rounded object-contain"
onError={(event) => {
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"
}
}}
/>
) : (
<WrenchIcon className="size-5 text-white/50" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-[14px] font-semibold text-[#FAFAFA]">
{displayedConnector.name ?? displayedConnector.id ?? "Connector"}
</p>
<StatusPill status={displayedConnector.status} />
</div>
{displayedConnector.description ? (
<p className="mt-1 text-[12px] leading-snug text-[#A1A1AA]">
{displayedConnector.description}
</p>
) : null}
{displayedConnector.kind === "knowledge" ? (
<p className="mt-1 text-[11px] text-[#737373]">
{displayedConnector.connectionCount ?? 0} connection
{displayedConnector.connectionCount === 1 ? "" : "s"} ·{" "}
{displayedConnector.documentCount ?? 0} indexed items
</p>
) : null}
{displayedConnector.syncError ? (
<p className="mt-1 text-[11px] text-red-300">
{displayedConnector.syncError}
</p>
) : null}
</div>
</div>
{displayedConnector.installSteps?.length ? (
<ol className="mt-3 space-y-3">
{displayedConnector.installSteps.map((step, index) => (
<li key={`${step.title}-${index}`} className="flex gap-2.5">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[#080B10] text-[10px] font-semibold text-[#4BA0FA]">
{index + 1}
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<p className="text-[12px] font-medium text-[#FAFAFA]">
{step.title}
</p>
{step.description ? (
<p className="text-[12px] leading-snug text-[#A1A1AA]">
{step.description}
</p>
) : null}
{step.code ? (
<ConnectorCodeBlock code={step.code} apiKey={revealedKey} />
) : null}
{step.link ? (
<a
href={step.link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[12px] text-[#4BA0FA] hover:text-[#86C5FF]"
>
{step.link.label}
<ExternalLinkIcon className="size-3" />
</a>
) : null}
</div>
</li>
))}
</ol>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{needsKey && displayedConnector.keyPluginId && !isUpgrade ? (
<RevealPluginKeyButton
pluginId={displayedConnector.keyPluginId}
onReveal={setRevealedKey}
/>
) : null}
{isUpgrade ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#4BA0FA]">
<ZapIcon className="size-3.5" />
Upgrade to connect
</span>
) : null}
{displayedConnector.kind === "knowledge" && !isUpgrade ? (
<KnowledgeBaseConnectAction
connector={displayedConnector}
onConnected={() => onConnectionsChanged?.()}
onPendingChange={onConnectionPendingChange}
attemptKey={connectionAttemptKey}
/>
) : null}
{displayedConnector.docsUrl ? (
<a
href={displayedConnector.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white"
>
<BookOpenIcon className="size-3.5" />
Docs
</a>
) : null}
</div>
</div>
)
}
function NovaConnectorCompactCard({
connector,
expanded,
onToggle,
}: {
connector: NovaConnectorCardData
expanded: boolean
onToggle: () => void
}) {
const iconSrc = connectorIconSrc(connector)
return (
<div className="min-w-0 rounded-xl">
<button
type="button"
aria-expanded={expanded}
onClick={onToggle}
className={cn(
"flex min-h-14 w-full cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 text-left transition-colors focus:outline-none focus-visible:border-[#4BA0FA]/50",
expanded
? "border-[#4BA0FA]/30 bg-[#111820]"
: "border-white/[0.08] bg-[#0D121A] hover:border-white/[0.14] hover:bg-[#111820]",
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-[#080B0F]">
{iconSrc ? (
<img
src={iconSrc}
alt=""
className="size-5 rounded object-contain"
onError={(event) => {
event.currentTarget.style.display = "none"
}}
/>
) : (
<WrenchIcon className="size-4 text-white/50" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-semibold text-[#FAFAFA]">
{connector.name ?? connector.id ?? "Connector"}
</p>
<p className="mt-0.5 truncate text-[11px] text-[#737373]">
{connector.kind === "mcp"
? "MCP"
: connector.kind === "knowledge"
? "Knowledge base"
: "Plugin"}
</p>
</div>
<StatusPill status={connector.status} />
{expanded ? (
<ChevronDownIcon className="size-3.5 shrink-0 text-[#737373]" />
) : (
<ChevronRightIcon className="size-3.5 shrink-0 text-[#737373]" />
)}
</button>
</div>
)
}
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 (
<div className="my-2 flex items-center gap-2 rounded-xl border border-white/[0.08] bg-[#0D121A] px-3 py-2 text-xs text-white/55">
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA]" />
<span>
{toolName === "listNovaKnowledgeBases" ||
toolName === "getNovaKnowledgeBase" ||
toolName === "startNovaKnowledgeBaseConnection"
? "Checking knowledge bases…"
: "Checking Supermemory setup…"}
</span>
</div>
)
}
if (isError) {
return (
<div className="my-2 rounded-xl border border-red-400/15 bg-red-400/10 px-3 py-2 text-xs text-red-200">
Couldn't load connector setup.
</div>
)
}
if (!output) return null
if (output.success === false) {
return (
<div className="my-2 rounded-xl border border-white/[0.08] bg-[#0D121A] p-3 text-sm text-white/80">
<p className="font-medium text-[#FAFAFA]">
{output.error ?? "Connector not found"}
</p>
{output.available?.length ? (
<p className="mt-1 text-xs text-[#A1A1AA]">
Try one of: {output.available.map((item) => item.name).join(", ")}
</p>
) : null}
</div>
)
}
const isConnectorList =
(toolName === "listNovaConnectors" ||
toolName === "listNovaKnowledgeBases") &&
displayedConnectors.length > 1
const expandedConnector =
isConnectorList && expandedConnectorKey
? displayedConnectors.find(
(connector) => connectorCardKey(connector) === expandedConnectorKey,
)
: null
return (
<div className="my-2 space-y-2">
{isConnectorList ? (
<p className="px-1 text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
{toolName === "listNovaKnowledgeBases"
? "Knowledge bases"
: "Supermemory setup options"}
</p>
) : null}
<div
className={cn(
isConnectorList && "grid grid-cols-1 gap-2 sm:grid-cols-2",
!isConnectorList && "space-y-2",
)}
>
{displayedConnectors.map((connector) =>
isConnectorList ? (
<NovaConnectorCompactCard
key={connectorCardKey(connector)}
connector={connector}
expanded={connectorCardKey(connector) === expandedConnectorKey}
onToggle={() => {
const nextKey = connectorCardKey(connector)
setExpandedConnectorKey((current) =>
current === nextKey ? null : nextKey,
)
}}
/>
) : (
<NovaConnectorCard
key={connectorCardKey(connector)}
connector={connector}
onConnectionsChanged={() => void refetchConnections()}
onConnectionPendingChange={setConnectionPending}
connectionAttemptKey={connectionAttemptKey}
/>
),
)}
</div>
{expandedConnector ? (
<div className="pt-1">
<NovaConnectorCard
connector={expandedConnector}
onConnectionsChanged={() => void refetchConnections()}
onConnectionPendingChange={setConnectionPending}
connectionAttemptKey={connectionAttemptKey}
/>
</div>
) : null}
</div>
)
}
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 (
<span className="group/cite relative inline">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 no-underline hover:text-blue-300"
>
{label}
</a>
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden -translate-x-1/2 pb-1.5 group-hover/cite:block">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto block w-64 overflow-hidden rounded-lg border border-white/10 bg-[#0B0F16]/95 p-2.5 no-underline shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl"
>
<span className="flex items-center gap-2">
<img
src={faviconUrl(host)}
alt=""
className="size-4 shrink-0 rounded bg-white/10"
loading="lazy"
referrerPolicy="no-referrer"
/>
<span className="truncate text-xs font-medium text-white/85">
{host}
</span>
</span>
{hasTitle ? (
<span className="mt-1.5 block line-clamp-2 text-xs leading-snug text-white/60">
{rawTitle}
</span>
) : path ? (
<span className="mt-1 block truncate text-[11px] text-white/35">
{path}
</span>
) : null}
</a>
</span>
</span>
)
}
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<string, CitationTarget>
documentByKnownId: Map<string, DocumentWithMemories>
}) {
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") ? (
<span className="inline-flex h-4 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1.5 text-[9px] font-medium leading-none text-white/50 transition-colors group-hover/source:text-white/70 group-focus-within/source:text-white/70">
{sourceId}
</span>
) : (
<>
{children}
<span className="ml-1 inline-flex h-3.5 min-w-3.5 translate-y-[-1px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1 text-[9px] font-medium leading-none text-white/45 transition-colors group-hover/source:text-white/65 group-focus-within/source:text-white/65">
{sourceId}
</span>
</>
)
return (
<span className="group/source relative inline rounded-[3px] border-b border-dotted border-white/20 bg-white/[0.025] px-px text-white/90 transition-colors hover:border-white/35 hover:bg-white/[0.045] focus-within:border-white/35 focus-within:bg-white/[0.045]">
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-inherit no-underline outline-none focus-visible:ring-1 focus-visible:ring-white/25"
>
{citationContent}
</a>
) : (
<button
type="button"
className="cursor-help border-0 bg-transparent p-0 text-inherit"
>
{citationContent}
</button>
)}
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden w-72 -translate-x-1/2 pb-2 group-hover/source:block group-focus-within/source:block">
<span className="pointer-events-auto block rounded-xl border border-white/10 bg-[#0B0F16]/95 p-3 text-left shadow-[0_16px_44px_rgba(0,0,0,0.48)] backdrop-blur-xl">
<span className="mb-1 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-white/85">
{title}
</span>
<span className="shrink-0 rounded-full bg-white/5 px-2 py-0.5 text-[10px] capitalize text-white/40">
{sourceKind(target, document)}
</span>
</span>
{summary ? (
<span className="line-clamp-3 text-xs leading-snug text-white/55">
{summary}
</span>
) : null}
{url ? (
<span className="mt-2 block text-xs font-medium text-blue-300">
Open source
</span>
) : null}
</span>
</span>
</span>
)
}
function makeMarkdownComponents(
sources: SourceUrlPart[],
citationIndex: Map<string, CitationTarget>,
documentByKnownId: Map<string, DocumentWithMemories>,
) {
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 (
<SourceCitationLink
sourceId={sourceId}
citationIndex={citationIndex}
documentByKnownId={documentByKnownId}
>
{children}
</SourceCitationLink>
)
}
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 <CitationLink href={safeHref} label={label} source={source} />
}
if (!safeHref) return <>{children}</>
return (
<a
href={safeHref}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{children}
</a>
)
},
}
}
function SourcesPill({ sources }: { sources: ActionSource[] }) {
const [expanded, setExpanded] = useState(false)
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80"
aria-expanded={expanded}
aria-label={`${count} ${count === 1 ? "source" : "sources"}`}
>
<span className="flex -space-x-1.5">
{faviconHosts.length > 0 ? (
faviconHosts.map((host) => (
<img
key={host}
src={faviconUrl(host)}
alt=""
className="size-4 rounded-full border border-[#0B0F16] bg-white/10 object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
))
) : (
<GlobeIcon className="size-3.5 text-emerald-400" />
)}
</span>
<span>{count === 1 ? "1 source" : `${count} sources`}</span>
</button>
{expanded && (
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
{sources.map((source) => {
const host = source.url ? sourceHost(source.url) : null
const content = (
<>
{host ? (
<img
src={faviconUrl(host)}
alt=""
className="mt-0.5 size-4 shrink-0 rounded-full bg-white/10"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<BookOpenIcon className="mt-0.5 size-4 shrink-0 text-white/35" />
)}
<span className="min-w-0">
<span className="block truncate text-white/80">
{source.title}
</span>
<span className="block truncate text-[11px] text-white/40">
{source.subtitle || host || "Saved memory"}
</span>
</span>
</>
)
return (
<li key={source.id}>
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
>
{content}
</a>
) : (
<div className="flex items-start gap-2 rounded-lg px-2 py-1.5">
{content}
</div>
)}
</li>
)
})}
</ul>
</div>
)}
</div>
)
}
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 (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && hasExpandable && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<TerminalIcon
className={cn(
"size-3 shrink-0",
isDone
? output?.exitCode === 0
? "text-emerald-400"
: "text-amber-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
$
</span>
<span
className={cn(
"flex-1 text-left truncate",
isDone
? output?.exitCode === 0
? "text-emerald-300"
: "text-amber-300"
: isLoading
? "text-blue-300"
: isError
? "text-red-300"
: "text-white/70",
)}
>
{cmd ?? "…"}
</span>
{isLoading && <span className="text-white/30 shrink-0">running</span>}
{isDone && !hasOutput && (
<span className="text-white/30 shrink-0">done</span>
)}
{isError && <span className="text-red-400/60 shrink-0">error</span>}
{hasExpandable &&
(expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
))}
</button>
{expanded && (hasOutput || (isError && errorText)) && (
<div className="px-3 py-2 space-y-1">
{output?.stdout && output.stdout.length > 0 && (
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stdout}
</pre>
)}
{output?.stderr && output.stderr.length > 0 && (
<pre className="text-amber-300/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stderr}
</pre>
)}
{isError && errorText && (
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{errorText}
</pre>
)}
</div>
)}
</div>
)
}
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const toolName = connectorToolName(part)
if (NOVA_CONNECTOR_TOOLS.has(toolName)) {
return <NovaConnectorToolDisplay part={part} />
}
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
if (isWebSearchToolName(toolName)) {
if (part.state === "output-available") return null
if (part.state === "error" || part.state === "output-error") {
return (
<div className="my-1 flex items-center gap-2 text-xs text-red-400/80">
<GlobeIcon className="size-3.5 shrink-0" />
<span>Web search failed</span>
</div>
)
}
return (
<div className="my-1 flex items-center gap-2 text-xs text-white/50">
<Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400/80" />
<span>Searching the web</span>
</div>
)
}
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 (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<Icon
className={cn(
"size-3 shrink-0",
isDone
? "text-emerald-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span
className={cn(
"font-medium",
isDone
? "text-emerald-400"
: isError
? "text-red-400"
: "text-blue-400",
)}
>
{label}
</span>
{isLoading && <span className="text-white/40 ml-auto">running</span>}
{isDone && <span className="text-white/40 ml-auto">done</span>}
{isError && <span className="text-red-400/60 ml-auto">error</span>}
{expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
)}
</button>
{expanded && (
<div className="px-3 py-2 space-y-2">
{part.input !== undefined && (
<div>
<div className="text-white/40 mb-1">Input</div>
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{typeof part.input === "string"
? part.input
: JSON.stringify(part.input, null, 2)}
</pre>
</div>
)}
{isDone && part.output !== undefined && (
<div>
<div className="text-white/40 mb-1">Output</div>
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2)}
</pre>
</div>
)}
{isError && errorText && (
<div>
<div className="text-red-400/80 mb-1">Error</div>
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 whitespace-pre-wrap break-all text-xs">
{errorText}
</pre>
</div>
)}
</div>
)}
</div>
)
}
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 (
<button
type="button"
onClick={() => void save()}
disabled={saved || saving}
title={label}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] transition-colors",
saved
? "text-emerald-400/75"
: saveState === "error"
? "text-red-300/75 hover:bg-red-400/10"
: "text-white/50 hover:bg-white/10 hover:text-white/80",
className,
)}
>
{saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : saved ? (
<CheckIcon className="size-3.5" />
) : (
<BookOpenIcon className="size-3.5" />
)}
<span>{saving ? "Saving…" : label}</span>
</button>
)
}
interface AgentMessageProps {
message: UIMessage
index: number
messagesLength: number
hoveredMessageId: string | null
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
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<string>()
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<string>()
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<string>()
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 (
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<RelatedMemories
message={message}
expandedMemories={expandedMemories}
onToggle={onToggleMemories}
/>
{researchDuration ? (
<p className="mb-1 text-xs text-white/40">
Research completed in{" "}
<span className="tabular-nums text-white/60">
{researchDuration}
</span>
</p>
) : 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 (
<div
key={`${message.id}-doc-${doc.sourceId}-${partIndex}`}
className="rounded-lg border border-[#1E2128] bg-[#0D121A] px-3 py-2 text-xs my-1"
>
<div className="text-white/40 mb-0.5">Document</div>
<div className="text-white/80">{doc.title}</div>
{doc.filename && (
<div className="text-white/50 text-[11px] mt-0.5">
{doc.filename}
</div>
)}
</div>
)
}
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 (
<div
key={`${message.id}-${partIndex}`}
className={cn("text-sm text-white/90 chat-markdown-content")}
>
<Streamdown components={markdownComponents}>
{researchMetadata
? normalizeResearchMarkdownForDisplay(
parseSourceAnnotatedMarkdown(
runText,
allowedSourceIds,
).markdown,
)
: parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
.markdown}
</Streamdown>
</div>
)
}
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 (
<ToolCallDisplay
key={`${message.id}-${dt.toolCallId}-${partIndex}`}
part={{
type: `tool-${dt.toolName}`,
state: displayState,
input: dt.input,
output:
dt.state === "output-available" ? dt.output : undefined,
toolCallId: dt.toolCallId,
errorText: dt.errorText,
}}
/>
)
}
if (part.type.startsWith("tool-")) {
if (shouldSkipNovaConnectorPart(message.parts, partIndex)) {
return null
}
return (
<ToolCallDisplay
key={`${message.id}-${partIndex}`}
part={part as ToolCallDisplayPart}
/>
)
}
return null
})}
</div>
</div>
{hasAssistantText && !hasAutoStartKnowledgeConnection && (
<div className="flex min-h-7 items-center gap-2">
<MessageActions
messageId={message.id}
messageText={copyText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
{actionSources.length > 0 && (
<div
className={cn(
"transition-opacity duration-200",
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
)}
>
<SourcesPill sources={actionSources} />
</div>
)}
{researchMetadata?.runId ? (
<>
<SaveResearchToMemoryButton
runId={researchMetadata.runId}
apiBase={researchApiBase}
className={
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0"
}
/>
<a
href={`${researchApiBase}/chat/research/${researchMetadata.runId}/report.md`}
download
title="Download research as Markdown"
className={cn(
"rounded p-1.5 text-white/50 transition-colors hover:bg-white/10 hover:text-white/80",
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
)}
>
<DownloadIcon className="size-3.5" />
</a>
</>
) : null}
{responseModelLabel && (
<span
className={cn(
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0",
)}
>
{responseModelLabel}
</span>
)}
</div>
)}
</div>
)
}