From 594de446ad14495682ccce2e08d44b771acc36a0 Mon Sep 17 00:00:00 2001 From: Connor Etherington Date: Wed, 4 Mar 2026 09:47:40 +0200 Subject: [PATCH] fix: Chat x402 template Added a new next-402-chat template under templates/ that reuses the simple next-chat UI and adds a payment/auth switcher for Echo credits vs USDC via x402, wired client/server payment flows, and regis --- packages/sdk/echo-start/src/index.ts | 5 + templates/README.md | 17 ++ templates/next-402-chat/.env.local | 3 + templates/next-402-chat/.gitignore | 41 +++ templates/next-402-chat/README.md | 47 +++ templates/next-402-chat/components.json | 22 ++ templates/next-402-chat/eslint.config.mjs | 19 ++ templates/next-402-chat/next.config.ts | 10 + templates/next-402-chat/package.json | 73 +++++ templates/next-402-chat/postcss.config.mjs | 5 + templates/next-402-chat/public/file.svg | 1 + templates/next-402-chat/public/globe.svg | 1 + templates/next-402-chat/public/logo/dark.svg | 66 ++++ templates/next-402-chat/public/logo/light.svg | 66 ++++ templates/next-402-chat/public/next.svg | 1 + templates/next-402-chat/public/vercel.svg | 1 + templates/next-402-chat/public/window.svg | 1 + .../src/app/_components/chat.tsx | 245 +++++++++++++++ .../src/app/_components/echo/balance.tsx | 50 +++ .../app/_components/echo/sign-in-button.tsx | 17 ++ .../src/app/_components/header.tsx | 29 ++ .../next-402-chat/src/app/api/chat/route.ts | 80 +++++ .../src/app/api/echo/[...echo]/route.ts | 2 + templates/next-402-chat/src/app/favicon.ico | Bin 0 -> 15086 bytes templates/next-402-chat/src/app/globals.css | 124 ++++++++ templates/next-402-chat/src/app/layout.tsx | 40 +++ templates/next-402-chat/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 +++++++++++++++ .../next-402-chat/src/components/balance.tsx | 51 ++++ .../src/components/echo-account-next.tsx | 9 + .../src/components/echo-account.tsx | 77 +++++ .../src/components/echo-button.tsx | 86 ++++++ .../src/components/echo-popover.tsx | 56 ++++ .../next-402-chat/src/components/logo.tsx | 42 +++ .../src/components/money-input.tsx | 100 ++++++ .../src/components/top-up-button.tsx | 93 ++++++ .../src/components/ui/avatar.tsx | 53 ++++ .../next-402-chat/src/components/ui/badge.tsx | 46 +++ .../src/components/ui/button.tsx | 59 ++++ .../next-402-chat/src/components/ui/card.tsx | 92 ++++++ .../src/components/ui/carousel.tsx | 240 +++++++++++++++ .../src/components/ui/collapsible.tsx | 33 ++ .../src/components/ui/hover-card.tsx | 44 +++ .../next-402-chat/src/components/ui/input.tsx | 21 ++ .../src/components/ui/popover.tsx | 48 +++ .../src/components/ui/scroll-area.tsx | 58 ++++ .../src/components/ui/select.tsx | 185 +++++++++++ .../src/components/ui/skeleton.tsx | 13 + .../src/components/ui/textarea.tsx | 18 ++ .../src/components/ui/tooltip.tsx | 61 ++++ .../src/components/wallet/auth-guard.tsx | 82 +++++ .../src/components/wallet/config.ts | 18 ++ .../src/components/wallet/connect-button.tsx | 7 + .../src/components/wallet/header-account.tsx | 15 + .../src/components/wallet/index.ts | 4 + .../src/components/wallet/payment-mode.tsx | 59 ++++ .../src/components/wallet/wallet-provider.tsx | 19 ++ templates/next-402-chat/src/echo/index.ts | 5 + .../next-402-chat/src/lib/currency-utils.ts | 17 ++ templates/next-402-chat/src/lib/utils.ts | 6 + templates/next-402-chat/src/providers.tsx | 15 + templates/next-402-chat/tsconfig.json | 27 ++ 77 files changed, 4767 insertions(+) create mode 100644 templates/next-402-chat/.env.local create mode 100644 templates/next-402-chat/.gitignore create mode 100644 templates/next-402-chat/README.md create mode 100644 templates/next-402-chat/components.json create mode 100644 templates/next-402-chat/eslint.config.mjs create mode 100644 templates/next-402-chat/next.config.ts create mode 100644 templates/next-402-chat/package.json create mode 100644 templates/next-402-chat/postcss.config.mjs create mode 100644 templates/next-402-chat/public/file.svg create mode 100644 templates/next-402-chat/public/globe.svg create mode 100644 templates/next-402-chat/public/logo/dark.svg create mode 100644 templates/next-402-chat/public/logo/light.svg create mode 100644 templates/next-402-chat/public/next.svg create mode 100644 templates/next-402-chat/public/vercel.svg create mode 100644 templates/next-402-chat/public/window.svg create mode 100644 templates/next-402-chat/src/app/_components/chat.tsx create mode 100644 templates/next-402-chat/src/app/_components/echo/balance.tsx create mode 100644 templates/next-402-chat/src/app/_components/echo/sign-in-button.tsx create mode 100644 templates/next-402-chat/src/app/_components/header.tsx create mode 100644 templates/next-402-chat/src/app/api/chat/route.ts create mode 100644 templates/next-402-chat/src/app/api/echo/[...echo]/route.ts create mode 100644 templates/next-402-chat/src/app/favicon.ico create mode 100644 templates/next-402-chat/src/app/globals.css create mode 100644 templates/next-402-chat/src/app/layout.tsx create mode 100644 templates/next-402-chat/src/app/page.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/actions.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/branch.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/code-block.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/conversation.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/image.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/inline-citation.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/loader.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/message.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/prompt-input.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/reasoning.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/response.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/sources.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/suggestion.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/task.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/tool.tsx create mode 100644 templates/next-402-chat/src/components/ai-elements/web-preview.tsx create mode 100644 templates/next-402-chat/src/components/balance.tsx create mode 100644 templates/next-402-chat/src/components/echo-account-next.tsx create mode 100644 templates/next-402-chat/src/components/echo-account.tsx create mode 100644 templates/next-402-chat/src/components/echo-button.tsx create mode 100644 templates/next-402-chat/src/components/echo-popover.tsx create mode 100644 templates/next-402-chat/src/components/logo.tsx create mode 100644 templates/next-402-chat/src/components/money-input.tsx create mode 100644 templates/next-402-chat/src/components/top-up-button.tsx create mode 100644 templates/next-402-chat/src/components/ui/avatar.tsx create mode 100644 templates/next-402-chat/src/components/ui/badge.tsx create mode 100644 templates/next-402-chat/src/components/ui/button.tsx create mode 100644 templates/next-402-chat/src/components/ui/card.tsx create mode 100644 templates/next-402-chat/src/components/ui/carousel.tsx create mode 100644 templates/next-402-chat/src/components/ui/collapsible.tsx create mode 100644 templates/next-402-chat/src/components/ui/hover-card.tsx create mode 100644 templates/next-402-chat/src/components/ui/input.tsx create mode 100644 templates/next-402-chat/src/components/ui/popover.tsx create mode 100644 templates/next-402-chat/src/components/ui/scroll-area.tsx create mode 100644 templates/next-402-chat/src/components/ui/select.tsx create mode 100644 templates/next-402-chat/src/components/ui/skeleton.tsx create mode 100644 templates/next-402-chat/src/components/ui/textarea.tsx create mode 100644 templates/next-402-chat/src/components/ui/tooltip.tsx create mode 100644 templates/next-402-chat/src/components/wallet/auth-guard.tsx create mode 100644 templates/next-402-chat/src/components/wallet/config.ts create mode 100644 templates/next-402-chat/src/components/wallet/connect-button.tsx create mode 100644 templates/next-402-chat/src/components/wallet/header-account.tsx create mode 100644 templates/next-402-chat/src/components/wallet/index.ts create mode 100644 templates/next-402-chat/src/components/wallet/payment-mode.tsx create mode 100644 templates/next-402-chat/src/components/wallet/wallet-provider.tsx create mode 100644 templates/next-402-chat/src/echo/index.ts create mode 100644 templates/next-402-chat/src/lib/currency-utils.ts create mode 100644 templates/next-402-chat/src/lib/utils.ts create mode 100644 templates/next-402-chat/src/providers.tsx create mode 100644 templates/next-402-chat/tsconfig.json diff --git a/packages/sdk/echo-start/src/index.ts b/packages/sdk/echo-start/src/index.ts index c249d50d1..1e79397df 100644 --- a/packages/sdk/echo-start/src/index.ts +++ b/packages/sdk/echo-start/src/index.ts @@ -44,6 +44,11 @@ const DEFAULT_TEMPLATES = { description: 'Full-stack Next.js application with Echo and the Vercel AI SDK', }, + 'next-402-chat': { + title: 'Next.js Chat x402', + description: + 'Next.js chat app with an auth switcher for Echo credits or x402 USDC', + }, 'next-image': { title: 'Next.js Image Gen', description: diff --git a/templates/README.md b/templates/README.md index d95c84c8e..8bb0a5c55 100644 --- a/templates/README.md +++ b/templates/README.md @@ -127,6 +127,23 @@ npx echo-start@latest --template next-chat --- +#### Next.js Chat x402 (`next-402-chat`) + +A simple chat template with a payment/auth switcher for Echo credits or USDC via x402. + +```bash +npx echo-start@latest --template next-402-chat +``` + +**Features:** + +- Simple chat UI based on the standard Next.js chat template +- Auth switcher between Echo credits and wallet-based x402 +- USDC payments via x402 with wallet connection +- Echo credits flow with built-in Echo authentication + +--- + #### Next.js Image Generation (`next-image`) Image generation application with Echo billing. diff --git a/templates/next-402-chat/.env.local b/templates/next-402-chat/.env.local new file mode 100644 index 000000000..fd6f8687c --- /dev/null +++ b/templates/next-402-chat/.env.local @@ -0,0 +1,3 @@ +ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" +NEXT_PUBLIC_ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID="YOUR_PROJECT_ID" diff --git a/templates/next-402-chat/.gitignore b/templates/next-402-chat/.gitignore new file mode 100644 index 000000000..e72b4d6a4 --- /dev/null +++ b/templates/next-402-chat/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/templates/next-402-chat/README.md b/templates/next-402-chat/README.md new file mode 100644 index 000000000..1ca095c9d --- /dev/null +++ b/templates/next-402-chat/README.md @@ -0,0 +1,47 @@ +# Echo Next.js Chat x402 Template + +A minimal Next.js chat template with an auth switcher that lets users choose how to pay: + +- Echo credits (Echo auth) +- USDC via x402 (wallet auth) + +## Quick Start + +```bash +npx echo-start@latest --template next-402-chat +``` + +Then configure env vars: + +```bash +ECHO_APP_ID=your_app_id +NEXT_PUBLIC_ECHO_APP_ID=your_app_id +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id +``` + +## Features + +- Simple chat UI from the standard `next-chat` template +- Payment/auth switcher on the sign-in screen +- Echo credits flow via `@merit-systems/echo-next-sdk` +- USDC x402 flow via `@merit-systems/ai-x402` +- Wallet connection via RainbowKit + wagmi + +## How It Works + +- `credits` mode: + - User signs in with Echo + - API uses `openai(model)` from Echo SDK +- `x402` mode: + - User connects wallet + - Client uses `useChatWithPayment` + - API uses `createX402OpenAIWithoutPayment` and reads `x-payment` header + +## Run Locally + +```bash +pnpm install +pnpm dev +``` + +Open `http://localhost:3000`. diff --git a/templates/next-402-chat/components.json b/templates/next-402-chat/components.json new file mode 100644 index 000000000..edcaef267 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/eslint.config.mjs b/templates/next-402-chat/eslint.config.mjs new file mode 100644 index 000000000..c884132a1 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/next.config.ts b/templates/next-402-chat/next.config.ts new file mode 100644 index 000000000..1332615e1 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/package.json b/templates/next-402-chat/package.json new file mode 100644 index 000000000..8c4f8599e --- /dev/null +++ b/templates/next-402-chat/package.json @@ -0,0 +1,73 @@ +{ + "name": "next-402-chat-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/ai-x402": "latest", + "@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-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.7.1", + "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/next-402-chat/postcss.config.mjs b/templates/next-402-chat/postcss.config.mjs new file mode 100644 index 000000000..f50127cda --- /dev/null +++ b/templates/next-402-chat/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/templates/next-402-chat/public/file.svg b/templates/next-402-chat/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/templates/next-402-chat/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/next-402-chat/public/globe.svg b/templates/next-402-chat/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/templates/next-402-chat/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/next-402-chat/public/logo/dark.svg b/templates/next-402-chat/public/logo/dark.svg new file mode 100644 index 000000000..31c6d2b07 --- /dev/null +++ b/templates/next-402-chat/public/logo/dark.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/next-402-chat/public/logo/light.svg b/templates/next-402-chat/public/logo/light.svg new file mode 100644 index 000000000..4462aad0a --- /dev/null +++ b/templates/next-402-chat/public/logo/light.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/next-402-chat/public/next.svg b/templates/next-402-chat/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/templates/next-402-chat/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/next-402-chat/public/vercel.svg b/templates/next-402-chat/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/templates/next-402-chat/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/next-402-chat/public/window.svg b/templates/next-402-chat/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/templates/next-402-chat/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/next-402-chat/src/app/_components/chat.tsx b/templates/next-402-chat/src/app/_components/chat.tsx new file mode 100644 index 000000000..9a2b18c82 --- /dev/null +++ b/templates/next-402-chat/src/app/_components/chat.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { useChatWithPayment } from '@merit-systems/ai-x402/client'; +import { CopyIcon, MessageSquare } from 'lucide-react'; +import { Fragment, useState } from 'react'; +import { useWalletClient } from 'wagmi'; +import { usePaymentMode } from '@/components/wallet/payment-mode'; +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'; +import type { Signer } from 'x402/types'; + +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 { paymentMode } = usePaymentMode(); + const { data: walletClient } = useWalletClient(); + const echoChat = useChat(); + const x402Chat = useChatWithPayment({ + walletClient: walletClient as unknown as Signer, + regenerateOptions: { + body: { + model: model, + paymentMethod: 'x402', + }, + }, + }); + const activeChat = paymentMode === 'x402' ? x402Chat : echoChat; + const { messages, sendMessage, status } = activeChat; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (input.trim()) { + sendMessage( + { text: input }, + { + body: { + model: model, + paymentMethod: paymentMode, + }, + } + ); + setInput(''); + } + }; + + const handleSuggestionClick = (suggestion: string) => { + sendMessage( + { text: suggestion }, + { + body: { + model: model, + paymentMethod: paymentMode, + }, + } + ); + }; + + 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/next-402-chat/src/app/_components/echo/balance.tsx b/templates/next-402-chat/src/app/_components/echo/balance.tsx new file mode 100644 index 000000000..76cec986e --- /dev/null +++ b/templates/next-402-chat/src/app/_components/echo/balance.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useEcho } from '@merit-systems/echo-next-sdk/client'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; + +interface BalanceData { + balance: number; + currency?: string; +} + +export default function Balance() { + const [balanceData, setBalanceData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const { balance, echoClient } = useEcho(); + + useEffect(() => { + if (balance) { + setBalanceData({ balance: balance.balance || 0, currency: 'USD' }); + setLoading(false); + } + }, [balance]); + + const formatBalance = (amount: number, currency = 'USD') => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(amount); + }; + + return ( + + ); +} diff --git a/templates/next-402-chat/src/app/_components/echo/sign-in-button.tsx b/templates/next-402-chat/src/app/_components/echo/sign-in-button.tsx new file mode 100644 index 000000000..a6fc1a3bb --- /dev/null +++ b/templates/next-402-chat/src/app/_components/echo/sign-in-button.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useEcho } from '@merit-systems/echo-next-sdk/client'; +import { Button } from '@/components/ui/button'; + +export default function SignInButton() { + const { signIn } = useEcho(); + + return ( + + ); +} diff --git a/templates/next-402-chat/src/app/_components/header.tsx b/templates/next-402-chat/src/app/_components/header.tsx new file mode 100644 index 000000000..8ccb8fe0b --- /dev/null +++ b/templates/next-402-chat/src/app/_components/header.tsx @@ -0,0 +1,29 @@ +import { HeaderAccount } from '@/components/wallet'; +import type { FC } from 'react'; + +interface HeaderProps { + title?: string; + className?: string; +} + +const Header: FC = ({ title = 'My App', className = '' }) => { + return ( +
+
+
+
+

{title}

+
+ + +
+
+
+ ); +}; + +export default Header; diff --git a/templates/next-402-chat/src/app/api/chat/route.ts b/templates/next-402-chat/src/app/api/chat/route.ts new file mode 100644 index 000000000..26e1526b2 --- /dev/null +++ b/templates/next-402-chat/src/app/api/chat/route.ts @@ -0,0 +1,80 @@ +import { convertToModelMessages, streamText, type UIMessage } from 'ai'; +import { + createX402OpenAIWithoutPayment, + UiStreamOnError, +} from '@merit-systems/ai-x402/server'; +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, + paymentMethod, + }: { + messages: UIMessage[]; + model: string; + paymentMethod?: 'credits' | 'x402'; + } = 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 shouldUseX402 = paymentMethod === 'x402'; + const result = streamText({ + model: shouldUseX402 + ? createX402OpenAIWithoutPayment({ + paymentAuthHeader: req.headers.get('x-payment'), + echoAppId: process.env.ECHO_APP_ID, + })(model) + : openai(model), + messages: convertToModelMessages(messages), + maxRetries: 0, + }); + + return result.toUIMessageStreamResponse({ + sendSources: true, + sendReasoning: true, + onError: shouldUseX402 ? UiStreamOnError() : undefined, + }); + } 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/next-402-chat/src/app/api/echo/[...echo]/route.ts b/templates/next-402-chat/src/app/api/echo/[...echo]/route.ts new file mode 100644 index 000000000..c7296d950 --- /dev/null +++ b/templates/next-402-chat/src/app/api/echo/[...echo]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/echo'; +export const { GET, POST } = handlers; diff --git a/templates/next-402-chat/src/app/favicon.ico b/templates/next-402-chat/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b30699db5223b20f4c9a978cff68fd9ae68e4497 GIT binary patch literal 15086 zcmeHO&ube;6dqe{T}ZGTdTMJDg;LU^OCXqFu&g$4@Fp-C@2 zrS#A?G@%VN6eUp_QDVq{P~`Fxp&wj* zJD)$eX8CbE6LSS^zF+?uu_6D$qsHRfWzDIg{GpeY1K<%G(#O}FZ+>TjG5K*ICbVf= z!~qWA!gkb)dPW?HT=;SHi@)aXFsjUnmXDib zRdOf)U$2iuD8F1Et!Z6dhfm2@Jg3;#Z~nlcZ(nNO;O_I!i$mv#zWdD|81zjU;+@4G zafIfS_^xma=zGxog=d{yLlX0!c@IfFr;hn=$KP%K`op>D&!6M`zh0A$=TCp<&!fM_ zqPstjMz?`u8l-}1J#rlKq`TB38wW6F8dSb_Y0_0Kg2X_7TShaq;;8=LPVUN#Y@W=it zYY&|+`r{M_Yo5^F{d!UQn*Xb?K>jwJ0a0VUkBk2TRg+j7bM15 zKWm_lHm#%U7!Q4|>)@Hj8+w9opTR|^=jTU0doun|92z?>j(zYBzCS=Lf|dLJfq2XT z;y|74lzBO%9%M+@AG4sbyJMW@CBuJDNu>L~1k)cw#Y zz?<){67K|It3Ti_{t-vlJ767$^mn}n7fy*d5L2(b5r^g@I5BV3bzfFp*>%OCbCnWD zBG;nRm2Y={1?M<ATB4$q$R`2Is&rESrb1aH_XUjygua}{j6-iC4} zZK><+QS;Vunpu{8IEIRW~NR{oD%m8;*QqFH@6^} zmz{Rm=81WWCSKY+Mh%x&=6T2F9lz76NU2I0#P77++{b;3wRT|hNa!a%p?i*N2xLOJ zj~B$yvEn;XKR-EcUHi&|(r|v`90S)Cdxj|e@xDUc!SoF&r9wU{tK<; zktacC0d`VkkAH(>VSMXvRB>jbAK%c{jW2HVMu)zU3Y!C4OCdWi{6FG6X zUHg|TTw+m@2PE4<@i6u*9fzBUiTZhxw&cNF_~RMkMq9`{oV3E#un4sO^CZ1QQKPc%i?Yz=C=@rkuropR&N0*}L+No&o zqbn;rk1m~gb#k(~F@5s=YmLTxSJ#%lS)ZEuczt=|#QH+%#CkQQpl?ZZre;rE9dAE| z?ZfGlpKZN0`2;@q&R$uGzJ5orKIyy`l2=4{Pa*$eX5Urp=RF0-9OI$Kq?bIn`{#UU zY(sviM?2_nE<&bpAy?W#a_tnG1=O`4=))H|piW!pvanyS?YamFKVqLaQ>8unVGBF# z=~49WHqnQYVUPZb-V^gTSbP>jJo)yW-y&l_PkZj2da>90&|d8SzB|_S`xNe-3gFxP zh4vv?`9n@HHpm|^ATbX3z)t#WkQ`^xiN|01?0nF31iH=-#>CpOANnXA@2~&DCkp2X zBzlRNX7}1dydGOVwA1}X>*SJ$xhwBR_vHQW=D0Dn5o1) { + return ( + + + +
+
{children}
+ + + + ); +} diff --git a/templates/next-402-chat/src/app/page.tsx b/templates/next-402-chat/src/app/page.tsx new file mode 100644 index 000000000..908e5895a --- /dev/null +++ b/templates/next-402-chat/src/app/page.tsx @@ -0,0 +1,12 @@ +import Chat from '@/app/_components/chat'; +import { AuthGuard } from '@/components/wallet'; +import { isSignedIn } from '@/echo'; + +export default async function Home() { + const signedIn = await isSignedIn(); + return ( + + + + ); +} diff --git a/templates/next-402-chat/src/components/ai-elements/actions.tsx b/templates/next-402-chat/src/components/ai-elements/actions.tsx new file mode 100644 index 000000000..c806be915 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/src/components/ai-elements/branch.tsx b/templates/next-402-chat/src/components/ai-elements/branch.tsx new file mode 100644 index 000000000..902afccf9 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/src/components/ai-elements/code-block.tsx b/templates/next-402-chat/src/components/ai-elements/code-block.tsx new file mode 100644 index 000000000..1574767fb --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/src/components/ai-elements/conversation.tsx b/templates/next-402-chat/src/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..756e81c2f --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/src/components/ai-elements/image.tsx b/templates/next-402-chat/src/components/ai-elements/image.tsx new file mode 100644 index 000000000..4f1de9477 --- /dev/null +++ b/templates/next-402-chat/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) => ( + {props.alt} +); diff --git a/templates/next-402-chat/src/components/ai-elements/inline-citation.tsx b/templates/next-402-chat/src/components/ai-elements/inline-citation.tsx new file mode 100644 index 000000000..de89ef041 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/src/components/ai-elements/loader.tsx b/templates/next-402-chat/src/components/ai-elements/loader.tsx new file mode 100644 index 000000000..f6f568d75 --- /dev/null +++ b/templates/next-402-chat/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) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/templates/next-402-chat/src/components/ai-elements/message.tsx b/templates/next-402-chat/src/components/ai-elements/message.tsx new file mode 100644 index 000000000..797efaa70 --- /dev/null +++ b/templates/next-402-chat/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/next-402-chat/src/components/ai-elements/prompt-input.tsx b/templates/next-402-chat/src/components/ai-elements/prompt-input.tsx new file mode 100644 index 000000000..f632e50f6 --- /dev/null +++ b/templates/next-402-chat/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 ( +