diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index b2329751..094b0961 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -89,6 +89,10 @@ import { type NovaResearchRun, researchPollDelayMs, } from "@/lib/nova-research" +import { + releaseNovaKnowledgeConnectionWindow, + reserveNovaKnowledgeConnectionWindowForMessage, +} from "@/lib/chat-knowledge-connectors" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" @@ -956,6 +960,10 @@ export function ChatSidebar({ if (hasBusy) return false const hasErrored = drafts.some((d) => d.status === "error") if (hasErrored) return false + const reservedKnowledgeProvider = + status !== "submitted" && status !== "streaming" + ? reserveNovaKnowledgeConnectionWindowForMessage(trimmed) + : null const chatIdForSend = threadId ?? fallbackChatId @@ -1107,6 +1115,9 @@ export function ChatSidebar({ return true } catch (error) { + if (reservedKnowledgeProvider) { + releaseNovaKnowledgeConnectionWindow(reservedKnowledgeProvider) + } pendingRequestAttachmentsRef.current = [] toast.error("Failed to send message", { description: diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index 4b8581fe..447140cd 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -1,6 +1,13 @@ "use client" -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" +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" @@ -23,8 +30,22 @@ import { 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, @@ -105,6 +126,8 @@ function faviconUrl(host: string): string { type NovaConnectorStatus = | "active" + | "syncing" + | "error" | "setup_pending" | "not_connected" | "upgrade_required" @@ -119,8 +142,9 @@ type NovaConnectorStep = { } type NovaConnectorCardData = { - kind?: "plugin" | "mcp" + kind?: "plugin" | "mcp" | "knowledge" id?: string + provider?: NovaKnowledgeBaseProvider name?: string icon?: string description?: string @@ -132,22 +156,38 @@ type NovaConnectorCardData = { 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 + requestedAction?: "connect" connectors?: NovaConnectorCardData[] connector?: NovaConnectorCardData keyReveal?: { pluginId: string; label?: string } | null - available?: Array<{ kind: "plugin" | "mcp"; id: string; name: string }> + 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 = { @@ -156,6 +196,25 @@ const CONNECTOR_ICON_FALLBACKS: Record = { 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 } @@ -164,6 +223,14 @@ const STATUS_COPY: Record< 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", @@ -290,7 +357,12 @@ function connectorOutputFromPart( function connectorToolPriority(toolName: string | null): number { if (toolName === "prepareNovaPluginSetup") return 2 - if (toolName === "getNovaConnectorSetup") return 1 + if ( + toolName === "getNovaConnectorSetup" || + toolName === "getNovaKnowledgeBase" || + toolName === "startNovaKnowledgeBaseConnection" + ) + return 1 return 0 } @@ -439,15 +511,332 @@ function RevealPluginKeyButton({ ) } -function NovaConnectorCard({ +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, + autoStart = false, + attemptKey, }: { connector: NovaConnectorCardData + onConnected: () => void + onPendingChange?: (pending: boolean) => void + autoStart?: boolean + attemptKey?: string +}) { + const [granolaOpen, setGranolaOpen] = useState(false) + const [connectionState, setConnectionState] = useState< + "idle" | "starting" | "waiting" | "error" + >("idle") + const [error, setError] = useState(null) + const startedRef = useRef(false) + 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 ( + !autoStart || + !provider || + connector.kind !== "knowledge" || + connector.canConnect === false || + startedRef.current + ) { + return + } + startedRef.current = true + if (attemptKey && sessionStorage.getItem(attemptKey)) { + setConnectionState("waiting") + onPendingChange?.(true) + return + } + void connect(false) + }, [ + attemptKey, + autoStart, + connect, + connector.canConnect, + connector.kind, + onPendingChange, + provider, + ]) + + 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, + autoStartConnection = false, + connectionAttemptKey, +}: { + connector: NovaConnectorCardData + onConnectionsChanged?: () => void + onConnectionPendingChange?: (pending: boolean) => void + autoStartConnection?: boolean + connectionAttemptKey?: string }) { const [revealedKey, setRevealedKey] = useState() - const needsKey = Boolean(connector.canGenerateKey && connector.keyPluginId) - const isUpgrade = connector.status === "upgrade_required" - const iconSrc = connectorIconSrc(connector) + 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} + autoStart={autoStartConnection} + attemptKey={connectionAttemptKey} + /> + )} +
+
+ ) + } return (
@@ -459,8 +848,8 @@ function NovaConnectorCard({ className="size-6 rounded object-contain" onError={(event) => { const img = event.currentTarget - const fallback = connector.id - ? CONNECTOR_ICON_FALLBACKS[connector.id] + const fallback = displayedConnector.id + ? CONNECTOR_ICON_FALLBACKS[displayedConnector.id] : undefined if (fallback && img.dataset.fallbackApplied !== "true") { img.dataset.fallbackApplied = "true" @@ -477,21 +866,33 @@ function NovaConnectorCard({

- {connector.name ?? connector.id ?? "Connector"} + {displayedConnector.name ?? displayedConnector.id ?? "Connector"}

- +
- {connector.description ? ( + {displayedConnector.description ? (

- {connector.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}
- {connector.installSteps?.length ? ( + {displayedConnector.installSteps?.length ? (
    - {connector.installSteps.map((step, index) => ( + {displayedConnector.installSteps.map((step, index) => (
  1. {index + 1} @@ -526,9 +927,9 @@ function NovaConnectorCard({ ) : null}
    - {needsKey && connector.keyPluginId && !isUpgrade ? ( + {needsKey && displayedConnector.keyPluginId && !isUpgrade ? ( ) : null} @@ -538,9 +939,18 @@ function NovaConnectorCard({ Upgrade to connect ) : null} - {connector.docsUrl ? ( + {displayedConnector.kind === "knowledge" && !isUpgrade ? ( + onConnectionsChanged?.()} + onPendingChange={onConnectionPendingChange} + autoStart={autoStartConnection} + attemptKey={connectionAttemptKey} + /> + ) : null} + {displayedConnector.docsUrl ? (

    - {connector.kind === "mcp" ? "MCP" : "Plugin"} + {connector.kind === "mcp" + ? "MCP" + : connector.kind === "knowledge" + ? "Knowledge base" + : "Plugin"}

    @@ -614,8 +1028,64 @@ 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 autoStartConnection = + toolName === "startNovaKnowledgeBaseConnection" && + output?.requestedAction === "connect" + 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" @@ -623,7 +1093,13 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { return (
    - Checking Supermemory setup… + + {toolName === "listNovaKnowledgeBases" || + toolName === "getNovaKnowledgeBase" || + toolName === "startNovaKnowledgeBaseConnection" + ? "Checking knowledge bases…" + : "Checking Supermemory setup…"} +
    ) } @@ -649,14 +1125,13 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) {
) } - const connectors = output.connector - ? [output.connector] - : (output.connectors ?? []) const isConnectorList = - toolName === "listNovaConnectors" && connectors.length > 1 + (toolName === "listNovaConnectors" || + toolName === "listNovaKnowledgeBases") && + displayedConnectors.length > 1 const expandedConnector = isConnectorList && expandedConnectorKey - ? connectors.find( + ? displayedConnectors.find( (connector) => connectorCardKey(connector) === expandedConnectorKey, ) : null @@ -664,7 +1139,9 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) {
{isConnectorList ? (

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

) : null}
- {connectors.map((connector) => + {displayedConnectors.map((connector) => isConnectorList ? ( void refetchConnections()} + onConnectionPendingChange={setConnectionPending} + autoStartConnection={autoStartConnection} + connectionAttemptKey={connectionAttemptKey} /> ), )}
{expandedConnector ? (
- + void refetchConnections()} + onConnectionPendingChange={setConnectionPending} + autoStartConnection={autoStartConnection} + connectionAttemptKey={connectionAttemptKey} + />
) : null}
@@ -1533,6 +2020,10 @@ export function AgentMessage({ 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], @@ -1592,6 +2083,7 @@ export function AgentMessage({ ) } 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 @@ -1672,7 +2164,7 @@ export function AgentMessage({ })} - {hasAssistantText && ( + {hasAssistantText && !hasAutoStartKnowledgeConnection && (
() + +const KNOWLEDGE_BASE_ALIASES: Record< + NovaKnowledgeBaseProvider, + readonly string[] +> = { + "google-drive": ["google drive", "gdrive"], + notion: ["notion"], + onedrive: ["onedrive", "one drive"], + granola: ["granola", "granola notes"], +} + +const KNOWLEDGE_BASE_NAMES: Record = { + "google-drive": "Google Drive", + notion: "Notion", + onedrive: "OneDrive", + granola: "Granola", +} + +function normalizeConnectIntent(value: string): string { + return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ") +} + +export function detectNovaKnowledgeBaseConnectIntent( + message: string, +): NovaKnowledgeBaseProvider | null { + if (!CONNECT_INTENT_RE.test(message)) return null + const normalized = normalizeConnectIntent(message) + return ( + NOVA_KNOWLEDGE_BASE_PROVIDERS.find((provider) => + KNOWLEDGE_BASE_ALIASES[provider].some((alias) => + normalized.includes(alias), + ), + ) ?? null + ) +} + +function closePendingConnectionWindow( + provider: NovaKnowledgeBaseProvider, + closePopup: boolean, +) { + const pending = pendingConnectionWindows.get(provider) + if (!pending) return + window.clearTimeout(pending.cleanupTimer) + pendingConnectionWindows.delete(provider) + if (closePopup && !pending.popup.closed) pending.popup.close() +} + +export function reserveNovaKnowledgeConnectionWindow( + provider: NovaKnowledgeBaseProvider, +): boolean { + if (provider === "granola" || typeof window === "undefined") return false + const existing = pendingConnectionWindows.get(provider) + if (existing && !existing.popup.closed) { + existing.popup.focus() + return true + } + if (existing) closePendingConnectionWindow(provider, false) + + const popup = window.open( + "about:blank", + `supermemory-nova-${provider}-connection`, + ) + if (!popup) return false + popup.opener = null + popup.document.title = `Connecting ${KNOWLEDGE_BASE_NAMES[provider]}…` + popup.document.body.style.cssText = + "margin:0;min-height:100vh;display:grid;place-items:center;background:#080b0f;color:#fafafa;font-family:ui-sans-serif,system-ui,sans-serif" + const status = popup.document.createElement("p") + status.textContent = `Preparing ${KNOWLEDGE_BASE_NAMES[provider]} connection…` + status.style.cssText = "font-size:16px;opacity:.75" + popup.document.body.append(status) + + const cleanupTimer = window.setTimeout( + () => closePendingConnectionWindow(provider, true), + 5 * 60 * 1000, + ) + pendingConnectionWindows.set(provider, { popup, cleanupTimer }) + return true +} + +export function reserveNovaKnowledgeConnectionWindowForMessage( + message: string, +): NovaKnowledgeBaseProvider | null { + const provider = detectNovaKnowledgeBaseConnectIntent(message) + if (!provider || provider === "granola") return null + reserveNovaKnowledgeConnectionWindow(provider) + return provider +} + +export function navigateReservedNovaKnowledgeConnectionWindow( + provider: NovaKnowledgeBaseProvider, + authLink: string, +): boolean { + const pending = pendingConnectionWindows.get(provider) + if (!pending || pending.popup.closed) { + if (pending) closePendingConnectionWindow(provider, false) + return false + } + pending.popup.location.replace(authLink) + pending.popup.focus() + return true +} + +export function isNovaKnowledgeConnectionWindowOpen( + provider: NovaKnowledgeBaseProvider, +): boolean { + const pending = pendingConnectionWindows.get(provider) + return Boolean(pending && !pending.popup.closed) +} + +export function releaseNovaKnowledgeConnectionWindow( + provider: NovaKnowledgeBaseProvider, +) { + if (typeof window === "undefined") return + closePendingConnectionWindow(provider, true) +} + +export function isNovaKnowledgeBaseProvider( + value: string | undefined, +): value is NovaKnowledgeBaseProvider { + return NOVA_KNOWLEDGE_BASE_PROVIDERS.some((provider) => provider === value) +} + +function latestConnection(connections: KnowledgeConnection[]) { + return connections.reduce((latest, item) => { + if (!latest) return item + const latestTime = Date.parse( + latest.lastSyncRun?.startedAt ?? latest.lastSyncRun?.completedAt ?? "", + ) + const itemTime = Date.parse( + item.lastSyncRun?.startedAt ?? item.lastSyncRun?.completedAt ?? "", + ) + return (Number.isFinite(itemTime) ? itemTime : 0) > + (Number.isFinite(latestTime) ? latestTime : 0) + ? item + : latest + }, undefined) +} + +export function deriveKnowledgeConnectorState({ + provider, + connections, + fallbackStatus, +}: { + provider: NovaKnowledgeBaseProvider + connections: KnowledgeConnection[] + fallbackStatus: NovaKnowledgeBaseStatus +}): KnowledgeConnectorState { + const providerConnections = connections.filter( + (connection) => connection.provider === provider, + ) + const latest = latestConnection(providerConnections) + const lastSyncStatus = latest?.lastSyncRun?.status + const status: NovaKnowledgeBaseStatus = + fallbackStatus === "upgrade_required" + ? "upgrade_required" + : lastSyncStatus === "running" + ? "syncing" + : lastSyncStatus === "failed" + ? "error" + : providerConnections.length > 0 + ? "active" + : "not_connected" + + return { + status, + connectionCount: providerConnections.length, + documentCount: providerConnections.reduce( + (total, connection) => total + (connection.metadata?.documentCount ?? 0), + 0, + ), + lastSyncStatus, + lastSyncAt: + latest?.lastSyncRun?.completedAt ?? latest?.lastSyncRun?.startedAt, + } +}