From 00f91295e074c31728f5a7c9a19f6701b9f0f613 Mon Sep 17 00:00:00 2001
From: edow <285729101@qq.com>
Date: Wed, 18 Feb 2026 13:05:39 +0800
Subject: [PATCH] add chat-x402 template with echo/wallet auth switcher
---
templates/chat-x402/.env.example | 3 +
templates/chat-x402/README.md | 50 +++
templates/chat-x402/components.json | 22 ++
templates/chat-x402/eslint.config.mjs | 19 ++
templates/chat-x402/next.config.ts | 10 +
templates/chat-x402/package.json | 73 +++++
templates/chat-x402/postcss.config.mjs | 5 +
templates/chat-x402/public/file.svg | 1 +
templates/chat-x402/public/globe.svg | 1 +
templates/chat-x402/public/logo/dark.svg | 66 ++++
templates/chat-x402/public/logo/light.svg | 66 ++++
templates/chat-x402/public/next.svg | 1 +
templates/chat-x402/public/vercel.svg | 1 +
templates/chat-x402/public/window.svg | 1 +
.../src/app/_components/auth-guard.tsx | 52 ++++
.../src/app/_components/auth-modal.tsx | 97 ++++++
.../chat-x402/src/app/_components/chat.tsx | 226 ++++++++++++++
.../src/app/_components/connect-button.tsx | 97 ++++++
.../app/_components/connection-selector.tsx | 48 +++
.../chat-x402/src/app/_components/header.tsx | 31 ++
templates/chat-x402/src/app/api/chat/route.ts | 66 ++++
.../src/app/api/echo/[...echo]/route.ts | 2 +
templates/chat-x402/src/app/globals.css | 124 ++++++++
templates/chat-x402/src/app/layout.tsx | 40 +++
templates/chat-x402/src/app/page.tsx | 12 +
.../src/components/ai-elements/actions.tsx | 65 ++++
.../src/components/ai-elements/branch.tsx | 212 +++++++++++++
.../src/components/ai-elements/code-block.tsx | 148 +++++++++
.../components/ai-elements/conversation.tsx | 97 ++++++
.../src/components/ai-elements/image.tsx | 24 ++
.../ai-elements/inline-citation.tsx | 287 ++++++++++++++++++
.../src/components/ai-elements/loader.tsx | 96 ++++++
.../src/components/ai-elements/message.tsx | 58 ++++
.../components/ai-elements/prompt-input.tsx | 230 ++++++++++++++
.../src/components/ai-elements/reasoning.tsx | 173 +++++++++++
.../src/components/ai-elements/response.tsx | 22 ++
.../src/components/ai-elements/sources.tsx | 77 +++++
.../src/components/ai-elements/suggestion.tsx | 53 ++++
.../src/components/ai-elements/task.tsx | 94 ++++++
.../src/components/ai-elements/tool.tsx | 142 +++++++++
.../components/ai-elements/web-preview.tsx | 252 +++++++++++++++
.../chat-x402/src/components/balance.tsx | 51 ++++
.../src/components/echo-account-next.tsx | 9 +
.../chat-x402/src/components/echo-account.tsx | 77 +++++
.../chat-x402/src/components/echo-button.tsx | 86 ++++++
.../chat-x402/src/components/echo-popover.tsx | 56 ++++
templates/chat-x402/src/components/logo.tsx | 42 +++
.../chat-x402/src/components/money-input.tsx | 100 ++++++
.../src/components/top-up-button.tsx | 93 ++++++
.../chat-x402/src/components/ui/avatar.tsx | 53 ++++
.../chat-x402/src/components/ui/badge.tsx | 46 +++
.../chat-x402/src/components/ui/button.tsx | 59 ++++
.../chat-x402/src/components/ui/card.tsx | 92 ++++++
.../chat-x402/src/components/ui/carousel.tsx | 240 +++++++++++++++
.../src/components/ui/collapsible.tsx | 33 ++
.../chat-x402/src/components/ui/dialog.tsx | 143 +++++++++
.../src/components/ui/hover-card.tsx | 44 +++
.../chat-x402/src/components/ui/input.tsx | 21 ++
.../chat-x402/src/components/ui/popover.tsx | 48 +++
.../src/components/ui/scroll-area.tsx | 58 ++++
.../chat-x402/src/components/ui/select.tsx | 185 +++++++++++
.../chat-x402/src/components/ui/skeleton.tsx | 13 +
.../chat-x402/src/components/ui/textarea.tsx | 18 ++
.../chat-x402/src/components/ui/tooltip.tsx | 61 ++++
templates/chat-x402/src/echo/index.ts | 5 +
.../src/lib/402/createPaymentHeader.ts | 35 +++
templates/chat-x402/src/lib/currency-utils.ts | 17 ++
templates/chat-x402/src/lib/utils.ts | 6 +
templates/chat-x402/src/lib/wagmi-config.ts | 10 +
templates/chat-x402/src/providers.tsx | 26 ++
templates/chat-x402/tsconfig.json | 27 ++
71 files changed, 4898 insertions(+)
create mode 100644 templates/chat-x402/.env.example
create mode 100644 templates/chat-x402/README.md
create mode 100644 templates/chat-x402/components.json
create mode 100644 templates/chat-x402/eslint.config.mjs
create mode 100644 templates/chat-x402/next.config.ts
create mode 100644 templates/chat-x402/package.json
create mode 100644 templates/chat-x402/postcss.config.mjs
create mode 100644 templates/chat-x402/public/file.svg
create mode 100644 templates/chat-x402/public/globe.svg
create mode 100644 templates/chat-x402/public/logo/dark.svg
create mode 100644 templates/chat-x402/public/logo/light.svg
create mode 100644 templates/chat-x402/public/next.svg
create mode 100644 templates/chat-x402/public/vercel.svg
create mode 100644 templates/chat-x402/public/window.svg
create mode 100644 templates/chat-x402/src/app/_components/auth-guard.tsx
create mode 100644 templates/chat-x402/src/app/_components/auth-modal.tsx
create mode 100644 templates/chat-x402/src/app/_components/chat.tsx
create mode 100644 templates/chat-x402/src/app/_components/connect-button.tsx
create mode 100644 templates/chat-x402/src/app/_components/connection-selector.tsx
create mode 100644 templates/chat-x402/src/app/_components/header.tsx
create mode 100644 templates/chat-x402/src/app/api/chat/route.ts
create mode 100644 templates/chat-x402/src/app/api/echo/[...echo]/route.ts
create mode 100644 templates/chat-x402/src/app/globals.css
create mode 100644 templates/chat-x402/src/app/layout.tsx
create mode 100644 templates/chat-x402/src/app/page.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/actions.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/branch.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/code-block.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/conversation.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/image.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/inline-citation.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/loader.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/message.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/prompt-input.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/reasoning.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/response.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/sources.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/suggestion.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/task.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/tool.tsx
create mode 100644 templates/chat-x402/src/components/ai-elements/web-preview.tsx
create mode 100644 templates/chat-x402/src/components/balance.tsx
create mode 100644 templates/chat-x402/src/components/echo-account-next.tsx
create mode 100644 templates/chat-x402/src/components/echo-account.tsx
create mode 100644 templates/chat-x402/src/components/echo-button.tsx
create mode 100644 templates/chat-x402/src/components/echo-popover.tsx
create mode 100644 templates/chat-x402/src/components/logo.tsx
create mode 100644 templates/chat-x402/src/components/money-input.tsx
create mode 100644 templates/chat-x402/src/components/top-up-button.tsx
create mode 100644 templates/chat-x402/src/components/ui/avatar.tsx
create mode 100644 templates/chat-x402/src/components/ui/badge.tsx
create mode 100644 templates/chat-x402/src/components/ui/button.tsx
create mode 100644 templates/chat-x402/src/components/ui/card.tsx
create mode 100644 templates/chat-x402/src/components/ui/carousel.tsx
create mode 100644 templates/chat-x402/src/components/ui/collapsible.tsx
create mode 100644 templates/chat-x402/src/components/ui/dialog.tsx
create mode 100644 templates/chat-x402/src/components/ui/hover-card.tsx
create mode 100644 templates/chat-x402/src/components/ui/input.tsx
create mode 100644 templates/chat-x402/src/components/ui/popover.tsx
create mode 100644 templates/chat-x402/src/components/ui/scroll-area.tsx
create mode 100644 templates/chat-x402/src/components/ui/select.tsx
create mode 100644 templates/chat-x402/src/components/ui/skeleton.tsx
create mode 100644 templates/chat-x402/src/components/ui/textarea.tsx
create mode 100644 templates/chat-x402/src/components/ui/tooltip.tsx
create mode 100644 templates/chat-x402/src/echo/index.ts
create mode 100644 templates/chat-x402/src/lib/402/createPaymentHeader.ts
create mode 100644 templates/chat-x402/src/lib/currency-utils.ts
create mode 100644 templates/chat-x402/src/lib/utils.ts
create mode 100644 templates/chat-x402/src/lib/wagmi-config.ts
create mode 100644 templates/chat-x402/src/providers.tsx
create mode 100644 templates/chat-x402/tsconfig.json
diff --git a/templates/chat-x402/.env.example b/templates/chat-x402/.env.example
new file mode 100644
index 000000000..03a3220f7
--- /dev/null
+++ b/templates/chat-x402/.env.example
@@ -0,0 +1,3 @@
+ECHO_APP_ID=your-echo-app-id
+NEXT_PUBLIC_ECHO_APP_ID=your-echo-app-id
+NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-walletconnect-project-id
diff --git a/templates/chat-x402/README.md b/templates/chat-x402/README.md
new file mode 100644
index 000000000..d2fcb40dc
--- /dev/null
+++ b/templates/chat-x402/README.md
@@ -0,0 +1,50 @@
+# Chat x402 Template
+
+A simple AI chat template with an auth switcher that lets users choose between paying with **Echo credits** or **USDC via x402** protocol.
+
+Based on the [next-chat](../next-chat) template with the wallet/x402 payment flow from the [sora-template](https://github.com/Merit-Systems/sora-template).
+
+## Features
+
+- Simple chat interface using Vercel AI SDK
+- Auth switcher modal: Echo credits **or** wallet-based USDC (x402)
+- RainbowKit wallet connection (Base, Mainnet)
+- Echo SDK for authentication and billing
+- Model selection (GPT-4o, GPT-5)
+
+## Getting Started
+
+1. Copy `.env.example` to `.env.local` and fill in your keys:
+
+```bash
+cp .env.example .env.local
+```
+
+2. Install dependencies:
+
+```bash
+npm install
+```
+
+3. Run dev server:
+
+```bash
+npm run dev
+```
+
+## Environment Variables
+
+| Variable | Description |
+| --- | --- |
+| `ECHO_APP_ID` | Your Echo app ID (server-side) |
+| `NEXT_PUBLIC_ECHO_APP_ID` | Your Echo app ID (client-side) |
+| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | WalletConnect project ID for wallet connections |
+
+## How It Works
+
+Users are presented with a login modal offering two payment methods:
+
+1. **Echo Credits** — Sign in via Echo OAuth, pay per-message with Echo credits
+2. **USDC via x402** — Connect a wallet (e.g. MetaMask), pay with USDC using the [x402](https://www.x402.org/) payment protocol
+
+Once authenticated through either method, users get access to the same chat interface.
diff --git a/templates/chat-x402/components.json b/templates/chat-x402/components.json
new file mode 100644
index 000000000..edcaef267
--- /dev/null
+++ b/templates/chat-x402/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/templates/chat-x402/eslint.config.mjs b/templates/chat-x402/eslint.config.mjs
new file mode 100644
index 000000000..c884132a1
--- /dev/null
+++ b/templates/chat-x402/eslint.config.mjs
@@ -0,0 +1,19 @@
+import { FlatCompat } from '@eslint/eslintrc'
+
+const compat = new FlatCompat({
+ // import.meta.dirname is available after Node.js v20.11.0
+ baseDirectory: import.meta.dirname,
+})
+
+const eslintConfig = [
+ ...compat.config({
+ extends: ['next'],
+ settings: {
+ next: {
+ rootDir: 'examples/nextjs-chatbot/',
+ },
+ },
+ }),
+]
+
+export default eslintConfig
\ No newline at end of file
diff --git a/templates/chat-x402/next.config.ts b/templates/chat-x402/next.config.ts
new file mode 100644
index 000000000..1332615e1
--- /dev/null
+++ b/templates/chat-x402/next.config.ts
@@ -0,0 +1,10 @@
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ /* config options here */
+ turbopack: {
+ root: '.',
+ },
+};
+
+export default nextConfig;
diff --git a/templates/chat-x402/package.json b/templates/chat-x402/package.json
new file mode 100644
index 000000000..0b95bc140
--- /dev/null
+++ b/templates/chat-x402/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "chat-x402-template",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build --turbopack",
+ "start": "next start",
+ "lint": "biome check --write",
+ "postinstall": "npm dedupe || true"
+ },
+ "dependencies": {
+ "@ai-sdk/react": "2.0.17",
+ "@merit-systems/echo-next-sdk": "latest",
+ "@merit-systems/echo-react-sdk": "latest",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@radix-ui/react-use-controllable-state": "^1.2.2",
+ "@rainbow-me/rainbowkit": "^2.2.8",
+ "@tanstack/react-query": "^5.90.2",
+ "ai": "5.0.19",
+ "@ai-sdk/openai": "2.0.16",
+ "autonumeric": "^4.10.9",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "embla-carousel-react": "^8.6.0",
+ "lucide-react": "^0.542.0",
+ "next": "15.5.9",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-syntax-highlighter": "^15.6.6",
+ "shiki": "^3.12.2",
+ "streamdown": "^1.2.0",
+ "tailwind-merge": "^3.3.1",
+ "use-stick-to-bottom": "^1.1.1",
+ "viem": "2.x",
+ "wagmi": "^2.17.5",
+ "x402": "^0.6.6",
+ "zod": "^4.1.5"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3",
+ "@next/eslint-plugin-next": "^15.5.3",
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "tailwindcss": "^4",
+ "tw-animate-css": "^1.3.8",
+ "typescript": "^5"
+ },
+ "overrides": {
+ "ai": "5.0.19",
+ "@ai-sdk/react": "2.0.17",
+ "@ai-sdk/openai": "2.0.16",
+ "@merit-systems/echo-react-sdk": {
+ "ai": "5.0.19",
+ "@ai-sdk/react": "2.0.17"
+ },
+ "@merit-systems/echo-next-sdk": {
+ "ai": "5.0.19",
+ "@ai-sdk/openai": "2.0.16"
+ }
+ }
+}
diff --git a/templates/chat-x402/postcss.config.mjs b/templates/chat-x402/postcss.config.mjs
new file mode 100644
index 000000000..f50127cda
--- /dev/null
+++ b/templates/chat-x402/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/templates/chat-x402/public/file.svg b/templates/chat-x402/public/file.svg
new file mode 100644
index 000000000..004145cdd
--- /dev/null
+++ b/templates/chat-x402/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/templates/chat-x402/public/globe.svg b/templates/chat-x402/public/globe.svg
new file mode 100644
index 000000000..567f17b0d
--- /dev/null
+++ b/templates/chat-x402/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/templates/chat-x402/public/logo/dark.svg b/templates/chat-x402/public/logo/dark.svg
new file mode 100644
index 000000000..31c6d2b07
--- /dev/null
+++ b/templates/chat-x402/public/logo/dark.svg
@@ -0,0 +1,66 @@
+
+
\ No newline at end of file
diff --git a/templates/chat-x402/public/logo/light.svg b/templates/chat-x402/public/logo/light.svg
new file mode 100644
index 000000000..4462aad0a
--- /dev/null
+++ b/templates/chat-x402/public/logo/light.svg
@@ -0,0 +1,66 @@
+
+
\ No newline at end of file
diff --git a/templates/chat-x402/public/next.svg b/templates/chat-x402/public/next.svg
new file mode 100644
index 000000000..5174b28c5
--- /dev/null
+++ b/templates/chat-x402/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/templates/chat-x402/public/vercel.svg b/templates/chat-x402/public/vercel.svg
new file mode 100644
index 000000000..770539603
--- /dev/null
+++ b/templates/chat-x402/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/templates/chat-x402/public/window.svg b/templates/chat-x402/public/window.svg
new file mode 100644
index 000000000..b2b2a44f6
--- /dev/null
+++ b/templates/chat-x402/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/templates/chat-x402/src/app/_components/auth-guard.tsx b/templates/chat-x402/src/app/_components/auth-guard.tsx
new file mode 100644
index 000000000..a46a65450
--- /dev/null
+++ b/templates/chat-x402/src/app/_components/auth-guard.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useEcho } from '@merit-systems/echo-next-sdk/client';
+import { useEffect, useState } from 'react';
+import { useAccount } from 'wagmi';
+import { ConnectionSelector } from './connection-selector';
+
+interface AuthGuardProps {
+ children: React.ReactNode;
+}
+
+export function AuthGuard({ children }: AuthGuardProps) {
+ const { isConnected } = useAccount();
+ const { user } = useEcho();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return null;
+ }
+
+ const isEchoSignedIn = !!user;
+ const isAuthenticated = isEchoSignedIn || isConnected;
+
+ if (!isAuthenticated) {
+ return (
+
+
+
+
+ Chat x402
+
+
+ AI-powered chat — pay with Echo credits or USDC via x402
+
+
+
+
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/templates/chat-x402/src/app/_components/auth-modal.tsx b/templates/chat-x402/src/app/_components/auth-modal.tsx
new file mode 100644
index 000000000..7c8e45dcf
--- /dev/null
+++ b/templates/chat-x402/src/app/_components/auth-modal.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { Logo } from '@/components/logo';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { useEcho } from '@merit-systems/echo-next-sdk/client';
+import { ConnectButton } from '@rainbow-me/rainbowkit';
+import { Wallet } from 'lucide-react';
+import { useState } from 'react';
+import { useAccount } from 'wagmi';
+
+interface AuthModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function AuthModal({ open, onOpenChange }: AuthModalProps) {
+ const { isConnected } = useAccount();
+ const { user, signIn, isLoading } = useEcho();
+ const [isSigningIn, setIsSigningIn] = useState(false);
+
+ const isEchoConnected = !!user;
+ const isWalletConnected = isConnected;
+
+ if (isEchoConnected || isWalletConnected) {
+ if (open) {
+ onOpenChange(false);
+ }
+ return null;
+ }
+
+ const handleEchoConnect = () => {
+ setIsSigningIn(true);
+ signIn();
+ };
+
+ return (
+
+ );
+}
diff --git a/templates/chat-x402/src/app/_components/chat.tsx b/templates/chat-x402/src/app/_components/chat.tsx
new file mode 100644
index 000000000..797647214
--- /dev/null
+++ b/templates/chat-x402/src/app/_components/chat.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import { useChat } from '@ai-sdk/react';
+import { CopyIcon, MessageSquare } from 'lucide-react';
+import { Fragment, useState } from 'react';
+import { Action, Actions } from '@/components/ai-elements/actions';
+import {
+ Conversation,
+ ConversationContent,
+ ConversationEmptyState,
+ ConversationScrollButton,
+} from '@/components/ai-elements/conversation';
+import { Loader } from '@/components/ai-elements/loader';
+import { Message, MessageContent } from '@/components/ai-elements/message';
+import {
+ PromptInput,
+ PromptInputModelSelect,
+ PromptInputModelSelectContent,
+ PromptInputModelSelectItem,
+ PromptInputModelSelectTrigger,
+ PromptInputModelSelectValue,
+ PromptInputSubmit,
+ PromptInputTextarea,
+ PromptInputToolbar,
+ PromptInputTools,
+} from '@/components/ai-elements/prompt-input';
+import {
+ Reasoning,
+ ReasoningContent,
+ ReasoningTrigger,
+} from '@/components/ai-elements/reasoning';
+import { Response } from '@/components/ai-elements/response';
+import {
+ Source,
+ Sources,
+ SourcesContent,
+ SourcesTrigger,
+} from '@/components/ai-elements/sources';
+import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion';
+
+const models = [
+ {
+ name: 'GPT 4o',
+ value: 'gpt-4o',
+ },
+ {
+ name: 'GPT 5',
+ value: 'gpt-5',
+ },
+];
+
+const suggestions = [
+ 'Can you explain how to play tennis?',
+ 'Write me a code snippet of how to use the vercel ai sdk to create a chatbot',
+ 'How do I make a really good fish taco?',
+];
+
+const ChatBotDemo = () => {
+ const [input, setInput] = useState('');
+ const [model, setModel] = useState(models[0].value);
+ const { messages, sendMessage, status } = useChat();
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (input.trim()) {
+ sendMessage(
+ { text: input },
+ {
+ body: {
+ model: model,
+ },
+ }
+ );
+ setInput('');
+ }
+ };
+
+ const handleSuggestionClick = (suggestion: string) => {
+ sendMessage(
+ { text: suggestion },
+ {
+ body: {
+ model: model,
+ },
+ }
+ );
+ };
+
+ return (
+
+
+
+
+ {messages.length === 0 ? (
+ }
+ title="No messages yet"
+ description="Start a conversation to see messages here"
+ />
+ ) : (
+ messages.map(message => (
+
+ {message.role === 'assistant' &&
+ message.parts.filter(part => part.type === 'source-url')
+ .length > 0 && (
+
+ part.type === 'source-url'
+ ).length
+ }
+ />
+ {message.parts
+ .filter(part => part.type === 'source-url')
+ .map((part, i) => (
+
+
+
+ ))}
+
+ )}
+ {message.parts.map((part, i) => {
+ switch (part.type) {
+ case 'text':
+ return (
+
+
+
+
+ {part.text}
+
+
+
+ {message.role === 'assistant' &&
+ i === messages.length - 1 && (
+
+
+ navigator.clipboard.writeText(part.text)
+ }
+ label="Copy"
+ >
+
+
+
+ )}
+
+ );
+ case 'reasoning':
+ return (
+
+
+ {part.text}
+
+ );
+ default:
+ return null;
+ }
+ })}
+
+ ))
+ )}
+ {status === 'submitted' && }
+
+
+
+
+ {suggestions.map(suggestion => (
+
+ ))}
+
+
+
+ setInput(e.target.value)}
+ value={input}
+ />
+
+
+ {
+ setModel(value);
+ }}
+ value={model}
+ >
+
+
+
+
+ {models.map(model => (
+
+ {model.name}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ChatBotDemo;
diff --git a/templates/chat-x402/src/app/_components/connect-button.tsx b/templates/chat-x402/src/app/_components/connect-button.tsx
new file mode 100644
index 000000000..72c2bfeb2
--- /dev/null
+++ b/templates/chat-x402/src/app/_components/connect-button.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { ConnectButton } from '@rainbow-me/rainbowkit';
+
+export function WalletConnectButton() {
+ return (
+
+ {({
+ account,
+ chain,
+ openAccountModal,
+ openChainModal,
+ openConnectModal,
+ mounted,
+ }) => {
+ const ready = mounted;
+ const connected = ready && account && chain;
+
+ return (
+
+ {(() => {
+ if (!connected) {
+ return (
+
+ );
+ }
+
+ if (chain.unsupported) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ })()}
+
+ );
+ }}
+
+ );
+}
diff --git a/templates/chat-x402/src/app/_components/connection-selector.tsx b/templates/chat-x402/src/app/_components/connection-selector.tsx
new file mode 100644
index 000000000..31a77e15e
--- /dev/null
+++ b/templates/chat-x402/src/app/_components/connection-selector.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { useEcho } from '@merit-systems/echo-next-sdk/client';
+import { useState } from 'react';
+import { useAccount } from 'wagmi';
+import { AuthModal } from './auth-modal';
+import { WalletConnectButton } from './connect-button';
+import { EchoAccount } from '@/components/echo-account-next';
+
+export function ConnectionSelector() {
+ const [authModalOpen, setAuthModalOpen] = useState(false);
+ const { isConnected } = useAccount();
+ const { user } = useEcho();
+
+ const isEchoConnected = !!user;
+ const isWalletConnected = isConnected;
+
+ if (isEchoConnected) {
+ return (
+
+
+
+ );
+ }
+
+ if (isWalletConnected) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/templates/chat-x402/src/app/_components/header.tsx b/templates/chat-x402/src/app/_components/header.tsx
new file mode 100644
index 000000000..c74f4f63b
--- /dev/null
+++ b/templates/chat-x402/src/app/_components/header.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import type { FC } from 'react';
+import { ConnectionSelector } from './connection-selector';
+
+interface HeaderProps {
+ title?: string;
+ className?: string;
+}
+
+const Header: FC = ({ title = 'Chat x402', className = '' }) => {
+ return (
+
+ );
+};
+
+export default Header;
diff --git a/templates/chat-x402/src/app/api/chat/route.ts b/templates/chat-x402/src/app/api/chat/route.ts
new file mode 100644
index 000000000..842685c70
--- /dev/null
+++ b/templates/chat-x402/src/app/api/chat/route.ts
@@ -0,0 +1,66 @@
+import { convertToModelMessages, streamText, type UIMessage } from 'ai';
+import { openai } from '@/echo';
+
+// Allow streaming responses up to 30 seconds
+export const maxDuration = 30;
+
+export async function POST(req: Request) {
+ try {
+ const {
+ model,
+ messages,
+ }: {
+ messages: UIMessage[];
+ model: string;
+ } = await req.json();
+
+ // Validate required parameters
+ if (!model) {
+ return new Response(
+ JSON.stringify({
+ error: 'Bad Request',
+ message: 'Model parameter is required',
+ }),
+ {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }
+ );
+ }
+
+ if (!messages || !Array.isArray(messages)) {
+ return new Response(
+ JSON.stringify({
+ error: 'Bad Request',
+ message: 'Messages parameter is required and must be an array',
+ }),
+ {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }
+ );
+ }
+
+ const result = streamText({
+ model: openai(model),
+ messages: convertToModelMessages(messages),
+ });
+
+ return result.toUIMessageStreamResponse({
+ sendSources: true,
+ sendReasoning: true,
+ });
+ } catch (error) {
+ console.error('Chat API error:', error);
+ return new Response(
+ JSON.stringify({
+ error: 'Internal server error',
+ message: 'Failed to process chat request',
+ }),
+ {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ }
+ );
+ }
+}
diff --git a/templates/chat-x402/src/app/api/echo/[...echo]/route.ts b/templates/chat-x402/src/app/api/echo/[...echo]/route.ts
new file mode 100644
index 000000000..c7296d950
--- /dev/null
+++ b/templates/chat-x402/src/app/api/echo/[...echo]/route.ts
@@ -0,0 +1,2 @@
+import { handlers } from '@/echo';
+export const { GET, POST } = handlers;
diff --git a/templates/chat-x402/src/app/globals.css b/templates/chat-x402/src/app/globals.css
new file mode 100644
index 000000000..1ce36e908
--- /dev/null
+++ b/templates/chat-x402/src/app/globals.css
@@ -0,0 +1,124 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../node_modules/streamdown/dist/index.js";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.616 0.166 223.7);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.616 0.166 223.7);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.616 0.166 223.7);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.616 0.166 223.7);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/templates/chat-x402/src/app/layout.tsx b/templates/chat-x402/src/app/layout.tsx
new file mode 100644
index 000000000..188a48cf9
--- /dev/null
+++ b/templates/chat-x402/src/app/layout.tsx
@@ -0,0 +1,40 @@
+import Header from '@/app/_components/header';
+import { Providers } from '@/providers';
+import type { Metadata } from 'next';
+import { Geist, Geist_Mono } from 'next/font/google';
+import './globals.css';
+
+const geistSans = Geist({
+ variable: '--font-geist-sans',
+ subsets: ['latin'],
+});
+
+const geistMono = Geist_Mono({
+ variable: '--font-geist-mono',
+ subsets: ['latin'],
+});
+
+export const metadata: Metadata = {
+ title: 'Chat x402',
+ description:
+ 'AI-powered chat with Echo credits or USDC payments via x402 protocol',
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/templates/chat-x402/src/app/page.tsx b/templates/chat-x402/src/app/page.tsx
new file mode 100644
index 000000000..fe4905b8e
--- /dev/null
+++ b/templates/chat-x402/src/app/page.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import Chat from '@/app/_components/chat';
+import { AuthGuard } from '@/app/_components/auth-guard';
+
+export default function Home() {
+ return (
+
+
+
+ );
+}
diff --git a/templates/chat-x402/src/components/ai-elements/actions.tsx b/templates/chat-x402/src/components/ai-elements/actions.tsx
new file mode 100644
index 000000000..c806be915
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/actions.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import type { ComponentProps } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+
+export type ActionsProps = ComponentProps<'div'>;
+
+export const Actions = ({ className, children, ...props }: ActionsProps) => (
+
+ {children}
+
+);
+
+export type ActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+};
+
+export const Action = ({
+ tooltip,
+ children,
+ label,
+ className,
+ variant = 'ghost',
+ size = 'sm',
+ ...props
+}: ActionProps) => {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
diff --git a/templates/chat-x402/src/components/ai-elements/branch.tsx b/templates/chat-x402/src/components/ai-elements/branch.tsx
new file mode 100644
index 000000000..902afccf9
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/branch.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import type { UIMessage } from 'ai';
+import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
+import { createContext, useContext, useEffect, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+type BranchContextType = {
+ currentBranch: number;
+ totalBranches: number;
+ goToPrevious: () => void;
+ goToNext: () => void;
+ branches: ReactElement[];
+ setBranches: (branches: ReactElement[]) => void;
+};
+
+const BranchContext = createContext(null);
+
+const useBranch = () => {
+ const context = useContext(BranchContext);
+
+ if (!context) {
+ throw new Error('Branch components must be used within Branch');
+ }
+
+ return context;
+};
+
+export type BranchProps = HTMLAttributes & {
+ defaultBranch?: number;
+ onBranchChange?: (branchIndex: number) => void;
+};
+
+export const Branch = ({
+ defaultBranch = 0,
+ onBranchChange,
+ className,
+ ...props
+}: BranchProps) => {
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+ const [branches, setBranches] = useState([]);
+
+ const handleBranchChange = (newBranch: number) => {
+ setCurrentBranch(newBranch);
+ onBranchChange?.(newBranch);
+ };
+
+ const goToPrevious = () => {
+ const newBranch =
+ currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
+ handleBranchChange(newBranch);
+ };
+
+ const goToNext = () => {
+ const newBranch =
+ currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
+ handleBranchChange(newBranch);
+ };
+
+ const contextValue: BranchContextType = {
+ currentBranch,
+ totalBranches: branches.length,
+ goToPrevious,
+ goToNext,
+ branches,
+ setBranches,
+ };
+
+ return (
+
+ div]:pb-0', className)}
+ {...props}
+ />
+
+ );
+};
+
+export type BranchMessagesProps = HTMLAttributes
;
+
+export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
+ const { currentBranch, setBranches, branches } = useBranch();
+ const childrenArray = Array.isArray(children) ? children : [children];
+
+ // Use useEffect to update branches when they change
+ useEffect(() => {
+ if (branches.length !== childrenArray.length) {
+ setBranches(childrenArray);
+ }
+ }, [childrenArray, branches, setBranches]);
+
+ return childrenArray.map((branch, index) => (
+ div]:pb-0',
+ index === currentBranch ? 'block' : 'hidden'
+ )}
+ key={branch.key}
+ {...props}
+ >
+ {branch}
+
+ ));
+};
+
+export type BranchSelectorProps = HTMLAttributes & {
+ from: UIMessage['role'];
+};
+
+export const BranchSelector = ({
+ className,
+ from,
+ ...props
+}: BranchSelectorProps) => {
+ const { totalBranches } = useBranch();
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export type BranchPreviousProps = ComponentProps;
+
+export const BranchPrevious = ({
+ className,
+ children,
+ ...props
+}: BranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useBranch();
+
+ return (
+
+ );
+};
+
+export type BranchNextProps = ComponentProps;
+
+export const BranchNext = ({
+ className,
+ children,
+ ...props
+}: BranchNextProps) => {
+ const { goToNext, totalBranches } = useBranch();
+
+ return (
+
+ );
+};
+
+export type BranchPageProps = HTMLAttributes;
+
+export const BranchPage = ({ className, ...props }: BranchPageProps) => {
+ const { currentBranch, totalBranches } = useBranch();
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ );
+};
diff --git a/templates/chat-x402/src/components/ai-elements/code-block.tsx b/templates/chat-x402/src/components/ai-elements/code-block.tsx
new file mode 100644
index 000000000..1574767fb
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/code-block.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { CheckIcon, CopyIcon } from 'lucide-react';
+import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
+import { createContext, useContext, useState } from 'react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import {
+ oneDark,
+ oneLight,
+} from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+type CodeBlockContextType = {
+ code: string;
+};
+
+const CodeBlockContext = createContext({
+ code: '',
+});
+
+export type CodeBlockProps = HTMLAttributes & {
+ code: string;
+ language: string;
+ showLineNumbers?: boolean;
+ children?: ReactNode;
+};
+
+export const CodeBlock = ({
+ code,
+ language,
+ showLineNumbers = false,
+ className,
+ children,
+ ...props
+}: CodeBlockProps) => (
+
+
+
+
+ {code}
+
+
+ {code}
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+);
+
+export type CodeBlockCopyButtonProps = ComponentProps & {
+ onCopy?: () => void;
+ onError?: (error: Error) => void;
+ timeout?: number;
+};
+
+export const CodeBlockCopyButton = ({
+ onCopy,
+ onError,
+ timeout = 2000,
+ children,
+ className,
+ ...props
+}: CodeBlockCopyButtonProps) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const { code } = useContext(CodeBlockContext);
+
+ const copyToClipboard = async () => {
+ if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
+ onError?.(new Error('Clipboard API not available'));
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(code);
+ setIsCopied(true);
+ onCopy?.();
+ setTimeout(() => setIsCopied(false), timeout);
+ } catch (error) {
+ onError?.(error as Error);
+ }
+ };
+
+ const Icon = isCopied ? CheckIcon : CopyIcon;
+
+ return (
+
+ );
+};
diff --git a/templates/chat-x402/src/components/ai-elements/conversation.tsx b/templates/chat-x402/src/components/ai-elements/conversation.tsx
new file mode 100644
index 000000000..756e81c2f
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/conversation.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { ArrowDownIcon } from 'lucide-react';
+import type { ComponentProps } from 'react';
+import { useCallback } from 'react';
+import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+export type ConversationProps = ComponentProps;
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+
+);
+
+export type ConversationContentProps = ComponentProps<
+ typeof StickToBottom.Content
+>;
+
+export const ConversationContent = ({
+ className,
+ ...props
+}: ConversationContentProps) => (
+
+);
+
+export type ConversationEmptyStateProps = ComponentProps<'div'> & {
+ title?: string;
+ description?: string;
+ icon?: React.ReactNode;
+};
+
+export const ConversationEmptyState = ({
+ className,
+ title = 'No messages yet',
+ description = 'Start a conversation to see messages here',
+ icon,
+ children,
+ ...props
+}: ConversationEmptyStateProps) => (
+
+ {children ?? (
+ <>
+ {icon &&
{icon}
}
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ >
+ )}
+
+);
+
+export type ConversationScrollButtonProps = ComponentProps;
+
+export const ConversationScrollButton = ({
+ className,
+ ...props
+}: ConversationScrollButtonProps) => {
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
+
+ const handleScrollToBottom = useCallback(() => {
+ scrollToBottom();
+ }, [scrollToBottom]);
+
+ return (
+ !isAtBottom && (
+
+ )
+ );
+};
diff --git a/templates/chat-x402/src/components/ai-elements/image.tsx b/templates/chat-x402/src/components/ai-elements/image.tsx
new file mode 100644
index 000000000..4f1de9477
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/image.tsx
@@ -0,0 +1,24 @@
+import type { Experimental_GeneratedImage } from 'ai';
+import { cn } from '@/lib/utils';
+
+export type ImageProps = Experimental_GeneratedImage & {
+ className?: string;
+ alt?: string;
+};
+
+export const Image = ({
+ base64,
+ uint8Array,
+ mediaType,
+ ...props
+}: ImageProps) => (
+
+);
diff --git a/templates/chat-x402/src/components/ai-elements/inline-citation.tsx b/templates/chat-x402/src/components/ai-elements/inline-citation.tsx
new file mode 100644
index 000000000..de89ef041
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/inline-citation.tsx
@@ -0,0 +1,287 @@
+'use client';
+
+import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
+import {
+ type ComponentProps,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { Badge } from '@/components/ui/badge';
+import {
+ Carousel,
+ type CarouselApi,
+ CarouselContent,
+ CarouselItem,
+} from '@/components/ui/carousel';
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from '@/components/ui/hover-card';
+import { cn } from '@/lib/utils';
+
+export type InlineCitationProps = ComponentProps<'span'>;
+
+export const InlineCitation = ({
+ className,
+ ...props
+}: InlineCitationProps) => (
+
+);
+
+export type InlineCitationTextProps = ComponentProps<'span'>;
+
+export const InlineCitationText = ({
+ className,
+ ...props
+}: InlineCitationTextProps) => (
+
+);
+
+export type InlineCitationCardProps = ComponentProps;
+
+export const InlineCitationCard = (props: InlineCitationCardProps) => (
+
+);
+
+export type InlineCitationCardTriggerProps = ComponentProps & {
+ sources: string[];
+};
+
+export const InlineCitationCardTrigger = ({
+ sources,
+ className,
+ ...props
+}: InlineCitationCardTriggerProps) => (
+
+
+ {sources.length ? (
+ <>
+ {new URL(sources[0]).hostname}{' '}
+ {sources.length > 1 && `+${sources.length - 1}`}
+ >
+ ) : (
+ 'unknown'
+ )}
+
+
+);
+
+export type InlineCitationCardBodyProps = ComponentProps<'div'>;
+
+export const InlineCitationCardBody = ({
+ className,
+ ...props
+}: InlineCitationCardBodyProps) => (
+
+);
+
+const CarouselApiContext = createContext(undefined);
+
+const useCarouselApi = () => {
+ const context = useContext(CarouselApiContext);
+ return context;
+};
+
+export type InlineCitationCarouselProps = ComponentProps;
+
+export const InlineCitationCarousel = ({
+ className,
+ children,
+ ...props
+}: InlineCitationCarouselProps) => {
+ const [api, setApi] = useState();
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselContent = (
+ props: InlineCitationCarouselContentProps
+) => ;
+
+export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselItem = ({
+ className,
+ ...props
+}: InlineCitationCarouselItemProps) => (
+
+);
+
+export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselHeader = ({
+ className,
+ ...props
+}: InlineCitationCarouselHeaderProps) => (
+
+);
+
+export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselIndex = ({
+ children,
+ className,
+ ...props
+}: InlineCitationCarouselIndexProps) => {
+ const api = useCarouselApi();
+ const [current, setCurrent] = useState(0);
+ const [count, setCount] = useState(0);
+
+ useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ setCount(api.scrollSnapList().length);
+ setCurrent(api.selectedScrollSnap() + 1);
+
+ api.on('select', () => {
+ setCurrent(api.selectedScrollSnap() + 1);
+ });
+ }, [api]);
+
+ return (
+
+ {children ?? `${current}/${count}`}
+
+ );
+};
+
+export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
+
+export const InlineCitationCarouselPrev = ({
+ className,
+ ...props
+}: InlineCitationCarouselPrevProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollPrev();
+ }
+ }, [api]);
+
+ return (
+
+ );
+};
+
+export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
+
+export const InlineCitationCarouselNext = ({
+ className,
+ ...props
+}: InlineCitationCarouselNextProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollNext();
+ }
+ }, [api]);
+
+ return (
+
+ );
+};
+
+export type InlineCitationSourceProps = ComponentProps<'div'> & {
+ title?: string;
+ url?: string;
+ description?: string;
+};
+
+export const InlineCitationSource = ({
+ title,
+ url,
+ description,
+ className,
+ children,
+ ...props
+}: InlineCitationSourceProps) => (
+
+ {title && (
+
{title}
+ )}
+ {url && (
+
{url}
+ )}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+);
+
+export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
+
+export const InlineCitationQuote = ({
+ children,
+ className,
+ ...props
+}: InlineCitationQuoteProps) => (
+
+ {children}
+
+);
diff --git a/templates/chat-x402/src/components/ai-elements/loader.tsx b/templates/chat-x402/src/components/ai-elements/loader.tsx
new file mode 100644
index 000000000..f6f568d75
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/loader.tsx
@@ -0,0 +1,96 @@
+import type { HTMLAttributes } from 'react';
+import { cn } from '@/lib/utils';
+
+type LoaderIconProps = {
+ size?: number;
+};
+
+const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
+
+);
+
+export type LoaderProps = HTMLAttributes & {
+ size?: number;
+};
+
+export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
+
+
+
+);
diff --git a/templates/chat-x402/src/components/ai-elements/message.tsx b/templates/chat-x402/src/components/ai-elements/message.tsx
new file mode 100644
index 000000000..797efaa70
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/message.tsx
@@ -0,0 +1,58 @@
+import type { UIMessage } from 'ai';
+import type { ComponentProps, HTMLAttributes } from 'react';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { cn } from '@/lib/utils';
+
+export type MessageProps = HTMLAttributes & {
+ from: UIMessage['role'];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+ div]:max-w-[80%]',
+ className
+ )}
+ {...props}
+ />
+);
+
+export type MessageContentProps = HTMLAttributes
;
+
+export const MessageContent = ({
+ children,
+ className,
+ ...props
+}: MessageContentProps) => (
+
+ {children}
+
+);
+
+export type MessageAvatarProps = ComponentProps & {
+ src: string;
+ name?: string;
+};
+
+export const MessageAvatar = ({
+ src,
+ name,
+ className,
+ ...props
+}: MessageAvatarProps) => (
+
+
+ {name?.slice(0, 2) || 'ME'}
+
+);
diff --git a/templates/chat-x402/src/components/ai-elements/prompt-input.tsx b/templates/chat-x402/src/components/ai-elements/prompt-input.tsx
new file mode 100644
index 000000000..f632e50f6
--- /dev/null
+++ b/templates/chat-x402/src/components/ai-elements/prompt-input.tsx
@@ -0,0 +1,230 @@
+'use client';
+
+import type { ChatStatus } from 'ai';
+import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
+import type {
+ ComponentProps,
+ HTMLAttributes,
+ KeyboardEventHandler,
+} from 'react';
+import { Children } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+import { cn } from '@/lib/utils';
+
+export type PromptInputProps = HTMLAttributes;
+
+export const PromptInput = ({ className, ...props }: PromptInputProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps & {
+ minHeight?: number;
+ maxHeight?: number;
+};
+
+export const PromptInputTextarea = ({
+ onChange,
+ className,
+ placeholder = 'What would you like to know?',
+ minHeight = 48,
+ maxHeight = 164,
+ ...props
+}: PromptInputTextareaProps) => {
+ const handleKeyDown: KeyboardEventHandler = e => {
+ if (e.key === 'Enter') {
+ // Don't submit if IME composition is in progress
+ if (e.nativeEvent.isComposing) {
+ return;
+ }
+
+ if (e.shiftKey) {
+ // Allow newline
+ return;
+ }
+
+ // Submit on Enter (without Shift)
+ e.preventDefault();
+ const form = e.currentTarget.form;
+ if (form) {
+ form.requestSubmit();
+ }
+ }
+ };
+
+ return (
+