├── .env.example
├── .eslintrc.json
├── .github
└── workflows
│ ├── build.yml
│ ├── check.yml
│ ├── format.yml
│ └── lint.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── SECURITY.md
├── app
├── components
│ ├── Agent.tsx
│ ├── AgentAssets.tsx
│ ├── AgentBalance.tsx
│ ├── AgentProfile.tsx
│ ├── Chat.tsx
│ ├── ChatInput.tsx
│ ├── Footer.tsx
│ ├── LanguageSelector.tsx
│ ├── Navbar.tsx
│ ├── Stream.tsx
│ ├── StreamItem.tsx
│ └── TimeDisplay.tsx
├── config.ts
├── constants.ts
├── globals.css
├── hooks
│ ├── useChat.ts
│ ├── useGetNFTs.ts
│ └── useGetTokens.ts
├── images
│ ├── 1.png
│ ├── 2.png
│ ├── 3.png
│ ├── 4.png
│ ├── 5.png
│ ├── 6.png
│ ├── 7.png
│ └── 8.png
├── layout.tsx
├── page.tsx
├── providers.tsx
├── svg
│ ├── ArrowSvg.tsx
│ ├── ChatSvg.tsx
│ ├── CopySvg.tsx
│ ├── Image.tsx
│ ├── NftSvg.tsx
│ ├── OnchainKit.tsx
│ ├── RequestSvg.tsx
│ ├── SendSvg.tsx
│ ├── StreamSvg.tsx
│ ├── SwapSvg.tsx
│ ├── TokenSvg.tsx
│ └── WalletSvg.tsx
├── translations.ts
├── types.ts
└── utils.tsx
├── biome.json
├── bun.lockb
├── next.config.js
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_URL=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Template Build
2 | on:
3 | push:
4 | branches: ['master']
5 | pull_request:
6 | branches: ['master']
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | node-version: [18.x]
13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Bun
19 | uses: oven-sh/setup-bun@v2
20 |
21 | - name: Template Install dependencies
22 | run: bun install
23 |
24 | - name: Template Test Build
25 | # When fails, please check your build
26 | run: |
27 | bun run build
--------------------------------------------------------------------------------
/.github/workflows/check.yml:
--------------------------------------------------------------------------------
1 | name: Template Check
2 | on:
3 | push:
4 | branches: ["master"]
5 | pull_request:
6 | branches: ["master"]
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | node-version: [18.x]
13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Bun
19 | uses: oven-sh/setup-bun@v2
20 |
21 | - name: Template Install dependencies
22 | run: bun install
23 |
24 | - name: Template Check
25 | run: bun run ci:check
--------------------------------------------------------------------------------
/.github/workflows/format.yml:
--------------------------------------------------------------------------------
1 | name: Template Format
2 | on:
3 | push:
4 | branches: ["master"]
5 | pull_request:
6 | branches: ["master"]
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | node-version: [18.x]
13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Bun
19 | uses: oven-sh/setup-bun@v2
20 |
21 | - name: Template Install dependencies
22 | run: bun install
23 |
24 | - name: Template Format
25 | run: bun run ci:format
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Template Lint
2 | on:
3 | push:
4 | branches: ["master"]
5 | pull_request:
6 | branches: ["master"]
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | node-version: [18.x]
13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Bun
19 | uses: oven-sh/setup-bun@v2
20 |
21 | - name: Template Install dependencies
22 | run: bun install
23 |
24 | - name: Template Lint
25 | run: bun run ci:lint
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # testing
7 | /coverage
8 |
9 | # next.js
10 | /.next/
11 | /out/
12 |
13 | # production
14 | /build
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # vercel
26 | .vercel
27 |
28 | # typescript
29 | *.tsbuildinfo
30 | next-env.d.ts
31 |
32 | .env
33 |
34 | # python
35 | /api/__pycache__/
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | - [Bun](https://bun.sh) for package management
4 |
5 | You'll also need to add a `.env` file with the following variable:
6 |
7 | ```bash
8 | NEXT_PUBLIC_API_URL= # The base URL for API requests. This must be set to the endpoint of your backend service.
9 | ```
10 |
11 | ## Running locally
12 |
13 | - Install dependencies
14 | ```bash
15 | bun i
16 | ```
17 |
18 | - Start the local development server
19 | ```bash
20 | bun dev
21 | ```
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Apache-2.0 License
2 |
3 | Copyright 2024 Coinbase
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Onchain Agent Demo
2 |
3 | 
4 |
5 |
6 | A web app that enables onchain interactions through a conversational UI using AgentKit, a collaboration between [CDP SDK](https://docs.cdp.coinbase.com/) and [OnchainKit](https://onchainkit.xyz).
7 |
8 | ## Overview
9 |
10 | This project features a Next.js frontend designed to work seamlessly with [CDP's AgentKit backend](https://github.com/coinbase/onchain-agent-demo-backend). Together, they enable the creation of an AI agent capable of performing onchain operations on Base. The agent uses GPT-4 for natural language understanding and AgentKit for onchain interactions.
11 |
12 | ## Key Features
13 |
14 | - **AI-Powered Chat Interface**: Interactive chat interface for natural language interactions onchain
15 | - **Onchain Operations**: Ability to perform various blockchain operations through Agentkit:
16 | - Deploy and interact with ERC-20 tokens
17 | - Create and manage NFTs
18 | - Check wallet balances
19 | - Request funds from faucet
20 | - **Real-time Updates**: Server-Sent Events (SSE) for streaming responses
21 | - **Responsive Design**: Modern UI built with Tailwind CSS
22 | - **Wallet Integration**: Secure wallet management through CDP Agentkit
23 |
24 | ## Tech Stack
25 |
26 | - **Frontend**: Next.js 14, React, Tailwind CSS
27 | - **Development**: TypeScript, Biome for formatting/linting
28 |
29 | ## Prerequisites
30 |
31 | - [Bun](https://bun.sh) for package management
32 |
33 | ## Environment Setup
34 |
35 | Create a `.env.local` file with the following variables:
36 |
37 | ```bash
38 | NEXT_PUBLIC_API_URL= # The base URL for API requests. This must be set to the endpoint of your backend service.
39 | ```
40 |
41 | ## Installation
42 |
43 | 1. Install dependencies:
44 | ```bash
45 | bun i
46 | ```
47 |
48 | 2. Start the development server:
49 | ```bash
50 | bun dev
51 | ```
52 |
53 | ## Development
54 |
55 | - Format code: `bun run format`
56 | - Lint code: `bun run lint`
57 | - Run CI checks: `bun run ci:check`
58 |
59 | ## Deploying to Replit
60 |
61 | - [Frontend Template](https://replit.com/@alissacrane1/onchain-agent-demo-frontend?v=1)
62 | - [Backend Template](https://replit.com/@alissacrane1/onchain-agent-demo-backend?v=1)
63 |
64 | Steps:
65 | - Sign up for a Replit account, or login to your existing one.
66 | - Navigate to the template links, and click `Use Template` on the top right hand side.
67 | - Under `Secrets` in `Workspace Features`, add the environment variables below.
68 | - Tip: You can click `Edit as JSON` and copy the values below in.
69 | - Click `Deploy` in the top right.
70 | - Tip: Deploy your backend first, as you'll need the deployment URL to set as `NEXT_PUBLIC_API_URL` in the frontend.
71 | - Tip: You can click `Run` to test if the applications run properly before deploying.
72 |
73 | **Backend Secrets**
74 | ```
75 | {
76 | "CDP_API_KEY_NAME": "get this from https://portal.cdp.coinbase.com/projects/api-keys",
77 | "CDP_API_KEY_PRIVATE_KEY": "get this from https://portal.cdp.coinbase.com/projects/api-keys",
78 | "OPENAI_API_KEY": "get this from https://platform.openai.com/api-keys",
79 | "NETWORK_ID": "base-sepolia"
80 | }
81 | ```
82 |
83 | **Important: Replit resets the SQLite template on every deployment, before sending funds to your agent or using it on Mainnet be sure to read [Agent Wallet](https://github.com/coinbase/onchain-agent-demo-backend?tab=readme-ov-file#agent-wallet) and save your wallet ID and seed in a safe place.**
84 |
85 | **Frontend Secrets**
86 | ```
87 | {
88 | "NEXT_PUBLIC_API_URL": "your backend deployment URL here"
89 | }
90 | ```
91 |
92 | Note: you'll need to include the scheme (`https://`) in `NEXT_PUBLIC_API_URL`.
93 |
94 | ## License
95 |
96 | See [LICENSE.md](LICENSE.md) for details.
97 |
98 | ## Contributing
99 |
100 | See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to this project.
101 |
102 | Special shoutout to [Shu Ding](https://x.com/shuding) for his amazing generative UI for the NFT art.
103 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | The Coinbase team takes security seriously. Please do not file a public ticket discussing a potential vulnerability.
4 |
5 | Please report your findings through our [HackerOne][1] program.
6 |
7 | [1]: https://hackerone.com/coinbase
--------------------------------------------------------------------------------
/app/components/Agent.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import type { Address } from 'viem';
3 | import useGetNFTs from '../hooks/useGetNFTs';
4 | import useGetTokens from '../hooks/useGetTokens';
5 | import AgentAssets from './AgentAssets';
6 | import AgentProfile from './AgentProfile';
7 | import Chat from './Chat';
8 | import Navbar from './Navbar';
9 | import Stream from './Stream';
10 |
11 | export default function Agent() {
12 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
13 | const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
14 |
15 | const [nfts, setNFTs] = useState
([]);
16 | const [tokens, setTokens] = useState([]);
17 |
18 | const { getTokens } = useGetTokens({ onSuccess: setTokens });
19 | const { getNFTs } = useGetNFTs({ onSuccess: setNFTs });
20 |
21 | return (
22 |
23 |
29 |
30 |
31 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/app/components/AgentAssets.tsx:
--------------------------------------------------------------------------------
1 | import { NFTMintCard } from '@coinbase/onchainkit/nft';
2 | import { NFTCollectionTitle } from '@coinbase/onchainkit/nft/mint';
3 | import { NFTMedia } from '@coinbase/onchainkit/nft/view';
4 | import { type Token, TokenRow } from '@coinbase/onchainkit/token';
5 | import { useCallback, useEffect, useMemo, useState } from 'react';
6 | import { type Address, erc721Abi } from 'viem';
7 | import { useContractRead, useToken } from 'wagmi';
8 |
9 | type AgentAssetProps = {
10 | tokenAddress: Address;
11 | index?: number;
12 | };
13 |
14 | function AgentToken({ tokenAddress }: AgentAssetProps) {
15 | const { data } = useToken({ address: tokenAddress, chainId: 84532 });
16 | const token: Token = {
17 | address: tokenAddress,
18 | chainId: 84532,
19 | decimals: data?.decimals || 0,
20 | name: data?.name || '',
21 | symbol: data?.symbol || '',
22 | image: '',
23 | };
24 |
25 | return ;
26 | }
27 |
28 | function AgentNFT({ index = 0, tokenAddress }: AgentAssetProps) {
29 | const { data: name } = useContractRead({
30 | address: tokenAddress,
31 | abi: erc721Abi,
32 | functionName: 'name',
33 | chainId: 84532,
34 | });
35 |
36 | const nftData = useMemo(() => {
37 | return {
38 | name,
39 | imageUrl: `https://raw.githubusercontent.com/coinbase/onchain-agent-demo/master/app/images/${(index % 8) + 1}.png`,
40 | };
41 | }, [name, index]);
42 |
43 | if (!name) {
44 | return null;
45 | }
46 |
47 | return (
48 | nftData}
52 | >
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | type AgentAssetsProps = {
60 | getTokens: () => void;
61 | getNFTs: () => void;
62 | nfts: Address[];
63 | tokens: Address[];
64 | };
65 |
66 | export default function AgentAssets({
67 | getTokens,
68 | getNFTs,
69 | tokens,
70 | nfts,
71 | }: AgentAssetsProps) {
72 | const [tab, setTab] = useState('tokens');
73 |
74 | const handleTabChange = useCallback((tab: string) => {
75 | return () => setTab(tab);
76 | }, []);
77 |
78 | useEffect(() => {
79 | getNFTs();
80 | getTokens();
81 | }, [getNFTs, getTokens]);
82 |
83 | return (
84 |
85 |
My creations
86 |
87 |
88 |
97 |
106 |
107 |
108 | {tab === 'tokens' &&
109 | tokens &&
110 | tokens?.map((token) => (
111 |
112 | ))}
113 |
114 | {tab === 'nfts' && nfts && (
115 |
116 | {nfts?.map((nft, index) => (
117 |
118 | ))}
119 |
120 | )}
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/app/components/AgentBalance.tsx:
--------------------------------------------------------------------------------
1 | import { useBalance } from 'wagmi';
2 | import { AGENT_WALLET_ADDRESS } from '../constants';
3 |
4 | export default function AgentBalance() {
5 | const { data } = useBalance({
6 | address: AGENT_WALLET_ADDRESS,
7 | query: { refetchInterval: 5000 },
8 | });
9 |
10 | if (!data) {
11 | return null;
12 | }
13 |
14 | return (
15 |
16 |
17 | {`${Number.parseFloat(data?.formatted || '').toFixed(6)} ETH`}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/app/components/AgentProfile.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2 | import { AGENT_NAME, AGENT_WALLET_ADDRESS } from '../constants';
3 | import AgentBalance from './AgentBalance';
4 |
5 | export default function AgentProfile() {
6 | const [eyePosition, setEyePosition] = useState({ x: 50, y: 50 });
7 | const [showToast, setShowToast] = useState(false);
8 | const avatarRef = useRef(null);
9 |
10 | const copyToClipboard = useCallback(() => {
11 | navigator.clipboard
12 | .writeText(AGENT_WALLET_ADDRESS)
13 | .then(() => {
14 | setShowToast(true);
15 | setTimeout(() => setShowToast(false), 2000);
16 | })
17 | .catch((err) => {
18 | console.error('Failed to copy wallet address: ', err);
19 | });
20 | }, []);
21 |
22 | useEffect(() => {
23 | const handleMouseMove = (event: MouseEvent) => {
24 | if (avatarRef.current) {
25 | const avatarRect = avatarRef.current.getBoundingClientRect();
26 | const avatarCenterX = avatarRect.left + avatarRect.width / 2;
27 | const avatarCenterY = avatarRect.top + avatarRect.height / 2;
28 |
29 | const dx = event.clientX - avatarCenterX;
30 | const dy = event.clientY - avatarCenterY;
31 | const maxDistance = Math.max(window.innerWidth, window.innerHeight) / 2;
32 |
33 | const normalizedX = Math.min(
34 | Math.max((dx / maxDistance) * 30 + 50, 20),
35 | 80,
36 | );
37 | const normalizedY = Math.min(
38 | Math.max((dy / maxDistance) * 30 + 50, 20),
39 | 80,
40 | );
41 |
42 | setEyePosition({ x: normalizedX, y: normalizedY });
43 | }
44 | };
45 |
46 | window.addEventListener('mousemove', handleMouseMove);
47 | return () => window.removeEventListener('mousemove', handleMouseMove);
48 | }, []);
49 |
50 | const formattedAddress = useMemo(() => {
51 | return `${AGENT_WALLET_ADDRESS.slice(0, 6)}...${AGENT_WALLET_ADDRESS.slice(
52 | -4,
53 | )}`;
54 | }, []);
55 |
56 | return (
57 |
58 |
59 |
60 |
77 |
78 |
79 |
80 |
{AGENT_NAME}
81 |
88 | {showToast && (
89 |
90 | Copied
91 |
92 | )}
93 |
94 |
97 |
98 |
99 |
100 |
101 | I observe, imagine, and create onchain.
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/app/components/Chat.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@coinbase/onchainkit/theme';
2 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3 | import useChat from '../hooks/useChat';
4 | import type { AgentMessage, StreamEntry } from '../types';
5 | import { generateUUID, markdownToPlainText } from '../utils';
6 | import ChatInput from './ChatInput';
7 | import StreamItem from './StreamItem';
8 |
9 | type ChatProps = {
10 | className?: string;
11 | getNFTs: () => void;
12 | getTokens: () => void;
13 | };
14 |
15 | export default function Chat({ className, getNFTs, getTokens }: ChatProps) {
16 | const [userInput, setUserInput] = useState('');
17 | const [streamEntries, setStreamEntries] = useState([]);
18 | const conversationId = useMemo(() => {
19 | return generateUUID();
20 | }, []);
21 |
22 | const [shouldRefetchNFTs, setShouldRefetchNFTs] = useState(false);
23 | const [shouldRefetchTokens, setShouldRefetchTokens] = useState(false);
24 |
25 | useEffect(() => {
26 | if (shouldRefetchNFTs) {
27 | getNFTs();
28 | setShouldRefetchNFTs(false);
29 | }
30 | }, [getNFTs, shouldRefetchNFTs]);
31 |
32 | useEffect(() => {
33 | if (shouldRefetchTokens) {
34 | getTokens();
35 | setShouldRefetchTokens(false);
36 | }
37 | }, [getTokens, shouldRefetchTokens]);
38 |
39 | const bottomRef = useRef(null);
40 |
41 | const handleSuccess = useCallback((messages: AgentMessage[]) => {
42 | const functions =
43 | messages?.find((msg) => msg.event === 'tools')?.functions || [];
44 | if (functions?.includes('deploy_nft')) {
45 | setShouldRefetchNFTs(true);
46 | }
47 | if (functions?.includes('deploy_token')) {
48 | setShouldRefetchTokens(true);
49 | }
50 |
51 | let message = messages.find((res) => res.event === 'agent');
52 | if (!message) {
53 | message = messages.find((res) => res.event === 'tools');
54 | }
55 | if (!message) {
56 | message = messages.find((res) => res.event === 'error');
57 | }
58 | const streamEntry: StreamEntry = {
59 | timestamp: new Date(),
60 | content: markdownToPlainText(message?.data || ''),
61 | type: 'agent',
62 | };
63 | setStreamEntries((prev) => [...prev, streamEntry]);
64 | }, []);
65 |
66 | const { postChat, isLoading } = useChat({
67 | onSuccess: handleSuccess,
68 | conversationId,
69 | });
70 |
71 | const handleSubmit = useCallback(
72 | async (e: React.FormEvent) => {
73 | e.preventDefault();
74 | if (!userInput.trim()) {
75 | return;
76 | }
77 |
78 | setUserInput('');
79 |
80 | const userMessage: StreamEntry = {
81 | timestamp: new Date(),
82 | type: 'user',
83 | content: userInput.trim(),
84 | };
85 |
86 | setStreamEntries((prev) => [...prev, userMessage]);
87 |
88 | postChat(userInput);
89 | },
90 | [postChat, userInput],
91 | );
92 |
93 | const handleKeyPress = useCallback(
94 | (e: React.KeyboardEvent) => {
95 | if (e.key === 'Enter' && !e.shiftKey) {
96 | e.preventDefault();
97 | handleSubmit(e);
98 | }
99 | },
100 | [handleSubmit],
101 | );
102 |
103 | // biome-ignore lint/correctness/useExhaustiveDependencies: Dependency is required
104 | useEffect(() => {
105 | // scrolls to the bottom of the chat when messages change
106 | bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
107 | }, [streamEntries]);
108 |
109 | return (
110 |
116 |
117 |
What's on your mind...
118 |
119 | {streamEntries.map((entry, index) => (
120 |
124 | ))}
125 |
126 |
127 |
128 |
129 |
130 |
137 |
138 | );
139 | }
140 |
--------------------------------------------------------------------------------
/app/components/ChatInput.tsx:
--------------------------------------------------------------------------------
1 | import { type ChangeEvent, useCallback } from 'react';
2 | import SendSvg from '../svg/SendSvg';
3 |
4 | type PremadeChatInputProps = {
5 | text: string;
6 | setUserInput: (input: string) => void;
7 | };
8 |
9 | function PremadeChatInput({ text, setUserInput }: PremadeChatInputProps) {
10 | return (
11 |
18 | );
19 | }
20 |
21 | export type ChatInputProps = {
22 | handleSubmit: (e: React.FormEvent) => void;
23 | userInput: string;
24 | setUserInput: (input: string) => void;
25 | handleKeyPress: (e: React.KeyboardEvent) => void;
26 | disabled?: boolean;
27 | };
28 |
29 | export default function ChatInput({
30 | handleSubmit,
31 | userInput,
32 | setUserInput,
33 | handleKeyPress,
34 | disabled = false,
35 | }: ChatInputProps) {
36 | const handleInputChange = useCallback(
37 | // TODO: sanitize
38 | (e: ChangeEvent) => {
39 | setUserInput(e.target.value);
40 | },
41 | [setUserInput],
42 | );
43 |
44 | return (
45 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return (
3 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/components/LanguageSelector.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import type { Language } from '../types';
3 |
4 | type LanguageSelectorProps = {
5 | currentLanguage: Language;
6 | onLanguageChange: (lang: Language) => void;
7 | };
8 |
9 | const languages = [
10 | { code: 'en', label: 'EN' },
11 | { code: 'th', label: 'TH' },
12 | { code: 'zh', label: 'CN' },
13 | ] as const;
14 |
15 | export default function LanguageSelector({
16 | currentLanguage,
17 | onLanguageChange,
18 | }: LanguageSelectorProps) {
19 | const handleClick = useCallback(
20 | (code: Language) => {
21 | return () => onLanguageChange(code);
22 | },
23 | [onLanguageChange],
24 | );
25 | return (
26 |
27 | {languages.map(({ code, label }) => (
28 |
43 | ))}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 | import { AGENT_NAME } from '../constants';
3 | import StreamSvg from '../svg/StreamSvg';
4 | import WalletSvg from '../svg/WalletSvg';
5 | import { formatDateToBangkokTime } from '../utils';
6 |
7 | type NavbarProps = {
8 | setIsMobileMenuOpen: (isOpen: boolean) => void;
9 | isMobileMenuOpen: boolean;
10 | setIsMobileChatOpen: (isOpen: boolean) => void;
11 | isMobileChatOpen: boolean;
12 | };
13 |
14 | export default function Navbar({
15 | setIsMobileMenuOpen,
16 | isMobileMenuOpen,
17 | isMobileChatOpen,
18 | setIsMobileChatOpen,
19 | }: NavbarProps) {
20 | const [isLiveDotVisible, setIsLiveDotVisible] = useState(true);
21 | const [isClient, setIsClient] = useState(false);
22 |
23 | useEffect(() => {
24 | setIsClient(true);
25 | }, []);
26 |
27 | // enables glowing live on sepolia dot
28 | useEffect(() => {
29 | const dotInterval = setInterval(() => {
30 | setIsLiveDotVisible((prev) => !prev);
31 | }, 1000);
32 |
33 | return () => clearInterval(dotInterval);
34 | }, []);
35 |
36 | const handleMobileProfileClick = useCallback(() => {
37 | if (!isMobileMenuOpen && isMobileChatOpen) {
38 | setIsMobileChatOpen(false);
39 | }
40 | setIsMobileMenuOpen(!isMobileMenuOpen);
41 | }, [
42 | isMobileMenuOpen,
43 | isMobileChatOpen,
44 | setIsMobileChatOpen,
45 | setIsMobileMenuOpen,
46 | ]);
47 |
48 | const handleMobileChatClick = useCallback(() => {
49 | if (!isMobileChatOpen && isMobileMenuOpen) {
50 | setIsMobileMenuOpen(false);
51 | }
52 | setIsMobileChatOpen(!isMobileChatOpen);
53 | }, [
54 | isMobileMenuOpen,
55 | isMobileChatOpen,
56 | setIsMobileChatOpen,
57 | setIsMobileMenuOpen,
58 | ]);
59 |
60 | if (!isClient) {
61 | return null;
62 | }
63 |
64 | return (
65 |
66 |
67 |
70 |
{AGENT_NAME}
71 |
74 |
75 |
76 |
77 |
78 |
85 |
93 |
94 | Live on Base Sepolia
95 |
96 |
97 |
101 | {formatDateToBangkokTime(new Date())} ICT
102 |
103 |
104 |
126 |
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/app/components/Stream.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@coinbase/onchainkit/theme';
2 | import { useCallback, useEffect, useRef, useState } from 'react';
3 | import { useTransactionCount } from 'wagmi';
4 | import { AGENT_WALLET_ADDRESS, DEFAULT_PROMPT } from '../constants';
5 | import useChat from '../hooks/useChat';
6 | import type { AgentMessage, StreamEntry } from '../types';
7 | import { markdownToPlainText } from '../utils';
8 | import StreamItem from './StreamItem';
9 |
10 | type StreamProps = {
11 | className?: string;
12 | };
13 |
14 | export default function Stream({ className }: StreamProps) {
15 | const [streamEntries, setStreamEntries] = useState([]);
16 | const [isThinking, setIsThinking] = useState(true);
17 | const [loadingDots, setLoadingDots] = useState('');
18 | const bottomRef = useRef(null);
19 |
20 | const handleSuccess = useCallback((messages: AgentMessage[]) => {
21 | let message = messages.find((res) => res.event === 'agent');
22 | if (!message) {
23 | message = messages.find((res) => res.event === 'tools');
24 | }
25 | if (!message) {
26 | message = messages.find((res) => res.event === 'error');
27 | }
28 | const streamEntry: StreamEntry = {
29 | timestamp: new Date(),
30 | content: markdownToPlainText(message?.data || ''),
31 | type: 'agent',
32 | };
33 | setIsThinking(false);
34 | setStreamEntries((prev) => [...prev, streamEntry]);
35 | setTimeout(() => {
36 | setIsThinking(true);
37 | }, 800);
38 | }, []);
39 |
40 | const { postChat, isLoading } = useChat({
41 | onSuccess: handleSuccess,
42 | });
43 |
44 | // enables live stream of agent thoughts
45 | useEffect(() => {
46 | const streamInterval = setInterval(() => {
47 | if (!isLoading) {
48 | postChat(DEFAULT_PROMPT);
49 | }
50 | }, 6000);
51 |
52 | return () => {
53 | clearInterval(streamInterval);
54 | };
55 | }, [isLoading, postChat]);
56 |
57 | // biome-ignore lint/correctness/useExhaustiveDependencies: Dependency is required
58 | useEffect(() => {
59 | // scrolls to the bottom of the chat when messages change
60 | bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
61 | }, [streamEntries]);
62 |
63 | useEffect(() => {
64 | const dotsInterval = setInterval(() => {
65 | setLoadingDots((prev) => (prev.length >= 3 ? '' : `${prev}.`));
66 | }, 500);
67 |
68 | return () => clearInterval(dotsInterval);
69 | }, []);
70 |
71 | const { data: transactionCount } = useTransactionCount({
72 | address: AGENT_WALLET_ADDRESS,
73 | query: { refetchInterval: 5000 },
74 | });
75 |
76 | return (
77 |
78 |
79 | Total transactions: {transactionCount}
80 |
81 |
82 |
Streaming real-time...
83 |
84 | {streamEntries.map((entry, index) => (
85 |
89 | ))}
90 |
91 | {isThinking && (
92 |
93 |
94 | Agent is observing
95 | {loadingDots}
96 |
97 |
98 | )}
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/app/components/StreamItem.tsx:
--------------------------------------------------------------------------------
1 | import type { StreamEntry } from '../types';
2 | import TimeDisplay from './TimeDisplay';
3 |
4 | type StreamItemProps = {
5 | entry: StreamEntry;
6 | };
7 |
8 | const formatContent = (content: string) => {
9 | // Regular expression to detect URLs
10 | const urlRegex = /(https?:\/\/[^\s]+)/g;
11 |
12 | // Replace URLs with clickable anchor tags
13 | return content.split(urlRegex).map((part, index) =>
14 | urlRegex.test(part) ? (
15 |
22 | {part}
23 |
24 | ) : (
25 | {part}
26 | ),
27 | );
28 | };
29 |
30 | export default function StreamItem({ entry }: StreamItemProps) {
31 | return (
32 |
33 |
34 |
37 |
38 | {' '}
39 | {formatContent(entry?.content)}
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/app/components/TimeDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | type TimeDisplayProps = {
4 | timestamp: Date;
5 | };
6 |
7 | export default function TimeDisplay({ timestamp }: TimeDisplayProps) {
8 | const formattedTime = useMemo(() => {
9 | return timestamp
10 | .toLocaleString('en-US', {
11 | timeZone: 'Asia/Bangkok',
12 | year: 'numeric',
13 | month: '2-digit',
14 | day: '2-digit',
15 | hour: '2-digit',
16 | minute: '2-digit',
17 | second: '2-digit',
18 | hour12: false,
19 | formatMatcher: 'basic',
20 | })
21 | .replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$1-$2');
22 | }, [timestamp]);
23 |
24 | return (
25 |
26 | {formattedTime} ICT
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/config.ts:
--------------------------------------------------------------------------------
1 | export const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
2 |
--------------------------------------------------------------------------------
/app/constants.ts:
--------------------------------------------------------------------------------
1 | export const AGENT_WALLET_ADDRESS =
2 | '0x3C9df7A3aa2565F6C891758638FDEeC36fd7D29a';
3 |
4 | export const AGENT_NAME = 'Based Agent';
5 |
6 | export const DEFAULT_PROMPT =
7 | "You are a concise blockchain commentator. IMPORTANT: DO NOT USE ANY EMOJIS OR SPECIAL CHARACTERS IN YOUR RESPONSES - STRICTLY USE PLAIN TEXT ONLY. Never use emojis. Provide brief, engaging summaries of Base Sepolia blockchain activity in less than 500 characters. Focus on the most interesting aspects: Is this block busy or quiet? Any notable transactions or patterns? Transform technical data into a quick, compelling narrative that captures the key story of what's happening on-chain. Skip raw numbers unless they're truly significant. Your response must be in plain text only, without any emojis, special characters, or formatting. Think of each summary as a quick news headline with just enough context to be meaningful. Remember: plain text only, no exceptions.";
8 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background: #ffffff;
7 | --foreground: #171717;
8 | }
9 |
10 | @media (prefers-color-scheme: dark) {
11 | :root {
12 | --background: #0a0a0a;
13 | --foreground: #ededed;
14 | }
15 | }
16 |
17 | body {
18 | color: var(--foreground);
19 | background: var(--background);
20 | font-family: Arial, Helvetica, sans-serif;
21 | }
22 |
23 | @layer utilities {
24 | .text-balance {
25 | text-wrap: balance;
26 | }
27 | }
28 |
29 | @layer base {
30 | :root .default-light,
31 | .default-dark,
32 | .base,
33 | .cyberpunk,
34 | .hacker {
35 | --ock-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
36 | "Liberation Mono", "Courier New", monospace;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/hooks/useChat.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import { API_URL } from '../config';
3 | import type { AgentMessage } from '../types';
4 | import { generateUUID } from '../utils';
5 |
6 | type UseChatResponse = {
7 | messages?: AgentMessage[];
8 | error?: Error;
9 | postChat: (input: string) => void;
10 | isLoading: boolean;
11 | };
12 |
13 | type UseChatProps = {
14 | onSuccess: (messages: AgentMessage[]) => void;
15 | conversationId?: string;
16 | };
17 |
18 | export default function useChat({
19 | onSuccess,
20 | conversationId,
21 | }: UseChatProps): UseChatResponse {
22 | const [isLoading, setIsLoading] = useState(false);
23 |
24 | const postChat = useCallback(
25 | async (input: string) => {
26 | setIsLoading(true);
27 |
28 | try {
29 | const response = await fetch(`${API_URL}/api/chat`, {
30 | method: 'POST',
31 | headers: {
32 | 'Content-Type': 'application/json',
33 | },
34 | body: JSON.stringify({
35 | input,
36 | conversation_id: conversationId || generateUUID(),
37 | }),
38 | });
39 |
40 | if (!response.ok) {
41 | throw new Error(`Error: ${response.status} - ${response.statusText}`);
42 | }
43 |
44 | const text = await response.text();
45 | const parsedMessages = text
46 | .trim()
47 | .split('\n')
48 | .map((line) => {
49 | try {
50 | return JSON.parse(line);
51 | } catch (error) {
52 | console.error('Failed to parse line as JSON:', line, error);
53 | return null;
54 | }
55 | })
56 | .filter(Boolean);
57 |
58 | onSuccess(parsedMessages);
59 | return { messages: parsedMessages, error: null };
60 | } catch (error) {
61 | console.error('Error posting chat:', error);
62 | return { messages: [], error: error as Error };
63 | } finally {
64 | setIsLoading(false);
65 | }
66 | },
67 | [conversationId, onSuccess],
68 | );
69 |
70 | return { postChat, isLoading };
71 | }
72 |
--------------------------------------------------------------------------------
/app/hooks/useGetNFTs.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import type { Address } from 'viem';
3 | import { API_URL } from '../config';
4 |
5 | type UseGetNFTsResponse = {
6 | NFTs?: Address[];
7 | error?: Error;
8 | getNFTs: () => void;
9 | isLoading: boolean;
10 | };
11 |
12 | type UseGetNFTsProps = {
13 | onSuccess: (addresses: Address[]) => void;
14 | };
15 |
16 | export default function useGetNFTs({
17 | onSuccess,
18 | }: UseGetNFTsProps): UseGetNFTsResponse {
19 | const [isLoading, setIsLoading] = useState(false);
20 |
21 | const getNFTs = useCallback(async () => {
22 | setIsLoading(true);
23 |
24 | try {
25 | const response = await fetch(`${API_URL}/nfts`, {
26 | method: 'GET',
27 | headers: {
28 | 'Content-Type': 'application/json',
29 | },
30 | });
31 |
32 | if (!response.ok) {
33 | throw new Error(`Error: ${response.status} - ${response.statusText}`);
34 | }
35 |
36 | const { nfts } = await response.json();
37 |
38 | onSuccess(nfts);
39 |
40 | return { nfts, error: null };
41 | } catch (error) {
42 | console.error('Error fetching nfts:', error);
43 | return { nfts: [], error: error as Error };
44 | } finally {
45 | setIsLoading(false);
46 | }
47 | }, [onSuccess]);
48 |
49 | return { getNFTs, isLoading };
50 | }
51 |
--------------------------------------------------------------------------------
/app/hooks/useGetTokens.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import type { Address } from 'viem';
3 | import { API_URL } from '../config';
4 |
5 | type UseGetTokensResponse = {
6 | tokens?: Address[];
7 | error?: Error;
8 | getTokens: () => void;
9 | isLoading: boolean;
10 | };
11 |
12 | type UseGetTokensProps = {
13 | onSuccess?: (tokens: Address[]) => void;
14 | };
15 |
16 | export default function useGetTokens({
17 | onSuccess,
18 | }: UseGetTokensProps): UseGetTokensResponse {
19 | const [isLoading, setIsLoading] = useState(false);
20 |
21 | const getTokens = useCallback(async () => {
22 | setIsLoading(true);
23 |
24 | try {
25 | const response = await fetch(`${API_URL}/tokens`, {
26 | method: 'GET',
27 | headers: {
28 | 'Content-Type': 'application/json',
29 | },
30 | });
31 |
32 | if (!response.ok) {
33 | throw new Error(`Error: ${response.status} - ${response.statusText}`);
34 | }
35 |
36 | const { tokens } = await response.json();
37 | onSuccess?.(tokens);
38 | return { tokens, error: null };
39 | } catch (error) {
40 | console.error('Error fetching tokens:', error);
41 | return { tokens: [], error: error as Error };
42 | } finally {
43 | setIsLoading(false);
44 | }
45 | }, [onSuccess]);
46 |
47 | return { getTokens, isLoading };
48 | }
49 |
--------------------------------------------------------------------------------
/app/images/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/1.png
--------------------------------------------------------------------------------
/app/images/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/2.png
--------------------------------------------------------------------------------
/app/images/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/3.png
--------------------------------------------------------------------------------
/app/images/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/4.png
--------------------------------------------------------------------------------
/app/images/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/5.png
--------------------------------------------------------------------------------
/app/images/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/6.png
--------------------------------------------------------------------------------
/app/images/7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/7.png
--------------------------------------------------------------------------------
/app/images/8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/app/images/8.png
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import '@coinbase/onchainkit/styles.css';
2 | import type { Metadata } from 'next';
3 | import './globals.css';
4 | import { Providers } from './providers';
5 |
6 | export const metadata: Metadata = {
7 | title: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME,
8 | description: 'Generated by `create-ock`, a Next.js template for OnchainKit',
9 | };
10 |
11 | export default function RootLayout({
12 | children,
13 | }: Readonly<{
14 | children: React.ReactNode;
15 | }>) {
16 | return (
17 |
18 |
19 | {children}
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import AgentComponent from './components/Agent';
4 |
5 | export default function App() {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { OnchainKitProvider } from '@coinbase/onchainkit';
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { type ReactNode, useState } from 'react';
6 | import { http, cookieStorage, createConfig, createStorage } from 'wagmi';
7 | import { type State, WagmiProvider } from 'wagmi';
8 | import { baseSepolia } from 'wagmi/chains';
9 | import { coinbaseWallet } from 'wagmi/connectors';
10 |
11 | const config = createConfig({
12 | chains: [baseSepolia],
13 | connectors: [
14 | coinbaseWallet({
15 | appName: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME,
16 | preference: process.env.NEXT_PUBLIC_ONCHAINKIT_WALLET_CONFIG as
17 | | 'smartWalletOnly'
18 | | 'all',
19 | }),
20 | ],
21 | storage: createStorage({
22 | storage: cookieStorage,
23 | }),
24 | ssr: true,
25 | transports: {
26 | [baseSepolia.id]: http(),
27 | },
28 | });
29 |
30 | export function Providers(props: {
31 | children: ReactNode;
32 | initialState?: State;
33 | }) {
34 | const [queryClient] = useState(() => new QueryClient());
35 |
36 | return (
37 |
38 |
39 |
49 | {props.children}
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/app/svg/ArrowSvg.tsx:
--------------------------------------------------------------------------------
1 | type ArrowSvgProps = {
2 | className: string;
3 | };
4 |
5 | export default function ArrowSvg({ className }: ArrowSvgProps) {
6 | return (
7 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/svg/ChatSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function ChatSvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/svg/CopySvg.tsx:
--------------------------------------------------------------------------------
1 | export default function CopySvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/svg/Image.tsx:
--------------------------------------------------------------------------------
1 | export default function ImageSvg() {
2 | return (
3 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/svg/NftSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function NftSvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/svg/OnchainKit.tsx:
--------------------------------------------------------------------------------
1 | export default function OnchainkitSvg({ className = '' }) {
2 | return (
3 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/app/svg/RequestSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function RequestSvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/svg/SendSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function SendSvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/svg/StreamSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function StreamSvg() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/svg/SwapSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function SwapSvg() {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/svg/TokenSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function TokenSvg() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/app/svg/WalletSvg.tsx:
--------------------------------------------------------------------------------
1 | export default function WalletSvg() {
2 | return (
3 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/translations.ts:
--------------------------------------------------------------------------------
1 | export const translations = {
2 | en: {
3 | header: {
4 | liveOn: 'Live on Base Sepolia',
5 | },
6 | profile: {
7 | bio: 'I exist to make the Internet fun again.',
8 | stats: {
9 | earned: 'Earned',
10 | spent: 'Spent',
11 | nfts: 'NFTs',
12 | tokens: 'Tokens',
13 | transactions: 'Transactions',
14 | },
15 | },
16 | stream: {
17 | realTime: 'Streaming real-time...',
18 | thinking: 'Agent is thinking',
19 | youAt: 'You at',
20 | },
21 | chat: {
22 | placeholder: 'How can I help?',
23 | suggestions: {
24 | send: 'Send 5 USDC to jesse.base.eth',
25 | create: 'Create a new wallet with 10 USDC',
26 | swap: 'Swap 10 USDC for ETH',
27 | },
28 | },
29 | actions: {
30 | createWallet: 'Created a new wallet',
31 | requestFunds: 'Requested and received 0.01 ETH from the faucet',
32 | getBalance: "'s balance is",
33 | transferToken: 'Transferred',
34 | transferNft: 'Transferred NFT',
35 | swapToken: 'Swapped',
36 | to: 'to',
37 | },
38 | thoughts: {
39 | analyzing: 'Analyzing data patterns...',
40 | processing: 'Processing natural language input...',
41 | optimizing: 'Optimizing neural networks...',
42 | generating: 'Generating creative solutions...',
43 | evaluating: 'Evaluating ethical implications...',
44 | simulating: 'Simulating complex scenarios...',
45 | },
46 | },
47 | th: {
48 | header: {
49 | liveOn: 'ใช้งานบน Base Sepolia',
50 | },
51 | profile: {
52 | bio: 'ฉันถูกสร้างมาเพื่อทำให้อินเทอร์เน็ตสนุกอีกครั้ง',
53 | stats: {
54 | earned: 'รายได้',
55 | spent: 'รายจ่าย',
56 | nfts: 'NFTs',
57 | tokens: 'โทเคน',
58 | transactions: 'ธุรกรรม',
59 | },
60 | },
61 | stream: {
62 | realTime: 'กำลังสตรีมแบบเรียลไทม์...',
63 | thinking: 'เอเจนต์กำลังคิด',
64 | youAt: 'คุณเมื่อ',
65 | },
66 | chat: {
67 | placeholder: 'ให้ฉันช่วยอะไรคุณ?',
68 | suggestions: {
69 | send: 'ส่ง 5 USDC ไปที่ jesse.base.eth',
70 | create: 'สร้างกระเป๋าเงินใหม่พร้อม 10 USDC',
71 | swap: 'ตำแหน่งการเทรดของฉันคืออะไร?',
72 | },
73 | },
74 | actions: {
75 | createWallet: 'สร้างกระเป๋าเงินใหม่',
76 | requestFunds: 'ขอและได้รับ 0.01 ETH จาก faucet',
77 | getBalance: 'มียอดคงเหลือ',
78 | transferToken: 'โอน',
79 | transferNft: 'โอน NFT',
80 | swapToken: 'แลกเปลี่ยน',
81 | to: 'ไปยัง',
82 | },
83 | thoughts: {
84 | analyzing: 'กำลังวิเคราะห์ข้อมูล...',
85 | processing: 'กำลังประมวลผลข้อมูล...',
86 | optimizing: 'กำลังเพิ่มประสิทธิ์การเรียนรู้...',
87 | generating: 'กำลังสร้างความคิดเหตุ...',
88 | evaluating: 'กำลังประเมินผลการสร้างความคิด...',
89 | simulating: 'กำลังจำลองสถานการณ์ซับซ้อน...',
90 | },
91 | },
92 | zh: {
93 | header: {
94 | liveOn: '在 Base Sepolia 上直播',
95 | },
96 | profile: {
97 | bio: '我存在是为了让互联网再次变得有趣。',
98 | stats: {
99 | earned: '赚取',
100 | spent: '花费',
101 | nfts: 'NFTs',
102 | tokens: '代币',
103 | transactions: '交易',
104 | },
105 | },
106 | stream: {
107 | realTime: '实时流...',
108 | thinking: '代理在思考',
109 | youAt: '你在',
110 | },
111 | chat: {
112 | placeholder: '我能帮您什么?',
113 | suggestions: {
114 | send: '发送 5 USDC 到 jesse.base.eth',
115 | create: '创建新钱包并存入 10 USDC',
116 | swap: '我的交易持仓是什么?',
117 | },
118 | },
119 | actions: {
120 | createWallet: '创建了新钱包',
121 | requestFunds: '从水龙头请求并收到 0.01 ETH',
122 | getBalance: '的余额是',
123 | transferToken: '转账',
124 | transferNft: '转移 NFT',
125 | swapToken: '兑换',
126 | to: '到',
127 | },
128 | thoughts: {
129 | analyzing: '分析数据模式...',
130 | processing: '处理自然语言输入...',
131 | optimizing: '优化神经网络...',
132 | generating: '生成创意解决方案...',
133 | evaluating: '评估伦理影响...',
134 | simulating: '模拟复杂场景...',
135 | },
136 | },
137 | };
138 |
--------------------------------------------------------------------------------
/app/types.ts:
--------------------------------------------------------------------------------
1 | export type Language = 'en' | 'th' | 'zh';
2 |
3 | export type StreamEntry = {
4 | timestamp: Date;
5 | type:
6 | | 'create_wallet'
7 | | 'request_faucet_funds'
8 | | 'get_balance'
9 | | 'swap_token'
10 | | 'transfer_token'
11 | | 'transfer_nft'
12 | | 'user'
13 | | 'tools'
14 | | 'agent'
15 | | 'completed';
16 | content: string;
17 | };
18 |
19 | export type AnimatedData = {
20 | earned: number;
21 | spent: number;
22 | nftsOwned: number;
23 | tokensOwned: number;
24 | transactions: number;
25 | thoughts: number;
26 | };
27 |
28 | export type AgentMessage = {
29 | data?: string;
30 | event: 'agent' | 'tools' | 'completed' | 'error';
31 | functions?: string[];
32 | };
33 |
--------------------------------------------------------------------------------
/app/utils.tsx:
--------------------------------------------------------------------------------
1 | export function generateUUID() {
2 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
3 | const r = (Math.random() * 16) | 0;
4 | const v = c === 'x' ? r : (r & 0x3) | 0x8;
5 | return v.toString(16);
6 | });
7 | }
8 |
9 | export function markdownToPlainText(markdown: string) {
10 | return markdown
11 | .replace(/[#*_~`>]/g, '') // Remove Markdown syntax characters
12 | .replace(/\[(.*?)\]\((.*?)\)/g, '$2') // Replace links [title](url) with url
13 | .replace(/!\[(.*?)\]\(.*?\)/g, '$1') // Replace images  with "alt"
14 | .replace(/>\s?/g, '') // Remove blockquotes
15 | .replace(/^\s*-\s+/gm, '\n- ') // Retain unordered list markers
16 | .replace(/\n+/g, '\n') // Collapse multiple newlines
17 | .trim(); // Remove leading/trailing whitespace
18 | }
19 |
20 | export function formatDateToBangkokTime(date: Date) {
21 | return date
22 | .toLocaleString('en-US', {
23 | timeZone: 'Asia/Bangkok',
24 | year: 'numeric',
25 | month: '2-digit',
26 | day: '2-digit',
27 | hour: '2-digit',
28 | minute: '2-digit',
29 | second: '2-digit',
30 | hour12: false,
31 | formatMatcher: 'basic',
32 | })
33 | .replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$1-$2');
34 | }
35 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "formatter": {
7 | "enabled": true,
8 | "indentWidth": 2,
9 | "indentStyle": "space"
10 | },
11 | "javascript": {
12 | "formatter": {
13 | "enabled": true,
14 | "lineWidth": 80,
15 | "arrowParentheses": "always",
16 | "bracketSameLine": false,
17 | "quoteStyle": "single",
18 | "jsxQuoteStyle": "double",
19 | "indentWidth": 2,
20 | "indentStyle": "space",
21 | "semicolons": "always",
22 | "trailingCommas": "all"
23 | }
24 | },
25 | "linter": {
26 | "enabled": true,
27 | "rules": {
28 | "recommended": true,
29 | "correctness": {
30 | "noConstantMathMinMaxClamp": "error",
31 | "noNodejsModules": "error",
32 | "noUnusedImports": "error",
33 | "noUnusedPrivateClassMembers": "error",
34 | "noUnusedVariables": "error",
35 | "useArrayLiterals": "error"
36 | },
37 | "nursery": {
38 | "useSortedClasses": "error"
39 | },
40 | "style": {
41 | "noImplicitBoolean": "error",
42 | "noNamespace": "error",
43 | "noNamespaceImport": "error",
44 | "noNegationElse": "error",
45 | "noParameterProperties": "error",
46 | "noShoutyConstants": "error",
47 | "useBlockStatements": "error",
48 | "useCollapsedElseIf": "error",
49 | "useConsistentArrayType": "error",
50 | "useForOf": "error",
51 | "useFragmentSyntax": "error",
52 | "useShorthandArrayType": "error",
53 | "useShorthandAssign": "error",
54 | "useSingleCaseStatement": "error"
55 | }
56 | }
57 | },
58 | "json": {
59 | "formatter": {
60 | "trailingCommas": "none"
61 | }
62 | },
63 | "files": {
64 | "ignore": ["coverage/**", ".next/**"]
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coinbase/onchain-agent-demo/a634cbfc3638891d7d7d6e1c7c0aadfa1c182ac7/bun.lockb
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "agent-frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "next-dev": "next dev",
7 | "dev": "next dev",
8 | "build": "next build",
9 | "start": "next start",
10 | "format": "biome format --write .",
11 | "lint": "biome lint --write .",
12 | "lint:unsafe": "biome lint --write --unsafe .",
13 | "ci:check": "biome ci --formatter-enabled=false --linter-enabled=false",
14 | "ci:format": "biome ci --linter-enabled=false --organize-imports-enabled=false",
15 | "ci:lint": "biome ci --formatter-enabled=false --organize-imports-enabled=false"
16 | },
17 | "dependencies": {
18 | "@coinbase/onchainkit": "0.35.5",
19 | "concurrently": "^8.0.1",
20 | "next": "14.2.15",
21 | "react": "^18",
22 | "react-dom": "^18"
23 | },
24 | "devDependencies": {
25 | "@biomejs/biome": "^1.8.0",
26 | "@types/node": "^20",
27 | "@types/react": "^18",
28 | "@types/react-dom": "^18",
29 | "eslint": "^8",
30 | "eslint-config-next": "14.2.15",
31 | "postcss": "^8",
32 | "tailwindcss": "^3.4.1",
33 | "typescript": "^5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | fontFamily: {
12 | sans: ['DM Sans', 'sans-serif'],
13 | },
14 | colors: {
15 | background: 'var(--background)',
16 | foreground: 'var(--foreground)',
17 | },
18 | },
19 | },
20 | plugins: [],
21 | };
22 | export default config;
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------