mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-12 23:01:07 +00:00
Refine Codex and OpenCode OAuth integration status (#1048)
Co-authored-by: Mahesh Sanikommu <maheshthedev@gmail.com>
This commit is contained in:
parent
fdd966f20e
commit
c606f76f6d
9 changed files with 814 additions and 271 deletions
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../connect/page"
|
||||
|
|
@ -99,7 +99,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
"Captures coding decisions and patterns automatically",
|
||||
"Builds persistent user profile across projects",
|
||||
],
|
||||
icon: "/images/plugins/codex.svg",
|
||||
icon: "/images/plugins/codex.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +199,7 @@ function AuthConnectContent() {
|
|||
|
||||
const redirectUrl = new URL(callback)
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
redirectUrl.searchParams.set("api_url", API_URL)
|
||||
window.location.href = redirectUrl.toString()
|
||||
} catch (err) {
|
||||
console.error("Failed to get API key:", err)
|
||||
|
|
|
|||
|
|
@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
|
|||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<p className="text-[11px] text-[#737373] truncate">
|
||||
{parsed.formatLabel}
|
||||
</p>
|
||||
</div>
|
||||
{parsed.identifierValue && (
|
||||
<p className="text-[10px] text-[#737373] truncate max-w-[45%]">
|
||||
{parsed.identifierValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[6px]">
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -47,24 +47,6 @@ function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
|
|||
>
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#B7BDC7]",
|
||||
)}
|
||||
>
|
||||
{parsed.formatLabel}
|
||||
</span>
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#8E97A3]",
|
||||
)}
|
||||
>
|
||||
{parsed.identifierLabel}: {parsed.identifierValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import type { UseMutationResult } from "@tanstack/react-query"
|
|||
import { toast } from "sonner"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { parsePluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginDetails } from "./plugin-details"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -270,13 +269,8 @@ export function DocumentModal({
|
|||
],
|
||||
)
|
||||
|
||||
const hasPluginInsights =
|
||||
pluginDocument &&
|
||||
pluginDocument.kind !== "claude-code-doc" &&
|
||||
pluginDocument.kind !== "openclaw-session"
|
||||
const hasDocumentInsights = Boolean(
|
||||
hasPluginInsights ||
|
||||
_document?.summary ||
|
||||
_document?.summary ||
|
||||
pluginDocument?.summary ||
|
||||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
|
||||
)
|
||||
|
|
@ -303,7 +297,6 @@ export function DocumentModal({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{hasPluginInsights && <PluginDetails parsed={pluginDocument} />}
|
||||
{_document && (_document.summary || pluginDocument?.summary) && (
|
||||
<DocumentSummary
|
||||
memoryEntries={_document.memoryEntries}
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import Image from "next/image"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
|
||||
function DetailPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[#F3F4F6] break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="bg-[#14161A] p-3 rounded-[14px] space-y-3 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[16px] font-semibold text-[#FAFAFA] leading-[125%]">
|
||||
Details
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center gap-1.5 text-[12px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{parsed.pluginIconSrc && (
|
||||
<Image
|
||||
src={parsed.pluginIconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<DetailPill label="Format" value={parsed.formatLabel} />
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<DetailPill
|
||||
label={parsed.identifierLabel}
|
||||
value={parsed.identifierValue}
|
||||
/>
|
||||
)}
|
||||
{parsed.clientLabel && parsed.clientValue && (
|
||||
<DetailPill label={parsed.clientLabel} value={parsed.clientValue} />
|
||||
)}
|
||||
</div>
|
||||
{parsed.artifacts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Outputs
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{parsed.artifacts.map((artifact, index) => (
|
||||
<DetailPill
|
||||
key={`${artifact.label}-${artifact.value}-${index}`}
|
||||
label={artifact.label}
|
||||
value={artifact.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,18 +23,20 @@ import {
|
|||
ArrowRight,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import { CHROME_EXTENSION_URL } from "@lib/constants"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import Image from "next/image"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import type { ViewParamValue } from "@/lib/search-params"
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -46,11 +48,11 @@ import {
|
|||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { toast } from "sonner"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import {
|
||||
PLUGIN_CATALOG,
|
||||
FREE_TIER_PLUGIN_IDS,
|
||||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
|
|
@ -64,6 +66,57 @@ interface ConnectedKey {
|
|||
keyId: string
|
||||
keyStart: string | null
|
||||
pluginId: string
|
||||
lastRequest?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
function toIsoDate(value: string | Date | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function parsePluginAuthKeys(
|
||||
apiKeys: ListedApiKey[],
|
||||
keyPrefix: (key: ListedApiKey) => string | null,
|
||||
): { active: ConnectedKey[]; setup: ConnectedKey[] } {
|
||||
const active: ConnectedKey[] = []
|
||||
const setup: ConnectedKey[] = []
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
typeof key.metadata === "string"
|
||||
? (JSON.parse(key.metadata) as {
|
||||
sm_type?: string
|
||||
sm_client?: string
|
||||
})
|
||||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
if (metadata.sm_type !== "plugin_auth" || !metadata.sm_client) continue
|
||||
const entry: ConnectedKey = {
|
||||
keyId: key.id,
|
||||
keyStart: keyPrefix(key),
|
||||
pluginId: normalizePluginClientId(metadata.sm_client),
|
||||
lastRequest: toIsoDate(key.lastRequest),
|
||||
createdAt: toIsoDate(key.createdAt),
|
||||
}
|
||||
if (key.lastRequest) active.push(entry)
|
||||
else setup.push(entry)
|
||||
} catch {}
|
||||
}
|
||||
return { active, setup }
|
||||
}
|
||||
|
||||
type ListedApiKey = {
|
||||
id: string
|
||||
name?: string | null
|
||||
createdAt?: string | Date | null
|
||||
enabled?: boolean
|
||||
lastRequest?: string | Date | null
|
||||
metadata: string | Record<string, unknown> | null
|
||||
start?: string | null
|
||||
}
|
||||
|
||||
type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import"
|
||||
|
|
@ -185,7 +238,7 @@ const catParam = parseAsStringEnum<CategoryFilter>([
|
|||
|
||||
const CATEGORY_LABEL: Record<CategoryFilter, string> = {
|
||||
all: "All",
|
||||
connected: "Connected",
|
||||
connected: "Active",
|
||||
plugins: "Plugins",
|
||||
"knowledge-bases": "Knowledge bases",
|
||||
"apps-extensions": "Apps & extensions",
|
||||
|
|
@ -423,7 +476,7 @@ function ProChip() {
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-[4px] border border-[#4BA0FA]/25 bg-[#4BA0FA]/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#4BA0FA]",
|
||||
"ml-auto shrink-0 pl-2 text-[10px] font-semibold uppercase tracking-wide text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
|
|
@ -487,56 +540,43 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ConnectedPill({
|
||||
keys,
|
||||
onRevoke,
|
||||
function ActiveButton({
|
||||
count,
|
||||
lastActive,
|
||||
onClick,
|
||||
}: {
|
||||
keys: ConnectedKey[]
|
||||
onRevoke: (keyId: string) => void
|
||||
count: number
|
||||
lastActive?: string | null
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-8 min-w-[104px] shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
<ChevronDown className="size-3 text-[#737373]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<p className="px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-[#737373]">
|
||||
{keys.length > 1 ? `${keys.length} connections` : "Connection"}
|
||||
</p>
|
||||
<div className="flex flex-col">
|
||||
{keys.map((k) => (
|
||||
<div
|
||||
key={k.keyId}
|
||||
className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-[#A1A1AA]">
|
||||
{k.keyStart ? `${k.keyStart}…` : "API key"}
|
||||
</span>
|
||||
<DisconnectButton onConfirm={() => onRevoke(k.keyId)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"group flex shrink-0 cursor-pointer items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] transition-colors sm:text-[13px]",
|
||||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
<span className="group-hover:underline">
|
||||
{count > 1 ? `${count} active` : "Active"}
|
||||
</span>
|
||||
{count <= 1 && lastActive && (
|
||||
<span className="text-[11px] font-normal text-[#737373]">
|
||||
· {formatRelativeTime(lastActive)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FinishSetupButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<PillButton onClick={onClick}>
|
||||
<span className="size-[7px] rounded-full bg-[#EAB308]" />
|
||||
Finish setup
|
||||
</PillButton>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -545,8 +585,7 @@ function ConnectionsCountPill({ count }: { count: number }) {
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-8 min-w-[104px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] sm:text-[13px]",
|
||||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
|
|
@ -594,7 +633,7 @@ function ItemCard({
|
|||
</div>
|
||||
<div className="flex flex-1 flex-col justify-end gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{leftIndicator}
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -1002,6 +1041,12 @@ export function IntegrationsView() {
|
|||
key: string
|
||||
pluginId: string | null
|
||||
}>({ open: false, key: "", pluginId: null })
|
||||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryFn: async () => {
|
||||
|
|
@ -1030,47 +1075,63 @@ export function IntegrationsView() {
|
|||
enabled: hasProProduct,
|
||||
})
|
||||
|
||||
type ApiKey = {
|
||||
id: string
|
||||
metadata: Record<string, unknown> | null
|
||||
start: string | null
|
||||
}
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
|
||||
queryKey: ["api-keys", org?.id],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = (await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})) as unknown as ApiKey[]
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery<ListedApiKey[]>(
|
||||
{
|
||||
queryKey: ["api-keys", org?.id],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/keys`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { keys?: ListedApiKey[] }
|
||||
return data.keys ?? []
|
||||
},
|
||||
enabled: !!org?.id,
|
||||
staleTime: 30 * 1000,
|
||||
},
|
||||
enabled: !!org?.id,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
)
|
||||
|
||||
const connectedPlugins = useMemo<ConnectedKey[]>(() => {
|
||||
const out: ConnectedKey[] = []
|
||||
for (const key of apiKeys) {
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
typeof key.metadata === "string"
|
||||
? (JSON.parse(key.metadata) as {
|
||||
sm_type?: string
|
||||
sm_client?: string
|
||||
})
|
||||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
out.push({
|
||||
keyId: key.id,
|
||||
keyStart: key.start ?? null,
|
||||
pluginId: metadata.sm_client,
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
const keyPrefix = useCallback((key: ListedApiKey): string | null => {
|
||||
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
|
||||
}, [])
|
||||
|
||||
const { active: activePlugins, setup: setupPlugins } = useMemo(
|
||||
() => parsePluginAuthKeys(apiKeys, keyPrefix),
|
||||
[apiKeys, keyPrefix],
|
||||
)
|
||||
|
||||
const activePluginById = useMemo(() => {
|
||||
const map = new Map<string, ConnectedKey>()
|
||||
for (const key of activePlugins) {
|
||||
const existing = map.get(key.pluginId)
|
||||
if (!existing) {
|
||||
map.set(key.pluginId, key)
|
||||
continue
|
||||
}
|
||||
const a = key.lastRequest ? new Date(key.lastRequest).getTime() : 0
|
||||
const b = existing.lastRequest
|
||||
? new Date(existing.lastRequest).getTime()
|
||||
: 0
|
||||
if (a >= b) map.set(key.pluginId, key)
|
||||
}
|
||||
return out
|
||||
}, [apiKeys])
|
||||
return map
|
||||
}, [activePlugins])
|
||||
|
||||
const activeCountByPlugin = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const key of activePlugins) {
|
||||
map.set(key.pluginId, (map.get(key.pluginId) ?? 0) + 1)
|
||||
}
|
||||
return map
|
||||
}, [activePlugins])
|
||||
|
||||
const setupPluginIds = useMemo(
|
||||
() => new Set(setupPlugins.map((k) => k.pluginId)),
|
||||
[setupPlugins],
|
||||
)
|
||||
|
||||
const connectionsByProvider = useMemo(() => {
|
||||
const out: Record<ConnectorProvider, Connection[]> = {
|
||||
|
|
@ -1188,6 +1249,7 @@ export function IntegrationsView() {
|
|||
)
|
||||
|
||||
const [category, setCategory] = useQueryState("cat", catParam)
|
||||
const [, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const [mcpClient, setMcpClient] = useQueryState("mcpClient", parseAsString)
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
|
@ -1218,14 +1280,14 @@ export function IntegrationsView() {
|
|||
const isItemConnected = useCallback(
|
||||
(item: Item): boolean => {
|
||||
if (item.kind === "plugin") {
|
||||
return connectedPlugins.some((k) => k.pluginId === item.pluginId)
|
||||
return activePluginById.has(item.pluginId)
|
||||
}
|
||||
if (item.kind === "connector") {
|
||||
return connectionsByProvider[item.provider].length > 0
|
||||
}
|
||||
return false
|
||||
},
|
||||
[connectedPlugins, connectionsByProvider],
|
||||
[activePluginById, connectionsByProvider],
|
||||
)
|
||||
|
||||
const counts = useMemo<Record<CategoryFilter, number>>(
|
||||
|
|
@ -1251,9 +1313,7 @@ export function IntegrationsView() {
|
|||
}
|
||||
}, [category, counts, setCategory])
|
||||
|
||||
const claudeCodeConnected = connectedPlugins.some(
|
||||
(k) => k.pluginId === "claude_code",
|
||||
)
|
||||
const claudeCodeConnected = activePluginById.has("claude_code")
|
||||
const claudeCodeNeedsPro =
|
||||
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code")
|
||||
|
||||
|
|
@ -1307,7 +1367,7 @@ export function IntegrationsView() {
|
|||
),
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
|
||||
ctaLabel: claudeCodeConnected
|
||||
? "Connected"
|
||||
? "Active"
|
||||
: claudeCodeNeedsPro
|
||||
? "Upgrade"
|
||||
: "Connect",
|
||||
|
|
@ -1362,13 +1422,58 @@ export function IntegrationsView() {
|
|||
const renderRight = (item: Item): ReactNode => {
|
||||
switch (item.kind) {
|
||||
case "plugin": {
|
||||
const keys = connectedPlugins.filter(
|
||||
(k) => k.pluginId === item.pluginId,
|
||||
)
|
||||
const activeKey = activePluginById.get(item.pluginId)
|
||||
const activeCount = activeCountByPlugin.get(item.pluginId) ?? 0
|
||||
const needsProUpgrade =
|
||||
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
|
||||
if (keys.length > 0) {
|
||||
return <ConnectedPill keys={keys} onRevoke={handleRevokePluginKey} />
|
||||
if (activeKey) {
|
||||
const busy = connectingPlugin === item.pluginId
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<ActiveButton
|
||||
count={activeCount}
|
||||
lastActive={activeKey.lastRequest}
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
setConnectedPluginId(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Connect ${item.name} to another agent`}
|
||||
title="Connect another agent"
|
||||
onClick={() => {
|
||||
if (needsProUpgrade) {
|
||||
handleUpgrade()
|
||||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] disabled:opacity-50 sm:size-9",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (setupPluginIds.has(item.pluginId)) {
|
||||
return (
|
||||
<FinishSetupButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
setFinishSetupPluginId(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (needsProUpgrade) {
|
||||
return (
|
||||
|
|
@ -1399,7 +1504,28 @@ export function IntegrationsView() {
|
|||
case "connector": {
|
||||
const count = connectionsByProvider[item.provider].length
|
||||
const needsProUpgrade = !isAutumnLoading && !hasProProduct
|
||||
if (count > 0) return <ConnectionsCountPill count={count} />
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<ConnectionsCountPill count={count} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another knowledge source"
|
||||
title="Add another knowledge source"
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
void setAddDoc("connect")
|
||||
}}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (needsProUpgrade) {
|
||||
return (
|
||||
<PillButton onClick={handleUpgrade}>
|
||||
|
|
@ -1498,30 +1624,32 @@ export function IntegrationsView() {
|
|||
/>
|
||||
)
|
||||
|
||||
const renderLeftIndicator = (item: Item): ReactNode => {
|
||||
if (item.kind === "plugin") {
|
||||
const isConnected = connectedPlugins.some(
|
||||
(k) => k.pluginId === item.pluginId,
|
||||
)
|
||||
return isConnected ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#00AC3F]" />
|
||||
) : null
|
||||
}
|
||||
if (item.kind === "connector") {
|
||||
const count = connectionsByProvider[item.provider].length
|
||||
return count > 0 ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#00AC3F]" />
|
||||
) : null
|
||||
}
|
||||
const renderLeftIndicator = (_item: Item): ReactNode => {
|
||||
return null
|
||||
}
|
||||
|
||||
const dialogPlugin = newKey.pluginId
|
||||
? PLUGIN_CATALOG[newKey.pluginId]
|
||||
: undefined
|
||||
const connectedDialogPlugin = connectedPluginId
|
||||
? PLUGIN_CATALOG[connectedPluginId]
|
||||
: undefined
|
||||
const connectedDialogKeys = connectedPluginId
|
||||
? activePlugins.filter((key) => key.pluginId === connectedPluginId)
|
||||
: []
|
||||
const connectedDialogNeedsPro =
|
||||
!!connectedPluginId &&
|
||||
!isAutumnLoading &&
|
||||
!hasProProduct &&
|
||||
!isFreeTierPlugin(connectedPluginId)
|
||||
const finishSetupPlugin = finishSetupPluginId
|
||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
||||
: undefined
|
||||
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const setupSteps: InstallStep[] = stepsEmbedKey
|
||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||
const setupSteps: InstallStep[] = skipGeneratedKeyStep
|
||||
? pluginSteps
|
||||
: [
|
||||
{
|
||||
|
|
@ -1538,9 +1666,7 @@ export function IntegrationsView() {
|
|||
return (
|
||||
<div className="flex-1 p-4 md:p-6 pt-2">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-5">
|
||||
{!q && category !== "connected" && (
|
||||
<FeaturedHero picks={featuredPicks} />
|
||||
)}
|
||||
{!q && <FeaturedHero picks={featuredPicks} />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -1575,7 +1701,7 @@ export function IntegrationsView() {
|
|||
? `No integrations match “${search}”.`
|
||||
: "Nothing in this category yet."}
|
||||
</p>
|
||||
) : q ? (
|
||||
) : q || category !== "all" ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleItems.map((item) => renderItemCard(item))}
|
||||
</div>
|
||||
|
|
@ -1699,6 +1825,246 @@ export function IntegrationsView() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!connectedPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConnectedPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[520px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{connectedDialogPlugin?.name ?? "Plugin"} connection
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{connectedDialogPlugin && (
|
||||
<IconBox>
|
||||
<Image
|
||||
src={connectedDialogPlugin.icon}
|
||||
alt={connectedDialogPlugin.name}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</IconBox>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
{connectedDialogPlugin?.name ?? "Plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-[#00AC3F]">
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Active
|
||||
{activePluginById.get(connectedPluginId ?? "")?.lastRequest && (
|
||||
<span className="text-[#737373]">
|
||||
·{" "}
|
||||
{formatRelativeTime(
|
||||
activePluginById.get(connectedPluginId ?? "")
|
||||
?.lastRequest,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{connectedDialogPlugin?.docsUrl && (
|
||||
<a
|
||||
href={connectedDialogPlugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" /> Docs
|
||||
</a>
|
||||
)}
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-2 rounded-[14px] bg-[#14161A] p-4",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#737373]">
|
||||
{connectedDialogKeys.length > 1
|
||||
? `${connectedDialogKeys.length} connections`
|
||||
: "Connection"}
|
||||
</p>
|
||||
{connectedDialogKeys.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{connectedDialogKeys.map((key) => (
|
||||
<div
|
||||
key={key.keyId}
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-lg bg-[#0D121A]/70 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[12px] text-[#A1A1AA]">
|
||||
{key.keyStart ? `${key.keyStart}...` : "API key"}
|
||||
</span>
|
||||
<DisconnectButton
|
||||
onConfirm={() => void handleRevokePluginKey(key.keyId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-[#A1A1AA]">
|
||||
No active connection was found.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[12px] text-[#737373]">
|
||||
Connect this plugin to another agent to run them in parallel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
{connectedDialogNeedsPro ? (
|
||||
<PillButton onClick={handleUpgrade}>
|
||||
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade to connect
|
||||
more
|
||||
</PillButton>
|
||||
) : (
|
||||
<PillButton
|
||||
onClick={() => {
|
||||
if (!connectedPluginId) return
|
||||
const pluginId = connectedPluginId
|
||||
setConnectedPluginId(null)
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
{connectingPlugin === connectedPluginId ? (
|
||||
<>
|
||||
<Loader className="size-3.5 animate-spin" /> Connecting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="size-3.5 text-[#4BA0FA]" /> Connect another
|
||||
</>
|
||||
)}
|
||||
</PillButton>
|
||||
)}
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!finishSetupPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFinishSetupPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{finishSetupPlugin && (
|
||||
<IconBox>
|
||||
<Image
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</IconBox>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
Complete install in the tool — this card turns active after the
|
||||
first API call.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{finishSetupSteps.length > 0 ? (
|
||||
<InstallSteps steps={finishSetupSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication, then send a test memory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@ import { hasActivePlan } from "@lib/queries"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Loader,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -17,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
|||
import {
|
||||
PLUGIN_CATALOG,
|
||||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
|
|
@ -27,9 +36,20 @@ interface ConnectedPlugin {
|
|||
keyId: string
|
||||
pluginId: string
|
||||
createdAt: string
|
||||
lastRequest?: string | null
|
||||
keyStart?: string | null
|
||||
}
|
||||
|
||||
type ListedApiKey = {
|
||||
id: string
|
||||
name?: string | null
|
||||
createdAt: string
|
||||
enabled?: boolean
|
||||
lastRequest: string | null
|
||||
metadata: string | Record<string, unknown> | null
|
||||
start?: string | null
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
|
|
@ -121,13 +141,77 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ConnectedPill({
|
||||
function toDate(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return "Never"
|
||||
const absMs = Math.abs(Date.now() - date.getTime())
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (absMs < minute) return "Just now"
|
||||
if (absMs < hour) return `${Math.round(absMs / minute)}m ago`
|
||||
if (absMs < day) return `${Math.round(absMs / hour)}h ago`
|
||||
return `${Math.round(absMs / day)}d ago`
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return "Unknown"
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function maskKey(start: string | null | undefined): string {
|
||||
if (!start) return "sm_********"
|
||||
return `${start}********`
|
||||
}
|
||||
|
||||
function keyPrefix(key: ListedApiKey): string | null {
|
||||
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
|
||||
}
|
||||
|
||||
function DetailStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 truncate text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivePill({
|
||||
plugin,
|
||||
connectedKeys,
|
||||
onRevoke,
|
||||
}: {
|
||||
plugin: PluginInfo
|
||||
connectedKeys: ConnectedPlugin[]
|
||||
onRevoke: (keyId: string) => void
|
||||
}) {
|
||||
const primaryKey = connectedKeys[0]
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -140,7 +224,7 @@ function ConnectedPill({
|
|||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
ACTIVE
|
||||
<ChevronDown className="size-3 text-[#737373]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -148,13 +232,67 @@ function ConnectedPill({
|
|||
align="end"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
|
||||
"w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PluginIconBox src={plugin.icon} alt={plugin.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[15px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">Active</p>
|
||||
</div>
|
||||
<span className="size-3 rounded-full bg-[#37D67A]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 border-y border-white/[0.08] py-4 sm:grid-cols-3">
|
||||
<DetailStat label="API key" value={maskKey(primaryKey?.keyStart)} />
|
||||
<DetailStat
|
||||
label="Last active"
|
||||
value={formatRelativeTime(primaryKey?.lastRequest)}
|
||||
/>
|
||||
<DetailStat
|
||||
label="Connected"
|
||||
value={formatRelativeTime(primaryKey?.createdAt)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
{plugin.docsUrl && (
|
||||
<a
|
||||
href={plugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
|
||||
>
|
||||
Docs <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
{plugin.githubUrl && (
|
||||
<a
|
||||
href={plugin.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
|
||||
>
|
||||
GitHub <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
<span className="ml-auto text-[11px] text-[#737373]">
|
||||
Connected {formatDate(primaryKey?.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
"px-2 pb-1.5 pt-4 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connectedKeys.length > 1
|
||||
|
|
@ -188,22 +326,26 @@ function PluginRow({
|
|||
plugin,
|
||||
pluginId,
|
||||
connectedKeys,
|
||||
needsSetup,
|
||||
needsProUpgrade,
|
||||
isConnecting,
|
||||
actionsDisabled,
|
||||
onConnect,
|
||||
onUpgrade,
|
||||
onRevoke,
|
||||
onFinishSetup,
|
||||
}: {
|
||||
plugin: PluginInfo
|
||||
pluginId: string
|
||||
connectedKeys: ConnectedPlugin[]
|
||||
needsSetup: boolean
|
||||
needsProUpgrade: boolean
|
||||
isConnecting: boolean
|
||||
actionsDisabled: boolean
|
||||
onConnect: (id: string) => void
|
||||
onUpgrade: () => void
|
||||
onRevoke: (keyId: string) => void
|
||||
onFinishSetup: (id: string) => void
|
||||
}) {
|
||||
const isConnected = connectedKeys.length > 0
|
||||
return (
|
||||
|
|
@ -240,7 +382,16 @@ function PluginRow({
|
|||
<div className="col-start-2 flex min-w-0 shrink-0 items-center gap-2 sm:col-start-auto sm:gap-4">
|
||||
{plugin.docsUrl && <DocsLink href={plugin.docsUrl} />}
|
||||
{isConnected ? (
|
||||
<ConnectedPill connectedKeys={connectedKeys} onRevoke={onRevoke} />
|
||||
<ActivePill
|
||||
plugin={plugin}
|
||||
connectedKeys={connectedKeys}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
) : needsSetup ? (
|
||||
<PillButton onClick={() => onFinishSetup(pluginId)}>
|
||||
<span className="size-[7px] rounded-full bg-[#EAB308]" />
|
||||
Finish setup
|
||||
</PillButton>
|
||||
) : needsProUpgrade ? (
|
||||
<PillButton onClick={onUpgrade}>
|
||||
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
|
||||
|
|
@ -307,6 +458,9 @@ export function PluginsDetail() {
|
|||
const queryClient = useQueryClient()
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
|
||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
open: boolean
|
||||
key: string
|
||||
|
|
@ -332,21 +486,51 @@ export function PluginsDetail() {
|
|||
queryKey: ["plugins"],
|
||||
})
|
||||
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
|
||||
enabled: !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery<ListedApiKey[]>(
|
||||
{
|
||||
enabled: !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/keys`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { keys?: ListedApiKey[] }
|
||||
return data.keys ?? []
|
||||
},
|
||||
queryKey: ["api-keys", org?.id],
|
||||
},
|
||||
queryKey: ["api-keys", org?.id],
|
||||
})
|
||||
)
|
||||
|
||||
const setupPluginIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (key.lastRequest) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
typeof key.metadata === "string"
|
||||
? (JSON.parse(key.metadata) as {
|
||||
sm_type?: string
|
||||
sm_client?: string
|
||||
})
|
||||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
ids.add(normalizePluginClientId(metadata.sm_client))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return ids
|
||||
}, [apiKeys])
|
||||
|
||||
const connectedPlugins = useMemo<ConnectedPlugin[]>(() => {
|
||||
const plugins: ConnectedPlugin[] = []
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (!key.lastRequest) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
|
|
@ -358,12 +542,15 @@ export function PluginsDetail() {
|
|||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
const createdAt = toDate(key.createdAt)?.toISOString()
|
||||
const lastRequest = toDate(key.lastRequest)?.toISOString()
|
||||
plugins.push({
|
||||
id: key.id,
|
||||
keyId: key.id,
|
||||
pluginId: metadata.sm_client,
|
||||
createdAt: key.createdAt.toISOString(),
|
||||
keyStart: key.start ?? null,
|
||||
pluginId: normalizePluginClientId(metadata.sm_client),
|
||||
createdAt: createdAt ?? new Date().toISOString(),
|
||||
lastRequest: lastRequest ?? null,
|
||||
keyStart: keyPrefix(key),
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -463,13 +650,18 @@ export function PluginsDetail() {
|
|||
const dialogPlugin = newKey.pluginId
|
||||
? PLUGIN_CATALOG[newKey.pluginId]
|
||||
: undefined
|
||||
const finishSetupPlugin = finishSetupPluginId
|
||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
||||
: undefined
|
||||
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
// If a step already embeds the key (an `export …="sm_…"` line), don't also
|
||||
// show the bare key in its own step — that's the repetition to avoid.
|
||||
// Otherwise (wizard-style installs) lead with a copy-the-key step.
|
||||
// Otherwise (wizard-style installs) lead with a copy-the-key step, unless
|
||||
// the plugin performs browser OAuth itself.
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const setupSteps: InstallStep[] = stepsEmbedKey
|
||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||
const setupSteps: InstallStep[] = skipGeneratedKeyStep
|
||||
? pluginSteps
|
||||
: [
|
||||
{
|
||||
|
|
@ -512,10 +704,15 @@ export function PluginsDetail() {
|
|||
connectedKeys={connectedPlugins.filter(
|
||||
(p) => p.pluginId === pluginId,
|
||||
)}
|
||||
needsSetup={
|
||||
!connectedPluginIds.has(pluginId) &&
|
||||
setupPluginIds.has(pluginId)
|
||||
}
|
||||
needsProUpgrade={needsProUpgrade}
|
||||
isConnecting={connectingPlugin === pluginId}
|
||||
actionsDisabled={!!connectingPlugin}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onFinishSetup={(id) => setFinishSetupPluginId(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
|
|
@ -638,6 +835,97 @@ export function PluginsDetail() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!finishSetupPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFinishSetupPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{finishSetupPlugin && (
|
||||
<PluginIconBox
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 truncate text-[12px] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
Complete install in the tool — status becomes active after the
|
||||
first API call.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{finishSetupPlugin?.installSteps?.length ? (
|
||||
<InstallSteps steps={finishSetupPlugin.installSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export interface PluginInfo {
|
|||
tagline: string
|
||||
icon: string
|
||||
docsUrl?: string
|
||||
githubUrl?: string
|
||||
usesOAuth?: boolean
|
||||
/** Steps shown after a key is minted. The literal `sm_...` is replaced
|
||||
* with the freshly generated key when rendered. */
|
||||
installSteps?: InstallStep[]
|
||||
|
|
@ -55,6 +57,7 @@ export const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
tagline: "Persistent memory for the Codex CLI — free on every plan",
|
||||
icon: "/images/plugins/codex.png",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/codex",
|
||||
githubUrl: "https://github.com/supermemoryai/codex-supermemory",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
|
|
@ -77,20 +80,20 @@ export const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
tagline: "Long-term memory for your OpenCode sessions",
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
|
||||
githubUrl: "https://github.com/supermemoryai/opencode-supermemory",
|
||||
usesOAuth: true,
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Use --no-tui for non-interactive environments.",
|
||||
code: "bunx opencode-supermemory@latest install",
|
||||
},
|
||||
{
|
||||
title: "Authenticate OpenCode",
|
||||
description:
|
||||
"Run the browser auth flow from the machine where OpenCode runs:",
|
||||
code: "bunx opencode-supermemory@latest login",
|
||||
},
|
||||
{
|
||||
title: "Verify your config",
|
||||
description:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue