diff --git a/.env.example b/.env.example index 63b2706..99f2ad8 100644 --- a/.env.example +++ b/.env.example @@ -61,4 +61,32 @@ RESEND_FROM_EMAIL=onboarding@resend.dev # Comma-separated API keys. The pool picks one at random per request; # 401/403 responses remove that key from the pool and retry. # Get free keys at https://jina.ai/reader after sign-up. -JINA_API_KEYS= \ No newline at end of file +JINA_API_KEYS= + +# Alchemy RPC — https://dashboard.alchemy.com/ +# Server-only. Used by app/api/alchemy/[...path] to proxy JSON-RPC +# requests. Never reaches the browser (no NEXT_PUBLIC_ prefix). +ALCHEMY_API_KEY= +# Optional. Comma-separated Alchemy network slugs the proxy will REJECT +# (turn-off list). The proxy accepts every network in the static catalog +# (lib/alchemy/networks.ts) by default — set this to disable specific +# chains in production (e.g. testnets). Leave empty to enable all. +ALCHEMY_DISABLED_NETWORKS= + +# Crypto real-swap — feature flag for routing `place_crypto_order` +# through a live DEX path. When unset / "false" (default), the card +# stays in SIMULATED mode (hardcoded Mock Coin balance, no signing, +# no on-chain broadcast). Set to "true" only after wiring a real DEX +# router + signer (currently dormant — wagmi hooks live in the React +# tree, so a server-only toggle won't reach them). Exposed to the +# browser (NEXT_PUBLIC_) on purpose. +NEXT_PUBLIC_CRYPTO_REAL_SWAP= + +# WalletConnect / Reown projectId — https://dashboard.reown.com +# Bundled into the browser (NEXT_PUBLIC_) on purpose: it's a public +# dapp identifier, not a secret (Reown docs: "this is a public +# identifier"). The free tier covers 100k connections / month. Required +# for WalletConnect-based wallets (binanceWallet, bitgetWallet) to +# expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase, +# Rainbow, Safe) work without it. +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..e3fcf68 --- /dev/null +++ b/.env.test @@ -0,0 +1,13 @@ +# Postgres — used by vitest under NODE_ENV=test. +# @next/env reads this file when NODE_ENV=test (along with .env.test.local). +DATABASE_URL_TEST=postgresql://FireTable@localhost:5432/langgraph_app_test + +# Jina pool — fake keys for unit tests. The pool rotation / failover logic +# is exercised against `vi.stubGlobal("fetch", ...)`, so no real network. +JINA_API_KEYS=jina_test_key_1,jina_test_key_2 + +# Better Auth — fake secret for unit tests. Required for the auth module +# to load; withAuth HOC tests mock getSession() directly so the secret +# value is never validated against real signatures. +BETTER_AUTH_SECRET=test_secret_aabbccddeeff00112233445566778899 +BETTER_AUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b1a1c34..2ad52b5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,11 +32,22 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.test !.env.example # vercel .vercel +# local visual verification screenshots +/.verify +# ad-hoc screenshots/logs dropped at repo root during manual testing +/*.png +/*.jpg +/*.jpeg +/*.log +/test-results/ +/playwright-report/ + # typescript *.tsbuildinfo next-env.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index d84fed8..58b1850 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,11 @@ Copy `.env.example` to `.env.local` and fill in: - `LANGCHAIN_API_KEY` — sent as `x-api-key` by the proxy; leave blank for local dev. - `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` — graph id, must match a key in `langgraph.json` (`agent`). - `NEXT_PUBLIC_LANGGRAPH_API_URL` — optional. If set, the browser skips the `/api` proxy and talks to LangGraph directly. Leave unset to use the in-app proxy. -- `USE_SUBGRAPH` — backend graph topology toggle. `true` uses compiled `weatherAgent` / `chatAgent` subgraphs; unset (default) uses the inlined version that flattens them into the parent graph. The inlined default is a workaround for the `@langchain/core@1.2.1` `EventStreamCallbackHandler` "Run ID not found in run map" bug that LangGraph JS subgraphs trigger — see `memory/langgraph-subgraph-run-map-bug.md`. +- `ALCHEMY_API_KEY` — server-only, used by `app/api/alchemy/[...path]` to proxy JSON-RPC. Required for the wallet's portfolio view (per-chain token balances via Alchemy Portfolio API). +- `ALCHEMY_DISABLED_NETWORKS` — optional comma-separated Alchemy network slugs the proxy will reject. Default deny-list lives in `lib/alchemy/networks.ts`. +- `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` — Reown projectId, required for WalletConnect-based wallets (Binance, Bitget) to expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase) work without it. +- `NEXT_PUBLIC_CRYPTO_REAL_SWAP` — feature flag for the live Uniswap V3 swap path. Unset/`false` keeps `place_crypto_order` in SIMULATED mode (Mock Coin balance, no signing, no broadcast). Set `true` to enable the real path (currently dormant). +- `USE_SUBGRAPH` — backend graph topology toggle. `true` uses compiled `weatherAgent` / `chatAgent` / `cryptoAgent` subgraphs; unset (default) uses the inlined version that flattens them into the parent graph. The inlined default is a workaround for the `@langchain/core@1.2.1` `EventStreamCallbackHandler` "Run ID not found in run map" bug that LangGraph JS subgraphs trigger — see `memory/langgraph-subgraph-run-map-bug.md`. - `NEXT_PUBLIC_USE_SUBGRAPH` — frontend mirror of `USE_SUBGRAPH`. Required because Next.js only inlines `NEXT_PUBLIC_*` vars into the browser bundle; the frontend reads this to decide whether to render the interrupt-UI card (`InterruptUI`) or the inline tool-call card. LangGraph CLI also reads `.env.local` (`langgraph.json` → `env: ".env.local"`) and pins Node 22. @@ -67,42 +71,53 @@ backend/ agent/ chat-agent.ts chatAgent compiled subgraph (USE_SUBGRAPH=true path) weather-agent.ts weatherAgent compiled subgraph (USE_SUBGRAPH=true path) + crypto-agent.ts cryptoAgent compiled subgraph (USE_SUBGRAPH=true path) node/ call-model-node.ts "agent" node — calls the model, appends AI reply rename-thread-agent-node.ts "renameThreadAgent" node — generates + persists the title - router-agent-node.ts "routerAgent" — picks weatherAgent vs chatAgent per turn + router-agent-node.ts "routerAgent" — picks weatherAgent / chatAgent / cryptoAgent per turn after-agent-node.ts "afterAgent" — touches threads.last_message_at - prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT - tool/ ask_location, geocode_location, get_weather, searchWeb, fetchUrl + prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, CRYPTO_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT + tool/ ask_location, geocode_location, get_weather, search_web, fetch_url + tool/crypto/ get_crypto_price, get_fx_rate, get_token_balances, get_NFT_holdings, connect_wallet, place_crypto_order, get_order_status langgraph.json CLI config: graph id, node version, env file app/ Next.js App Router layout.tsx Root layout, fonts, TooltipProvider page.tsx Renders in a full-viewport
assistant.tsx Builds useLangGraphRuntime; chooses /api vs direct URL - api/[..._path]/route.ts Edge catch-all proxy to LANGGRAPH_API_URL + web3-providers.tsx wagmi/RainbowKit QueryClient + WagmiProvider wrappers + api/[..._path]/route.ts Node catch-all proxy to LANGGRAPH_API_URL (withAuth-gated) + api/alchemy/[...path]/route.ts Server-only JSON-RPC proxy to Alchemy (with key + per-network disabled list) + api/alchemy/status/route.ts Returns Alchemy key health + disabled-network list globals.css Tailwind v4 entry components/ assistant-ui/ Chat primitives (thread, attachment, markdown, reasoning, tool-fallback, tool-group, tooltip-icon-button) ui/ shadcn/ui primitives (avatar, button, collapsible, dialog, tooltip) — new-york style, lucide icons + ui/address-or-hash.tsx Truncated address/hash with copy-to-clipboard tool-ui/ask-location/ Interrupt-driven or addResult-driven location picker card - tool-ui/weather/ Forecast widget renderer + tool-ui/weather/ Forecast widget renderer (vendored runtime + container + overlay) + tool-ui/crypto/ Price, connect-wallet, place-order, order-status, nft-gallery cards lib/utils.ts cn() = twMerge(clsx(...)) lib/threads/ Threads module (schema, queries, adapter, validators) +lib/wagmi.ts wagmi/RainbowKit config (chains, connectors, WalletConnect projectId) +lib/alchemy/ networks.ts (slug → Alchemy URL + disabled list) + portfolio.ts (RPC helpers) +lib/prices/coingecko.ts CoinGecko free-tier price client (60s in-memory cache) +lib/decimal.ts Decimal-based amount math for crypto (no native float) ``` ### Backend graph (`backend/agent.ts`) -The parent graph dispatches a router decision into one of two sub-flows, both ending in `afterAgent`: +The parent graph dispatches a router decision into one of three sub-flows, all ending in `afterAgent`: -- `routerAgent` — calls `chatModel.withStructuredOutput(RouteDecisionSchema, { method: "jsonSchema" })` (tagged `nostream` so partial tokens don't leak into the chat) and returns `{ routerDecision: { next: "weatherAgent" | "chatAgent" } }` for the conditional edge to read. -- `weatherAgent` / `chatAgent` — a model → tools loop driven by `toolsCondition`. Exits to `afterAgent` when the model emits no `tool_calls`. +- `routerAgent` — calls `chatModel.withStructuredOutput(RouteDecisionSchema, { method: "jsonSchema" })` (tagged `nostream` so partial tokens don't leak into the chat) and returns `{ routerDecision: { next: "weatherAgent" | "chatAgent" | "cryptoAgent" } }` for the conditional edge to read. +- `weatherAgent` / `chatAgent` / `cryptoAgent` — a model → tools loop driven by `toolsCondition`. Exits to `afterAgent` when the model emits no `tool_calls`. - `afterAgent` — touches `threads.last_message_at` for the current thread; no message-channel writes. - `renameThreadAgent` — fans out from `START` (parallel to `routerAgent`), generates the thread title on the first turn only, persists it to the `threads` row. Two topologies share the same router + rename + after nodes and are gated by `USE_SUBGRAPH` (env var, see Environment): -- **Inlined (default).** `weatherModel` / `weatherTools` / `chatModel` / `chatTools` are inlined as plain nodes in the parent graph. The model/tool logic is duplicated from `backend/agent/weather-agent.ts` and `backend/agent/chat-agent.ts` — keep them in sync. The router's pathMap remaps `"weatherAgent"`/`"chatAgent"` (the router's string enum) to `"weatherModel"`/`"chatModel"` (the inlined node names). -- **Subgraph (`USE_SUBGRAPH=true`).** The compiled `weatherAgent` and `chatAgent` from `backend/agent/*-agent.ts` are wired as opaque nodes via `addNode("weatherAgent", weatherAgent)`. PathMap is an array of allowed destinations, since the returned string already matches the node name. +- **Inlined (default).** `weatherModel` / `weatherTools` / `chatModel` / `chatTools` / `cryptoModel` / `cryptoTools` are inlined as plain nodes in the parent graph. The model/tool logic is duplicated from `backend/agent/weather-agent.ts`, `chat-agent.ts`, and `crypto-agent.ts` — keep them in sync. The router's pathMap remaps `"weatherAgent"`/`"chatAgent"`/`"cryptoAgent"` (the router's string enum) to `"weatherModel"`/`"chatModel"`/`"cryptoModel"` (the inlined node names). +- **Subgraph (`USE_SUBGRAPH=true`).** The compiled `weatherAgent`, `chatAgent`, and `cryptoAgent` from `backend/agent/*-agent.ts` are wired as opaque nodes via `addNode("weatherAgent", weatherAgent)`. PathMap is an array of allowed destinations, since the returned string already matches the node name. Both builders live in `backend/agent.ts` (`buildSubgraph()` and `buildInlined()`). When `USE_SUBGRAPH` flips, no other file needs to change — but if you add a node, prompt, or tool, update both builders. @@ -112,6 +127,10 @@ The chat models in `backend/model.ts` carry `modelKwargs: { reasoning_split: tru The weather prompt (in `backend/prompt/system.ts`) lists four steps in order — `ask_location` → `geocode_location` → `get_weather` → one-sentence reply — and explicitly forbids batching tools in a single turn. The frontend card (`components/tool-ui/ask-location`) keys off the `ask_location` `ToolMessage`, so any tool run alongside it would race the human input. See `docs/INTERRUPT.md` for the two runtime paths the card can take. +### `CRYPTO_AGENT_PROMPT` enforces one-tool-per-turn + no-investment-advice + +Same one-tool-per-turn discipline as weather, applied to the trade flow: `connect_wallet` → `place_crypto_order` → `get_order_status` are HARD checkpoints, each pauses for one user click. `place_crypto_order` is gated by a "no fiat amounts" rule — when the user names a dollar/yuan/euro amount, the agent declines rather than quoting. The prompt also hard-blocks investment advice: no "buy now", no price-direction predictions, no "good entry", no editorializing on token quality; the agent describes only what the user asked and what the card does. The cards render numbers — the prose never repeats them. + ### State persistence (dev vs prod) The checkpointer active for a run is chosen by the runner, not by us: @@ -131,10 +150,14 @@ Consequences worth knowing: `app/assistant.tsx` is a client component. It instantiates the runtime with `useLangGraphRuntime({ stream, create, load })` from `@assistant-ui/react-langgraph` (which wraps `@langchain/langgraph-sdk`'s `Client`). `stream` is built from `unstable_createLangGraphStream`; `apiUrl` is `NEXT_PUBLIC_LANGGRAPH_API_URL` if set, otherwise the same-origin `/api` URL. -`app/api/[..._path]/route.ts` is an edge-runtime catch-all that proxies every method (`GET/POST/PUT/PATCH/DELETE/OPTIONS`) to `${LANGGRAPH_API_URL}/${path}` with `x-api-key: LANGCHAIN_API_KEY`, strips hop-by-hop / content-encoding headers, and adds permissive CORS. The body of mutating requests is forwarded as text. +`app/api/[..._path]/route.ts` is a node-runtime catch-all (see rule #9 — edge throws on `auth.api.getSession`) that proxies every method (`GET/POST/PUT/PATCH/DELETE/OPTIONS`) to `${LANGGRAPH_API_URL}/${path}` with `x-api-key: LANGCHAIN_API_KEY`, strips hop-by-hop / content-encoding headers, and adds permissive CORS. The body of mutating requests is forwarded as text. The handler is wrapped in `withAuth` (cookie + Authorization are forwarded upstream so LangGraph can identify the calling thread). `components/assistant-ui/thread.tsx` mounts `InterruptUI` (uses `useLangGraphInterruptState` + `useLangGraphSendCommand`) inside the last assistant message. The interrupt-driven render only fires when `NEXT_PUBLIC_USE_SUBGRAPH=true`; in default (inlined) mode, the ask_location card renders in the tool-call slot instead. See `docs/INTERRUPT.md` for the full two-mode flow. +### Web3 providers + +`app/layout.tsx` wraps the assistant tree in `` (`app/web3-providers.tsx`), which stacks `@tanstack/react-query` `QueryClientProvider`, `WagmiProvider` (configured by `lib/wagmi.ts`), and RainbowKit's `RainbowKitProvider`. Wallet state is global to the browser; the crypto cards read `address` / `chainId` from wagmi hooks directly — they never receive the wallet through tool args. The trade flow is fully SIMULATED regardless of wallet connectivity: `place_crypto_order` auto-funds Mock Coin on the first trade and synthesizes the order on click. Setting `NEXT_PUBLIC_CRYPTO_REAL_SWAP=true` is required to route through any real DEX path (currently dormant — wagmi hooks live in the React tree, so a server-side router alone can't reach them). + ### Patches `patches/` is currently empty. `pnpm-workspace.yaml` retains the `patchedDependencies:` header as a placeholder — when you need to patch a package, add the entry there and drop the `.patch` file under `patches/`. Re-check on every package bump; drop the entry + file when upstream ships the fix. @@ -239,10 +262,59 @@ Before running `pnpm dev` (or starting any dev server), check whether the releva Killing a running dev server loses unsaved browser state, breaks open browser tabs, and erases hot-reload history. If the dev server appears stale or stuck, surface the observation and ask the developer how they want to proceed; do not act unilaterally. +### 8. Tool-UI buttons are text-only — no icons + +Buttons inside `components/tool-ui/**` (and any new card added under that directory) render the label as the action. Do not put a Lucide icon (or any other icon) as a prefix to the label, even with `gap-2` to space them out. No ``, ``, etc. inside ` diff --git a/components/tool-ui/crypto/connect-wallet-card.tsx b/components/tool-ui/crypto/connect-wallet-card.tsx new file mode 100644 index 0000000..ba06ace --- /dev/null +++ b/components/tool-ui/crypto/connect-wallet-card.tsx @@ -0,0 +1,277 @@ +"use client"; + +import * as React from "react"; +import { createPortal } from "react-dom"; +import { CheckCircle2Icon, ChevronDownIcon, WalletIcon } from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; +import { useAccount } from "wagmi"; +import { useAccountModal, useConnectModal } from "@rainbow-me/rainbowkit"; + +import { Button } from "@/components/ui/button"; +import { AddressOrHash } from "@/components/ui/address-or-hash"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; + +// ConnectWalletCard — one-shot wallet authorization. The backend tool +// (connect_wallet) pauses via interrupt(); the LLM's prompt message +// travels as the interrupt's `message` field and is rendered separately +// by the runtime, so this card reads only wallet state. +// +// Three views: +// +// 1. Resolved (result set) → confirmation row with the chosen address. +// 2. Connected (no resume yet) → footer has Cancel (left) and a +// segmented "Use this wallet" / dropdown-arrow (right). The +// arrow opens a small menu with "Use a different wallet". +// 3. Not connected → single "Connect wallet" button. +// +// The card never auto-resumes — the user picks an action explicitly. + +type ResumePayload = { address: `0x${string}`; chainId: number } | { error: string }; + +function chainName(chainId: number | undefined): string { + switch (chainId) { + case 1: + return "Ethereum"; + case 42161: + return "Arbitrum One"; + case 8453: + return "Base"; + case 11155111: + return "Sepolia"; + default: + return `Chain ${chainId ?? "?"}`; + } +} + +function parseResult(raw: unknown): ResumePayload | null { + return unwrapToolResult(raw); +} + +export const ConnectWalletCard: ToolCallMessagePartComponent> = ({ + result, +}) => { + const sendCommand = useLangGraphSendCommand(); + const { address, isConnected, chainId } = useAccount(); + const { openConnectModal } = useConnectModal(); + const { openAccountModal } = useAccountModal(); + + const parsed = parseResult(result); + + const cancel = () => sendCommand({ resume: JSON.stringify({ error: "cancelled" }) }); + + if (parsed && "address" in parsed) { + return ( +
+
+
+ +
+
+

Wallet Connected

+

+ + · {chainName(parsed.chainId)} +

+
+
+
+ ); + } + + if (parsed && "error" in parsed) { + return ( +
+ Wallet connection cancelled: {parsed.error} +
+ ); + } + + // Connected, awaiting confirmation. Footer = Cancel | segmented + // [Use this wallet ▾]. The chevron half of the segmented control + // opens a menu with "Use a different wallet". + if (isConnected && address && chainId) { + const resume = () => + sendCommand({ resume: JSON.stringify({ address, chainId }) }); + return ( +
+
+
+
+ +
+
+

Authorize Wallet

+

+ + · {chainName(chainId)} +

+
+
+
+ + openAccountModal?.()} /> +
+
+
+ ); + } + + // Default state — wallet not connected. + return ( +
+
+
+
+ +
+
+

Authorize Wallet

+

Connect your wallet to continue.

+
+
+ +
+
+ ); +}; + +// Segmented control: "Use this wallet" (main action) on the left, +// vertical divider, chevron trigger on the right that opens a menu. +// flex-1 fills the right column of the footer; the chevron stays a +// fixed width so the divider stays centered. +function SegmentedConfirm({ + onConfirm, + onSwitchWallet, +}: { + onConfirm: () => void; + onSwitchWallet: () => void; +}) { + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState<{ top: number; right: number } | null>(null); + const rootRef = React.useRef(null); + const triggerRef = React.useRef(null); + + const updatePos = React.useCallback(() => { + const el = triggerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + }, []); + + React.useEffect(() => { + if (!open) { + setMenuPos(null); + return; + } + updatePos(); + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (!rootRef.current?.contains(target) && !(target as Element).closest?.("[data-slot=connect-wallet-menu]")) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("resize", updatePos); + window.addEventListener("scroll", updatePos, true); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("resize", updatePos); + window.removeEventListener("scroll", updatePos, true); + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open, updatePos]); + + return ( +
+
+ + +
+ {open && + menuPos && + typeof document !== "undefined" && + createPortal( +
+ +
, + document.body, + )} +
+ ); +} diff --git a/components/tool-ui/crypto/index.ts b/components/tool-ui/crypto/index.ts new file mode 100644 index 0000000..a9335ac --- /dev/null +++ b/components/tool-ui/crypto/index.ts @@ -0,0 +1,5 @@ +export { CryptoPriceCard } from "./price-card"; +export { ConnectWalletCard } from "./connect-wallet-card"; +export { PlaceCryptoOrderCard } from "./place-crypto-order-card"; +export { OrderStatusCard } from "./order-status-card"; +export { NftGalleryCard } from "./nft-gallery-card"; diff --git a/components/tool-ui/crypto/nft-gallery-card.tsx b/components/tool-ui/crypto/nft-gallery-card.tsx new file mode 100644 index 0000000..4d961cc --- /dev/null +++ b/components/tool-ui/crypto/nft-gallery-card.tsx @@ -0,0 +1,652 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ChevronDownIcon, + ChevronRightIcon, + ExternalLinkIcon, + ImagesIcon, + ImageOffIcon, + PlayCircleIcon, + XIcon, +} from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; + +import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; +import { AddressOrHash } from "@/components/ui/address-or-hash"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; +import { NFT_GALLERY_COLLAPSED_STORAGE_KEY } from "@/lib/constants"; +import { cn } from "@/lib/utils"; + +type Nft = { + contractAddress: string; + contractName: string; + collectionName: string | null; + collectionSlug: string | null; + contractImageUrl: string | null; + network: string; + tokenId: string; + tokenType: string; + name: string; + thumbnailUrl: string | null; + cachedUrl: string | null; + contentType: string | null; + balance: string; + totalSupply: string | null; + floorPriceEth: number | null; +}; + +type Result = + | { success: true; address: string; totalCount: number; nfts: Nft[] } + | { success: false; error: string }; + +type Args = { address: string }; + +function parse(raw: unknown): Result | null { + return unwrapToolResult(raw); +} + +const NETWORK_LABEL: Record = { + "eth-mainnet": "Ethereum", + "arb-mainnet": "Arbitrum", + "opt-mainnet": "Optimism", + "base-mainnet": "Base", + "polygon-mainnet": "Polygon", +}; + +const NETWORK_SLUG_FOR_OPENSEA: Record = { + "eth-mainnet": "ethereum", + "arb-mainnet": "arbitrum", + "opt-mainnet": "optimism", + "base-mainnet": "base", + "polygon-mainnet": "matic", +}; + +function networkLabel(slug: string): string { + return NETWORK_LABEL[slug] ?? slug; +} + +function shortAddress(addr: string): string { + if (addr.length < 12) return addr; + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +function openseaUrl(nft: Nft): string | null { + const slug = NETWORK_SLUG_FOR_OPENSEA[nft.network]; + if (!slug) return null; + return `https://opensea.io/assets/${slug}/${nft.contractAddress}/${nft.tokenId}`; +} + +function groupKey(nft: Nft): string { + // Group by collection (network + collection name), not by contract. Same + // collection on different chains stays split; same name on the same + // chain merges — multiple contracts that all share a name land in one + // group with an address chip per contract. + const collection = nft.collectionName?.trim() || nft.contractName.trim(); + return `${nft.network}:${collection.toLowerCase()}`; +} + +// Read the persisted set of collapsed group keys. Returns an empty set +// during SSR (no window) and on any storage error — the user gets a fresh +// expanded view as the safe default. +function readCollapsedSet(): Set { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(NFT_GALLERY_COLLAPSED_STORAGE_KEY); + if (!raw) return new Set(); + const arr: unknown = JSON.parse(raw); + if (!Array.isArray(arr)) return new Set(); + return new Set(arr.filter((x): x is string => typeof x === "string")); + } catch { + return new Set(); + } +} + +function writeCollapsedSet(set: Set): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + NFT_GALLERY_COLLAPSED_STORAGE_KEY, + JSON.stringify(Array.from(set)), + ); + } catch { + // Quota exceeded or storage disabled — silently ignore. The in-memory + // state still updates; only the persistence is lost. + } +} + +type ContractBucket = { + contractAddress: string; + contractImageUrl: string | null; + items: Nft[]; +}; + +type Group = { + key: string; + network: string; + collectionName: string; + contractName: string; + // One bucket per underlying contract. Almost always 1, but a collection + // minted under multiple deployments (or re-deployed after a renounce) + // will show several — the sub-row of address chips lets the user see + // exactly which contracts contributed which tokens. + contracts: ContractBucket[]; +}; + +const GROUP_COLLAPSED_SIZE = 6; + +function NftImage({ + src, + alt, + contentType, + className, + fit = "cover", + controls = false, + showBadge = true, +}: { + src: string | null; + alt: string; + contentType?: string | null; + className?: string; + /** `cover` (default) fills the box, cropping overflow; `contain` letterboxes. */ + fit?: "cover" | "contain"; + /** Lightbox uses `controls` so the user can play / pause / unmute. + * Tile previews and group logos stay without controls (still autoplay muted + * in the background so the user immediately sees motion). */ + controls?: boolean; + /** Show the "Video" pill in the corner. Off for tiny contexts + * (group logos) where the badge crowds the artwork. */ + showBadge?: boolean; +}) { + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + // Two-pass media-kind resolution: trust contentType first; if missing + // (LangGraph dev server hasn't picked up the latest normalize) and the + // URL breaks under , retry as