diff --git a/apps/web/components/chat/chat-empty-state.tsx b/apps/web/components/chat/chat-empty-state.tsx index 1fe230bf3..bef9894a4 100644 --- a/apps/web/components/chat/chat-empty-state.tsx +++ b/apps/web/components/chat/chat-empty-state.tsx @@ -7,8 +7,8 @@ import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" export const DEFAULT_CHAT_PROMPTS = [ "What do you know about me?", - "What have I been working on lately?", - "What themes keep showing up in my memories?", + "Set up Cursor", + "Show my active plugins", ] as const const SUGGESTION_PILL_CLASS = cn( diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index 1b7f69a82..b30b92f79 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -5,9 +5,12 @@ import type { UIMessage } from "@ai-sdk/react" import { Streamdown } from "streamdown" import { BookOpenIcon, + CheckIcon, ChevronDownIcon, ChevronRightIcon, ClockIcon, + CopyIcon, + ExternalLinkIcon, GlobeIcon, ListIcon, Loader2, @@ -16,6 +19,7 @@ import { TerminalIcon, WrenchIcon, XCircleIcon, + ZapIcon, } from "lucide-react" import { cn } from "@lib/utils" import { isWebSearchToolName } from "@/lib/chat-web-search-tools" @@ -64,6 +68,592 @@ function faviconUrl(host: string): string { return `https://www.google.com/s2/favicons?sz=64&domain=${host}` } +type NovaConnectorStatus = + | "active" + | "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" + id?: string + name?: string + icon?: string + description?: string + features?: string[] + docsUrl?: string + repoUrl?: string + installSteps?: NovaConnectorStep[] + status?: NovaConnectorStatus + requiresPro?: boolean + canGenerateKey?: boolean + keyPluginId?: string +} + +type NovaConnectorToolOutput = { + success?: boolean + error?: string + kind?: string + connectors?: NovaConnectorCardData[] + connector?: NovaConnectorCardData + keyReveal?: { pluginId: string; label?: string } | null + available?: Array<{ kind: "plugin" | "mcp"; id: string; name: string }> +} + +const NOVA_CONNECTOR_TOOLS = new Set([ + "listNovaConnectors", + "getNovaConnectorSetup", + "prepareNovaPluginSetup", +]) + +const CONNECTOR_ICON_FALLBACKS: Record = { + codex: "/images/plugins/codex.png", + cursor: "/images/plugins/cursor.png", + mcp_cursor: "/mcp-supported-tools/cursor.png", +} + +const STATUS_COPY: Record< + NovaConnectorStatus, + { label: string; className: string } +> = { + active: { + label: "Active", + className: "border-emerald-400/20 bg-emerald-400/10 text-emerald-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 unwrapToolOutput(output: unknown): NovaConnectorToolOutput | null { + if (typeof output === "string") { + return parseConnectorOutput(output) + } + if (!output || typeof output !== "object") return null + const record = output as Record + for (const key of ["value", "result", "data", "output"]) { + const nested = record[key] + if (nested && nested !== output) { + const parsed = unwrapToolOutput(nested) + if (parsed) return parsed + } + } + if ( + record.type === "json" && + record.value && + typeof record.value === "object" + ) { + return record.value as NovaConnectorToolOutput + } + if (record.type === "text" && typeof record.value === "string") { + return parseConnectorOutput(record.value) + } + if (typeof record.text === "string") { + return parseConnectorOutput(record.text) + } + return record as NovaConnectorToolOutput +} + +function connectorIconSrc( + connector: NovaConnectorCardData, +): string | undefined { + if (connector.id && CONNECTOR_ICON_FALLBACKS[connector.id]) { + return CONNECTOR_ICON_FALLBACKS[connector.id] + } + if (connector.icon?.endsWith("/codex.svg")) + return CONNECTOR_ICON_FALLBACKS.codex + if (connector.icon?.endsWith("/cursor.svg")) + return CONNECTOR_ICON_FALLBACKS.cursor + return connector.icon +} + +function connectorCardKey(connector: NovaConnectorCardData): string { + return `${connector.kind ?? "connector"}-${connector.id ?? connector.name ?? "unknown"}` +} + +function connectorIdentity( + output: NovaConnectorToolOutput | null, +): string | null { + if (!output) return null + if (output.connectors && output.connectors.length !== 1) return null + const connector = output.connector ?? output.connectors?.[0] + if (!connector) return null + return `${connector.kind ?? "connector"}:${connector.id ?? connector.name ?? ""}` +} + +function connectorOutputFromPart( + part: unknown, +): NovaConnectorToolOutput | null { + if (!part || typeof part !== "object") return null + const record = part as { + type?: string + toolName?: string + output?: unknown + } + const toolName = connectorToolNameFromPart(record) + if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return null + return unwrapToolOutput(record.output) +} + +function connectorToolPriority(toolName: string | null): number { + if (toolName === "prepareNovaPluginSetup") return 2 + if (toolName === "getNovaConnectorSetup") return 1 + return 0 +} + +function shouldSkipNovaConnectorPart(parts: unknown[], index: number): boolean { + const part = parts[index] + const toolName = connectorToolNameFromPart(part) + if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return false + const identity = connectorIdentity(connectorOutputFromPart(part)) + if (!identity) return false + const priority = connectorToolPriority(toolName) + + for (let i = 0; i < parts.length; i++) { + if (i === index) continue + const otherTool = connectorToolNameFromPart(parts[i]) + if (!otherTool || !NOVA_CONNECTOR_TOOLS.has(otherTool)) continue + const otherIdentity = connectorIdentity(connectorOutputFromPart(parts[i])) + if (otherIdentity !== identity) continue + const otherPriority = connectorToolPriority(otherTool) + if (i < index && otherPriority >= priority) return true + if (i > index && otherPriority > priority) return true + } + return false +} + +function StatusPill({ status }: { status?: NovaConnectorStatus }) { + const copy = + STATUS_COPY[status ?? "not_connected"] ?? STATUS_COPY.not_connected + return ( + + {copy.label} + + ) +} + +function MiniCopyButton({ text, label }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +function ConnectorCodeBlock({ + code, + apiKey, +}: { + code: string + apiKey?: string +}) { + const rendered = apiKey ? code.replaceAll("sm_...", apiKey) : code + return ( +
+
+				{rendered}
+			
+ +
+ ) +} + +function RevealPluginKeyButton({ + pluginId, + onReveal, +}: { + pluginId: string + onReveal: (key: string) => void +}) { + const [state, setState] = useState<"idle" | "loading" | "copied" | "error">( + "idle", + ) + return ( + + ) +} + +function NovaConnectorCard({ + connector, +}: { + connector: NovaConnectorCardData +}) { + const [revealedKey, setRevealedKey] = useState() + const needsKey = Boolean(connector.canGenerateKey && connector.keyPluginId) + const isUpgrade = connector.status === "upgrade_required" + const iconSrc = connectorIconSrc(connector) + return ( +
+
+
+ {iconSrc ? ( + { + const img = event.currentTarget + const fallback = connector.id + ? CONNECTOR_ICON_FALLBACKS[connector.id] + : undefined + if (fallback && img.dataset.fallbackApplied !== "true") { + img.dataset.fallbackApplied = "true" + img.src = fallback + } else { + img.style.display = "none" + } + }} + /> + ) : ( + + )} +
+
+
+

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

+ +
+ {connector.description ? ( +

+ {connector.description} +

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

    + {step.title} +

    + {step.description ? ( +

    + {step.description} +

    + ) : null} + {step.code ? ( + + ) : null} + {step.link ? ( + + {step.link.label} + + + ) : null} +
    +
  2. + ))} +
+ ) : null} + +
+ {needsKey && connector.keyPluginId && !isUpgrade ? ( + + ) : null} + {isUpgrade ? ( + + + Upgrade to connect + + ) : null} + {connector.docsUrl ? ( + + + Docs + + ) : null} +
+
+ ) +} + +function NovaConnectorCompactCard({ + connector, + expanded, + onToggle, +}: { + connector: NovaConnectorCardData + expanded: boolean + onToggle: () => void +}) { + const iconSrc = connectorIconSrc(connector) + return ( +
+ +
+ ) +} + +function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) { + const [expandedConnectorKey, setExpandedConnectorKey] = useState< + string | null + >(null) + const toolName = connectorToolName(part) + const output = unwrapToolOutput(part.output) + const isLoading = + part.state === "input-streaming" || part.state === "input-available" + const isError = part.state === "error" || part.state === "output-error" + if (isLoading) { + return ( +
+ + Checking Supermemory setup… +
+ ) + } + if (isError) { + return ( +
+ Couldn't load connector setup. +
+ ) + } + if (!output) return null + if (output.success === false) { + return ( +
+

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

+ {output.available?.length ? ( +

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

+ ) : null} +
+ ) + } + const connectors = output.connector + ? [output.connector] + : (output.connectors ?? []) + const isConnectorList = + toolName === "listNovaConnectors" && connectors.length > 1 + const expandedConnector = + isConnectorList && expandedConnectorKey + ? connectors.find( + (connector) => connectorCardKey(connector) === expandedConnectorKey, + ) + : null + return ( +
+ {isConnectorList ? ( +

+ Supermemory setup options +

+ ) : null} +
+ {connectors.map((connector) => + isConnectorList ? ( + { + const nextKey = connectorCardKey(connector) + setExpandedConnectorKey((current) => + current === nextKey ? null : nextKey, + ) + }} + /> + ) : ( + + ), + )} +
+ {expandedConnector ? ( +
+ +
+ ) : null} +
+ ) +} + function isWebSearchPart(part: { type: string; toolName?: string }): boolean { if (part.type === "dynamic-tool") { return isWebSearchToolName(part.toolName ?? "") @@ -363,7 +953,10 @@ function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) { function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) { const [expanded, setExpanded] = useState(false) - const toolName = part.type.replace("tool-", "") + const toolName = connectorToolName(part) + if (NOVA_CONNECTOR_TOOLS.has(toolName)) { + return + } if (toolName === "bash") { return } @@ -609,6 +1202,9 @@ export function AgentMessage({ ) } if (part.type === "dynamic-tool") { + if (shouldSkipNovaConnectorPart(message.parts, partIndex)) { + return null + } const dt = part as { type: "dynamic-tool" toolName: string @@ -636,6 +1232,9 @@ export function AgentMessage({ ) } if (part.type.startsWith("tool-")) { + if (shouldSkipNovaConnectorPart(message.parts, partIndex)) { + return null + } return ( code { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.375rem; + background: rgba(255, 255, 255, 0.06); + padding: 0.125rem 0.3125rem; + color: #fafafa; + font-size: 0.875em; +} + /* Model spams `---` between every section; headings already separate them */ .chat-markdown-content hr { display: none;