From 0fd57545507919e031548100d547e1234790618c Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 31 Jul 2026 20:10:59 +0530 Subject: [PATCH] connect knowledge bases from Nova chat --- .../components/chat/message/agent-message.tsx | 279 ++++++++++++++++-- .../web/lib/chat-knowledge-connectors.test.ts | 47 +++ apps/web/lib/chat-knowledge-connectors.ts | 94 ++++++ 3 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 apps/web/lib/chat-knowledge-connectors.test.ts create mode 100644 apps/web/lib/chat-knowledge-connectors.ts diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index 4b8581fe..6edb8f8c 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -23,8 +23,17 @@ import { XCircleIcon, ZapIcon, } from "lucide-react" +import { $fetch } from "@lib/api" import { cn } from "@lib/utils" +import { GranolaConnectModal } from "@/components/granola-connect-modal" import { isWebSearchToolName } from "@/lib/chat-web-search-tools" +import { + deriveKnowledgeConnectorState, + isNovaKnowledgeBaseProvider, + type KnowledgeConnection, + type NovaKnowledgeBaseProvider, + type NovaKnowledgeBaseStatus, +} from "@/lib/chat-knowledge-connectors" import { buildCitationIndex, fetchDocumentsByIds, @@ -105,6 +114,8 @@ function faviconUrl(host: string): string { type NovaConnectorStatus = | "active" + | "syncing" + | "error" | "setup_pending" | "not_connected" | "upgrade_required" @@ -119,8 +130,9 @@ type NovaConnectorStep = { } type NovaConnectorCardData = { - kind?: "plugin" | "mcp" + kind?: "plugin" | "mcp" | "knowledge" id?: string + provider?: NovaKnowledgeBaseProvider name?: string icon?: string description?: string @@ -132,6 +144,14 @@ 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 = { @@ -141,13 +161,19 @@ type NovaConnectorToolOutput = { 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", ]) const CONNECTOR_ICON_FALLBACKS: Record = { @@ -164,6 +190,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 +324,11 @@ function connectorOutputFromPart( function connectorToolPriority(toolName: string | null): number { if (toolName === "prepareNovaPluginSetup") return 2 - if (toolName === "getNovaConnectorSetup") return 1 + if ( + toolName === "getNovaConnectorSetup" || + toolName === "getNovaKnowledgeBase" + ) + return 1 return 0 } @@ -439,15 +477,119 @@ 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, }: { connector: NovaConnectorCardData + onConnected: () => void +}) { + const [granolaOpen, setGranolaOpen] = useState(false) + const [connecting, setConnecting] = useState(false) + const [error, setError] = useState(null) + const provider = connector.provider + if (!provider || connector.kind !== "knowledge") return null + + const disabled = connector.canConnect === false || connecting + const buttonLabel = + connector.status === "active" || connector.status === "syncing" + ? "Add another" + : connector.status === "error" + ? "Reconnect" + : "Connect" + + const connect = async () => { + setError(null) + if (provider === "granola") { + setGranolaOpen(true) + return + } + + setConnecting(true) + 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") + window.location.assign(authLink) + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Failed to connect") + setConnecting(false) + } + } + + return ( + <> + + {connector.canConnect === false && connector.disabledReason ? ( +

+ {connector.disabledReason} +

+ ) : null} + {error ? ( +

{error}

+ ) : null} + {provider === "granola" ? ( + + ) : null} + + ) +} + +function NovaConnectorCard({ + connector, + onConnectionsChanged, +}: { + connector: NovaConnectorCardData + onConnectionsChanged?: () => void }) { 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) return (
@@ -459,8 +601,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 +619,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 +680,9 @@ function NovaConnectorCard({ ) : null}
    - {needsKey && connector.keyPluginId && !isUpgrade ? ( + {needsKey && displayedConnector.keyPluginId && !isUpgrade ? ( ) : null} @@ -538,9 +692,15 @@ function NovaConnectorCard({ Upgrade to connect ) : null} - {connector.docsUrl ? ( + {displayedConnector.kind === "knowledge" && !isUpgrade ? ( + onConnectionsChanged?.()} + /> + ) : null} + {displayedConnector.docsUrl ? (

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

    @@ -616,6 +780,53 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { >(null) const toolName = connectorToolName(part) const output = unwrapToolOutput(part.output) + 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) => { + const connections = query.state.data as KnowledgeConnection[] | undefined + return connections?.some( + (connection) => connection.lastSyncRun?.status === "running", + ) + ? 5000 + : false + }, + }) + 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 +834,12 @@ function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { return (
    - Checking Supermemory setup… + + {toolName === "listNovaKnowledgeBases" || + toolName === "getNovaKnowledgeBase" + ? "Checking knowledge bases…" + : "Checking Supermemory setup…"} +
    ) } @@ -649,14 +865,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 +879,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()} /> ), )}
{expandedConnector ? (
- + void refetchConnections()} + />
) : null}
diff --git a/apps/web/lib/chat-knowledge-connectors.test.ts b/apps/web/lib/chat-knowledge-connectors.test.ts new file mode 100644 index 00000000..5aa5e56c --- /dev/null +++ b/apps/web/lib/chat-knowledge-connectors.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { + deriveKnowledgeConnectorState, + isNovaKnowledgeBaseProvider, +} from "./chat-knowledge-connectors" + +describe("Nova knowledge connector state", () => { + it("reports live document and connection counts", () => { + expect( + deriveKnowledgeConnectorState({ + provider: "notion", + fallbackStatus: "not_connected", + connections: [ + { + provider: "notion", + metadata: { documentCount: 8 }, + lastSyncRun: { + status: "completed", + completedAt: "2026-07-31T12:00:00.000Z", + }, + }, + ], + }), + ).toEqual({ + status: "active", + connectionCount: 1, + documentCount: 8, + lastSyncStatus: "completed", + lastSyncAt: "2026-07-31T12:00:00.000Z", + }) + }) + + it("preserves upgrade restrictions while refreshing connection state", () => { + expect( + deriveKnowledgeConnectorState({ + provider: "onedrive", + fallbackStatus: "upgrade_required", + connections: [{ provider: "onedrive" }], + }).status, + ).toBe("upgrade_required") + }) + + it("recognizes only supported knowledge-base providers", () => { + expect(isNovaKnowledgeBaseProvider("granola")).toBe(true) + expect(isNovaKnowledgeBaseProvider("dropbox")).toBe(false) + }) +}) diff --git a/apps/web/lib/chat-knowledge-connectors.ts b/apps/web/lib/chat-knowledge-connectors.ts new file mode 100644 index 00000000..1d504d08 --- /dev/null +++ b/apps/web/lib/chat-knowledge-connectors.ts @@ -0,0 +1,94 @@ +export const NOVA_KNOWLEDGE_BASE_PROVIDERS = [ + "google-drive", + "notion", + "onedrive", + "granola", +] as const + +export type NovaKnowledgeBaseProvider = + (typeof NOVA_KNOWLEDGE_BASE_PROVIDERS)[number] + +export type NovaKnowledgeBaseStatus = + | "active" + | "syncing" + | "error" + | "not_connected" + | "upgrade_required" + +export type KnowledgeConnection = { + provider: string + metadata?: { documentCount?: number } | null + lastSyncRun?: { + status?: "running" | "completed" | "failed" + startedAt?: string + completedAt?: string + } | null +} + +export type KnowledgeConnectorState = { + status: NovaKnowledgeBaseStatus + connectionCount: number + documentCount: number + lastSyncStatus?: "running" | "completed" | "failed" + lastSyncAt?: string +} + +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, + } +}